从单体到模块化:不要急着拆微服务

0阅读10分钟

单体不是原罪

我见过太多团队把「重构」等同于「拆微服务」。上线半年的订单系统,代码耦合严重,部署一次要等 40 分钟,技术负责人就说「我们要拆微服务」。拆了半年,线上故障反而多了——原来本地方法调用变成了网络超时,原来一个事务就能完成的操作现在要处理分布式一致性。

单体架构不是原罪。没有模块边界的单体才是。 一个早期项目用单体是合理选择:部署简单、调试直接、事务边界清楚。问题出在演进过程中,团队不断往同一个代码库里堆功能,模块之间互相调用、共享数据库表,最终变成 Chris Richardson 说的「大泥球」——所有代码搅在一起,没人说得清某个功能的完整调用链。

这种时候拆微服务,只是把代码层面的问题变成网络层面的问题。

模块化单体:先在同一进程里画清边界

模块化单体(Modular Monolith)的核心思路是:仍然部署为一个应用,但内部按业务域划分模块,每个模块拥有独立的模型、服务、接口和测试,跨模块通信走公开契约而非直接引用内部类。

这个概念并不新。DDD 里的「限界上下文」(Bounded Context)就是用来划定语义边界的。Milan Jovanović 在 2025 年的文章里专门讨论过「过长的限界上下文」——当一个模块承担了太多职责,说明它内部的子域已经应该被进一步拆分。关键是,这种拆分不需要等到微服务阶段才做,在单体里就可以开始。

模块化单体的核心优势在于:

  • 团队可以独立理解和修改局部能力,不需要每次改订单模块都去读支付模块的代码
  • 模块间的依赖关系是显式的,可以通过工具检测和阻止
  • 数据所有权可以在代码层面先确立,为未来可能的服务拆分做准备
  • 如果最终不需要拆分,你省掉了一整套分布式系统的复杂度

2026 年的行业共识已经比较明确:团队不到 10 人、部署频率低于每天一次、业务域还在快速变化——这些情况下模块化单体是比微服务更合适的选择。正如 enqcode 一篇文章里说的:「架构不是意识形态,是上下文。」

案例一:订单模块与支付模块的隐式耦合

场景

一个电商系统,订单服务和支付服务跑在同一个 Spring Boot 进程里。代码分层是按技术职责切的——controller、service、repository、entity 各一个包。订单创建流程里直接调用 PaymentRepository 写入支付记录。

翻车

产品要求新增「先用后付」支付方式。开发去改支付相关逻辑,发现 PaymentRepository 被 7 个不同的 Service 引用,其中 3 个是订单模块的业务代码。改一个字段映射,测试跑了两天还没全绿。

更麻烦的是数据:payments 表有 14 个字段,其中 order_iduser_idtotal_amount 同时出现在 orders 表里,两边数据不一致已经发生过三次线上告警。

修复

第一步不是拆服务,而是把模块边界画清楚。

// ❌ 坏做法:订单服务直接引用支付模块的 Repository
// OrderService.ts
import { PaymentRepository } from '@/repositories/PaymentRepository'
 
export class OrderService {
  constructor(
    private paymentRepo: PaymentRepository,  // 直接跨模块引用
    private orderRepo: OrderRepository,
  ) {}
 
  async createOrder(dto: CreateOrderDTO) {
    const order = await this.orderRepo.create(dto)
    // 订单模块直接写支付表
    await this.paymentRepo.create({
      orderId: order.id,
      userId: order.userId,
      amount: order.totalAmount,
      status: 'PENDING',
    })
    return order
  }
}
// ✅ 好做法:通过公开接口和事件通信
// modules/payment/paymentModule.ts
// 支付模块暴露一个明确的接口
export interface PaymentService {
  createPayment(params: {
    orderId: string
    userId: string
    amount: number
  }): Promise<PaymentResult>
}
 
// modules/order/OrderService.ts
// 订单模块只依赖抽象接口,不关心支付实现
export class OrderService {
  constructor(
    private paymentService: PaymentService,  // 依赖接口而非具体实现
    private orderRepo: OrderRepository,
    private eventBus: EventBus,
  ) {}
 
  async createOrder(dto: CreateOrderDTO) {
    const order = await this.orderRepo.create(dto)
 
    // 通过接口调用,而非直接操作对方的数据库
    const paymentResult = await this.paymentService.createPayment({
      orderId: order.id,
      userId: order.userId,
      amount: order.totalAmount,
    })
 
    // 发布事件,其他模块按需订阅
    await this.eventBus.publish('order.created', {
      orderId: order.id,
      status: order.status,
      paymentId: paymentResult.id,
    })
 
    return order
  }
}

关键改动:

  1. 订单模块不再直接引用支付模块的 Repository
  2. 支付模块暴露明确的 PaymentService 接口
  3. 跨模块通信走事件总线,减少同步依赖
  4. 每个模块拥有自己的数据库表,通过 ID 关联而非直接读写

数据所有权:最难的不是代码

服务拆分最难的环节通常不是代码迁移,而是数据归属。哪些表属于哪个业务域,哪些字段可以被其他模块读取,哪些写入必须走接口——这些在动手之前必须定义清楚。

没有数据所有权的模块化是空中楼阁。两个模块写同一张表,就算代码层面接口再干净,底层还是一个共享状态的泥球。

数据所有权有三条基本规则:

规则含义违反后的后果
单点写入每张表只有一个模块有权写入并发冲突、数据不一致
读分离其他模块通过查询接口或事件获取数据跨模块 JOIN 查询导致表结构互相绑架
ID 引用跨模块关联只用 ID,不存冗余业务字段字段变更需要多个模块同步改
// ❌ 坏做法:多个模块直接写同一张表
// InventoryService.ts
export class InventoryService {
  async deductStock(orderId: string, items: OrderItem[]) {
    // 直接操作库存表,订单模块也在写这张表
    await db.inventory.update({
      where: { itemId: items[0].itemId },
      data: { stock: { decrement: items[0].quantity } },
    })
  }
}
// ✅ 好做法:每个模块拥有自己的表,通过事件同步
// modules/inventory/InventoryService.ts
export class InventoryService {
  constructor(
    private inventoryRepo: InventoryRepository,  // 只操作自己模块的表
    private eventBus: EventBus,
  ) {}
 
  // 库存模块独占 inventory 表的写入权
  async handleOrderPlaced(event: OrderPlacedEvent) {
    const result = await this.inventoryRepo.deductBatch(
      event.items.map(item => ({
        itemId: item.itemId,
        quantity: item.quantity,
      })),
    )
 
    // 通过事件通知其他模块
    await this.eventBus.publish('inventory.deducted', {
      orderId: event.orderId,
      results: result,
    })
  }
}

案例二:用户信息的全局污染

场景

一个 SaaS 平台,users 表有 50 多个字段,从登录凭证、个人资料、权限角色到收货地址、支付偏好全在里面。几乎每个模块都直接查这张表:订单模块读 user_iddefault_address_id,权限模块读 role_id,通知模块读 emailphone

翻车

安全审计要求把用户密码字段从明文改为 bcrypt 哈希。改完之后,3 个模块的登录功能正常,但「一键复购」功能挂了——它直接用 user.password 做了一次 MD5 校验(历史遗留逻辑)。还有一个内部报表模块直接拼接 SQL 查用户表,字段类型改了以后报表全空白。

问题根源:50 个字段放在一张表里,每个团队都觉得「改个字段而已」,实际上整张表是所有人的公共厕所。

修复

users 表按业务域拆成独立的子模块,每个子模块管自己的字段:

modules/
  auth/          → auth_credentials 表 (email, password_hash, oauth_provider)
  profile/       → user_profiles 表 (nickname, avatar_url, bio)
  address/       → user_addresses 表 (省市区, 详细地址, 默认标记)
  notification/  → notification_preferences 表 (email, phone, push_token)
  permission/    → roles, permissions, role_permissions 表

跨模块只通过 user_id 关联,不直接读对方模块的表。

// ❌ 坏做法:全局共享一张大宽表
// 任何模块都可以直接查 users 表的任何字段
const user = await db.users.findUnique({
  where: { id: userId },
  // 查了 20 个字段,其中 18 个跟当前业务无关
  select: {
    id: true, email: true, password: true, nickname: true,
    avatar: true, phone: true, role: true, address_id: true,
    payment_pref: true, created_at: true, // ... 还有 10 个
  },
})
// ✅ 好做法:每个子模块只暴露自己领域的查询接口
// modules/auth/AuthQueryService.ts
export class AuthQueryService {
  // 只暴露认证相关的信息
  async getCredentials(userId: string) {
    return db.auth_credentials.findUnique({
      where: { userId },
      select: { email: true, passwordHash: true, mfaEnabled: true },
    })
  }
}
 
// modules/profile/ProfileQueryService.ts
export class ProfileQueryService {
  // 只暴露个人资料
  async getProfile(userId: string) {
    return db.user_profiles.findUnique({
      where: { userId },
      select: { nickname: true, avatarUrl: true, bio: true },
    })
  }
}
 
// 业务代码只请求自己需要的模块接口
const profile = await profileQueryService.getProfile(userId)

拆分后,改密码哈希逻辑只需要关注 auth 模块,不会影响订单、报表和其他功能。每个模块的表结构变更被限制在自己的边界内。

演进路径:什么时候该拆服务

模块化单体是一个稳定的架构形态,不是过渡态。很多系统保持在模块化单体阶段就足够了。只有当以下条件出现时,才值得考虑进一步拆成微服务:

流程图画布 · 115%
Mermaid 流程图加载中...
拆分信号说明优先级
独立扩展需求某个模块的负载远高于其他,需要单独扩容
独立部署节奏某模块每天发版 10 次,其他模块一周一次
团队规模增长超过 3 个团队维护同一代码库,合并冲突频繁
技术栈差异某模块需要用不同语言或运行时,单体无法满足
故障隔离某模块的不稳定经常拖垮整个应用
「领导觉得该拆」没有实际痛点,只是觉得微服务更先进低——不要拆

案例三:通知模块的独立部署之路

场景

一个内容平台,邮件和推送通知功能嵌在主应用里。通知模块本身逻辑不复杂,但有两个特点:发送量大时 CPU 占用高,以及第三方推送服务偶尔超时会拖慢主请求。

翻车

双十一活动期间,推送量暴增,通知模块的处理线程全部阻塞在第三方 API 调用上。主应用的请求队列也满了——因为通知处理和订单提交跑在同一个进程里,共享同一个线程池。用户下单超时率从 0.1% 飙升到 12%。

紧急回滚后,团队决定把通知模块拆出来。

修复

因为之前已经做了模块化重构,通知模块有独立的接口和事件订阅,拆分过程比预期顺利得多。关键步骤:

  1. 通知模块已经有自己的 NotificationService 接口
  2. 订单模块通过 order.created 事件触发通知,不依赖同步调用
  3. 拆分只需要把事件消费者从主进程挪到独立服务
// ❌ 坏做法:拆分前,通知处理在主进程内,同步阻塞
// modules/notification/EmailNotifier.ts
export class EmailNotifier {
  constructor(
    private emailClient: EmailClient,
    private templateEngine: TemplateEngine,
  ) {}
 
  // 同步发送,第三方超时会阻塞调用方
  async sendOrderConfirmation(userId: string, order: OrderInfo) {
    const html = await this.templateEngine.render('order-confirm', { order })
    // 直接调用外部 API,没有超时保护和重试机制
    await this.emailClient.send({
      to: userId,
      subject: `订单 ${order.id} 确认`,
      html,
    })
  }
}
// ✅ 拆分后:异步消费 + 超时保护 + 独立部署
// services/notification/NotificationWorker.ts
export class NotificationWorker {
  constructor(
    private messageQueue: MessageQueue,
    private emailClient: EmailClient,
    private retryPolicy: RetryPolicy,
    private circuitBreaker: CircuitBreaker,  // 熔断器
  ) {}
 
  async start() {
    // 独立消费者,不影响主应用线程
    await this.messageQueue.consume('order.created', async (event) => {
      // 带超时和重试的发送
      await this.retryPolicy.execute(
        () => this.circuitBreaker.execute(
          () => this.emailClient.send({
            to: event.userId,
            subject: `订单 ${event.orderId} 确认`,
            html: event.renderedHtml,  // 渲染在主进程完成
          }),
        ),
        { maxRetries: 3, backoff: 'exponential' },
      )
    })
  }
}

拆分后,通知模块独立部署、独立扩容。主应用在发送事件后就返回,不再等待第三方 API 响应。双十一再遇到推送高峰,通知服务可以单独加机器,不影响订单链路。

这个案例能顺利拆分,前提是之前花了两个月做模块化重构——虽然当时没有拆服务的计划,但模块边界的清晰化为后续拆分铺平了道路。

对比总结

维度大泥球单体模块化单体微服务
代码组织按技术层分包,模块间随意调用按业务域分模块,跨模块走接口按业务域拆服务,跨服务走网络
数据库所有模块共享同一个库,任意表互访逻辑隔离,每个模块拥有自己的表物理隔离,每个服务独占数据库
部署方式整体部署整体部署独立部署
团队影响所有人改同一个代码库各团队负责自己的模块各团队负责自己的服务
调试难度低(单进程)低(单进程)高(分布式追踪)
运维复杂度
适用阶段MVP / 早期验证成长期 / 业务域趋于稳定大规模 / 多团队 / 高频发版
模块化重构手段解决的问题实施成本可逆性
目录结构重组代码组织混乱完全可逆
模块接口抽象模块间隐式耦合可逆但需改调用方
数据所有权划分共享数据库表中高需要数据迁移
事件总线引入同步调用链过长需要改造调用逻辑
契约测试接口变更导致的隐式破坏可随时去掉
拆分前的准备项模块化单体阶段是否必须说明
模块接口定义必须没有接口就没有边界
数据所有权划分必须拆分时不需要重新归属数据
契约测试强烈建议保证接口变更不破坏其他模块
事件驱动通信建议降低同步依赖,便于拆分后异步通信
独立数据库不必须逻辑隔离即可,物理隔离是拆分时的步骤
独立部署流水线不必须拆分时再建
分布式追踪不必须拆分时再引入
服务发现/注册不必须拆分时再引入

模块化重构检查清单

阶段一:现状摸底

  • 画出当前模块间的调用关系图(可以用依赖分析工具自动生成)
  • 统计每个模块被其他模块直接引用的次数,找出「热点模块」
  • 列出所有跨模块的数据库表访问,标记哪些是读、哪些是写
  • 确认团队对业务域的理解是否一致(可以做一次简单的 Event Storming)

阶段二:边界划分

  • 按业务能力(Business Capability)或子域(Subdomain)初步划分模块
  • 为每个模块定义公开接口(只暴露其他模块需要的能力)
  • 确定每张数据库表的归属模块,标记所有权
  • 识别共享的「通用概念」(如用户 ID、订单 ID),定义跨模块约定

阶段三:代码重构

  • 按模块重组目录结构,禁止模块内代码引用另一个模块的内部实现
  • 将跨模块的直接调用改为接口调用或事件发布
  • 消除跨模块的数据库直接查询,改为通过归属模块的接口获取
  • 引入架构守护工具(如 ArchUnit、dependency-cruiser、eslint-plugin-boundaries)阻止边界退化

阶段四:验证与守护

  • 为每个模块的公开接口编写契约测试
  • 在 CI 中增加模块依赖检查,新的违规引用直接阻断合并
  • 监控模块间的调用频次和延迟,建立基线
  • 定期 review 模块边界是否仍然合理,业务变化后及时调整

写在最后

架构演进的驱动力应该是具体问题,而不是技术趋势。模块化单体的价值在于:它让你在不引入分布式复杂度的前提下,获得清晰的代码边界和可测试的模块接口。这些工作不会因为最终没有拆微服务而白费——即使永远不拆,一个边界清楚的单体也比一个纠缠在一起的大泥球好维护得多。

如果最终确实需要拆分,模块化阶段积累下来的接口定义、数据归属和契约测试,会让拆分过程从一个「推倒重来」的项目变成一个「逐步提取」的工程操作。

不要急着拆服务。先把边界画对。

参考资料

评论 0

0 / 1000