模块化单体:什么时候不该上微服务
微服务(Microservice)解决的是「多团队独立交付」和「按需独立部署/伸缩」这两类问题,它本身不提升架构质量,也不提升单机吞吐。当团队只有一个、业务还在找方向、用例又以跨模块强一致为主时,微服务带来的分布式复杂度会先于收益到达。模块化单体(Modular Monolith)是这一步之前更划算的形态——它保留清晰边界,但不支付分布式代价。
1. 三个前提条件
先看表,再决定物理形态:
| 条件 | 倾向模块化单体 | 倾向微服务 |
|---|---|---|
| 团队规模 | 1 | 多支团队必须有独立发布窗口,互不阻塞 |
| 部署复杂度 | 一次构建、一次部署、一次回滚可接受 | 需要灰度、按服务回滚、按服务扩容 |
| 事务边界 | 多数用例需要跨模块强一致(下单同时锁库存、写订单) | 跨上下文只要求最终一致,能接受补偿与重试 |
| 关键链路隔离 | 非核心模块拖慢、甚至拖垮核心链路可以接受 | 需要独立故障域与独立降级 |
| 技术异构 | 统一语言与存储栈足够(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. 模块化单体的硬约束
没有约束的模块化单体只是普通单体。以下五条要可执行:
- 模块之间禁止直接读写对方的表;每个模块自己的表、自己的迁移脚本、自己的仓储实现。
- 跨模块调用只走两条通道:同步走对方的公开接口(
api.go),异步走进程内事件。 - 禁止 import 其他模块的
internal/或domain/、adapter/实现包,只能 import 对方顶层的公开 API 包。 - 契约里不出现其他模块的领域类型——这是最常见的破口,一旦出现,两个模块就锁死了。
- 依赖方向与边界用 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 查询上;单进程可以多副本水平扩展。
- 把拆分当成一次性工程:拆分是持续演进,触发信号出现一项就评估一项,而不是攒够理由做一次大爆炸重构。