模块化单体:什么时候不该上微服务

微服务(Microservice)解决的是「多团队独立交付」和「按需独立部署/伸缩」这两类问题,它本身不提升架构质量,也不提升单机吞吐。当团队只有一个、业务还在找方向、用例又以跨模块强一致为主时,微服务带来的分布式复杂度会先于收益到达。模块化单体(Modular Monolith)是这一步之前更划算的形态——它保留清晰边界,但不支付分布式代价。

1. 三个前提条件

先看表,再决定物理形态:

条件倾向模块化单体倾向微服务
团队规模13 支团队、2030 人以内,能共用一个发布节奏多支团队必须有独立发布窗口,互不阻塞
部署复杂度一次构建、一次部署、一次回滚可接受需要灰度、按服务回滚、按服务扩容
事务边界多数用例需要跨模块强一致(下单同时锁库存、写订单)跨上下文只要求最终一致,能接受补偿与重试
关键链路隔离非核心模块拖慢、甚至拖垮核心链路可以接受需要独立故障域与独立降级
技术异构统一语言与存储栈足够(Go + MySQL/Redis)某模块必须用别的语言或专用存储
运维能力观测/发布体系尚未成熟(无追踪、无自动化发布)已有分布式追踪、日志聚合、容器编排与灰度能力

判断原则:**只要「强一致事务 + 统一发布节奏 + 运维能力不足」占了两条以上,就先做模块化单体,把边界做扎实。**并发与容量问题属于另一类问题(缓存、读写分离、分片等,见 system-design 系列),拆服务不是它的解药。

2. 模块化单体到底是什么

定义:一个构建产物、一个进程、一次部署,但内部按限界上下文切成多个模块,且模块边界是不可绕过、可校验的。

              一个进程 / 一个二进制 / 一次部署
┌───────────────────────────────────────────────────────────┐
│  order 模块          inventory 模块        payment 模块      │
│  ┌──────────────┐   ┌──────────────┐   ┌──────────────┐   │
│  │ domain       │   │ domain       │   │ domain       │   │
│  │ app          │   │ app          │   │ app          │   │
│  │ adapter/repo │   │ adapter/repo │   │ adapter/repo │   │
│  └──────┬───────┘   └──────┬───────┘   └──────┬───────┘   │
│         │ 只能通过对方 api 包(接口 / 进程内事件)           │
│  ───────┴──────────────────┴──────────────────┴─────────   │
│  各自独立的表与迁移脚本,禁止跨模块直接读写对方表             │
└───────────────────────────────────────────────────────────┘

它和大泥球(Big Ball of Mud)的区别只有一个:跨模块是否允许直接访问对方的表、ORM model 和内部包。允许,就是大泥球;不允许,才有资格叫模块化单体。

3. 成本对比:把账算出来

维度模块化单体微服务
构建与发布一次构建、一次发布、回滚一体每服务独立流水线,需版本编排与兼容策略
本地开发clone 一个仓库,go run ./cmd/server需拉起 N 个服务 + 中间件 + 桩服务
跨上下文事务本地事务,ACID 直接给Saga/Outbox/TCC 等最终一致方案,需补偿与对账
调用开销函数调用(纳秒级)网络往返(毫秒级)+ 序列化 + 重试
重构成本全局重命名、编译期发现所有调用点接口契约协商、双版本并存、跨团队排期
故障域一崩全崩,需靠限流/隔离兜底可按服务隔离,但要处理级联与超时
观测单进程日志、pprof、本地栈直接用必须分布式追踪 + 日志聚合 + 指标关联
数据一致性单库约束、唯一索引即可保证需处理冗余数据、双写与对账任务
团队自治靠代码边界与评审纪律约束靠服务边界 + 独立部署真正落地
基础设施低:一台机器加数据库高:注册中心、网关、追踪、编排、配置中心
演进代价未来拆分要动数据与接口已具备拆分形态,新增上下文成本低

结论很直白:微服务把「团队并行的收益」前置收费,把「分布式的成本」长期摊在每次需求上。团队没到那个规模,前一笔收益根本拿不到。

4. 模块化单体的硬约束

没有约束的模块化单体只是普通单体。以下五条要可执行:

  1. 模块之间禁止直接读写对方的表;每个模块自己的表、自己的迁移脚本、自己的仓储实现。
  2. 跨模块调用只走两条通道:同步走对方的公开接口(api.go),异步走进程内事件。
  3. 禁止 import 其他模块的 internal/ 或 domain/、adapter/ 实现包,只能 import 对方顶层的公开 API 包。
  4. 契约里不出现其他模块的领域类型——这是最常见的破口,一旦出现,两个模块就锁死了。
  5. 依赖方向与边界用 CI 断言(见第 07 章的 go list 脚本),而不是靠代码评审的记忆。
// ✅ internal/inventory/api.go —— 模块对外契约只用本模块能解释的类型
package inventory

type Service interface {
    // 参数用原语或本模块定义的类型,不引入 order 模块的 SKUID/OrderID
    Reserve(ctx context.Context, sku string, qty int) (ReservationID, error)
    Release(ctx context.Context, id ReservationID) error
}
// ❌ 契约里混入其他模块的领域类型:order 与 inventory 从此绑死
//    任何一方改类型,另一方必须同时改,等于没有边界
package inventory

import "myapp/internal/order"

func Reserve(ctx context.Context, sku order.SKUID, o *order.Order) error { /* ... */ }

跨模块的异步解耦在单体内用进程内事件总线即可,但要注意两件事:

// 进程内事件总线:解耦调用,但不等于消息队列
// - 事务边界仍要自己管:建议事件在事务提交后发布,或先写本地事件表
// - 处理器失败不会自动回滚上游事务,需显式错误处理与重试策略
type Bus struct{ handlers map[string][]Handler }

func (b *Bus) Publish(ctx context.Context, topic string, payload any) error {
    for _, h := range b.handlers[topic] {
        if err := h.Handle(ctx, payload); err != nil {
            return fmt.Errorf("topic=%s 处理失败: %w", topic, err) // 不要吞错
        }
    }
    return nil
}

事务策略也要明确:一个用例尽量落在单个模块内(说明两个模块的边界划错了);确实要跨模块强一致时,优先单库本地事务 + 明确顺序,绝不要在单体里手工模拟两阶段提交。

5. 什么时候该拆:触发信号

信号具体表现拆什么
团队并行受阻多支团队在同一模块排队改代码,PR 长期冲突按团队对应的上下文先拆
部署耦合致险一个低风险模块的改动卡住核心链路上线拆高变更频率、低风险的模块
独立伸缩需求某模块独占 CPU/内存/连接池,且伸缩曲线与其他模块不同拆该模块(如转码、检索)
技术异构算法需 Python、检索需专用存储拆需要异构技术栈的模块
故障隔离要求非核心功能不允许拖垮交易链路拆非核心链路
合规与数据边界数据需独立权限、独立存储或跨地域放置拆持有该数据的模块

反向信号(此时不该拆):团队不足十人;跨模块强一致事务是用例主体;没有自动化发布与追踪能力;拆的理由只是「微服务更先进」。先补齐可观测性、自动化发布、接口契约与兼容策略这三件事,再谈拆分——拆分方案与数据归属的具体做法在第 09、10 章展开。

6. 落地检查项

  • 能画出模块清单与模块间依赖边,且每条边都有明确的接口或事件承载。
  • 每个模块有独立的表与迁移脚本,跨模块 SQL 联查为零。
  • 存在一条可执行的 CI 规则,能拦住跨模块内部包 import 与反向依赖。
  • 进程内事件有明确的发布时机(提交后)与失败处理策略。
  • 至少演练过一次「按模块拆分」的推演:哪些模块先拆、数据怎么切、契约怎么定。

7. 常见误区

  • 把模块化单体当大泥球:目录分了模块,代码里到处 dao 互调、跨表联查,边界只是文件夹。
  • 先拆服务,再补边界:边界没想清就分进程,结果是分布式大泥球,且每次重构都要改契约。
  • 用消息队列解决单体内部耦合:模块内部耦合本不该存在;引入 MQ 只是把编译期问题变成运行期问题,并额外背上分布式复杂度。
  • 认为单进程等于性能差:真实瓶颈通常在数据库、外部依赖和 N+1 查询上;单进程可以多副本水平扩展。
  • 把拆分当成一次性工程:拆分是持续演进,触发信号出现一项就评估一项,而不是攒够理由做一次大爆炸重构。

小结:先用模块化单体把边界、契约和数据归属做扎实;只有当「团队并行、独立部署、独立伸缩、故障隔离」中至少一项成为真实瓶颈时,才按第 09、10 章的方法拆进程。

笔记加载中…