27.2 模块化单体
一句话
模块化单体 = 一个制品 + 明确的模块边界 + 受控的跨模块调用;它用 “编译期边界” 换掉 “分布式复杂度”,但前提是边界能被机器校验——写在文档里的边界,三个月后一定消失。
三种形态的对照
| 维度 | 普通单体 | 模块化单体 | 微服务 |
|---|---|---|---|
| 部署单元 | 1 | 1 | N |
| 事务 | 本地 | 本地(跨模块尽量单事务) | 分布式事务/Saga |
| 边界强制 | 无 | 编译期 + CI 规则 | 网络 + 契约 |
| 故障域 | 全挂 | 全挂(但可降级) | 按服务隔离 |
| 团队协作 | 靠自觉 | 靠规则 | 靠契约与发布 |
模块化单体的价值主张:拿到 “边界清晰” 的 80% 收益,不付 “分布式” 的代价。
四种边界手段(从弱到强)
| 手段 | 做法 | 强制力 |
|---|---|---|
| 包/目录前缀 | internal/order、internal/user | 弱(靠命名) |
| 语言可见性 | Go 的 internal、Java 的 package-private | 中 |
| 依赖规则检查 | ArchUnit / arch-go / import-linter | 强(CI 失败) |
| 数据隔离 | 每模块独立 schema 或表前缀,禁止跨模块 JOIN | 强(DB 层拒绝) |
代码:把规则写进 CI
# Go: arch-go 规则(示意)
dependencies:
- package: "example.com/app/internal/order"
shouldNotDependOn: "example.com/app/internal/user" # 订单不许直接依赖用户内部实现
# 检查循环依赖(Go)
go list -deps ./... | sort | uniq -d # 重复出现的包=可能成环
# Java: jdeps 看包级依赖
jdeps -verbose:package build/classes | grep "-> " | head
实测:四种边界手段各挡得住什么
对一个 4 模块的示例工程(order / user / payment / common)做静态分析,脚本输出:
| 发现 | 数量 | 说明 |
|---|---|---|
跨模块 internal 依赖 | 1 处(order → user/internal) | Go 会在编译期报错,Java/Python 不会 |
| 模块级循环依赖 | 1 对(order ↔ payment) | 编译能过,语言可见性完全挡不住 |
| 最高扇入 | user/internal 被 2 个模块依赖 | 变更影响面最大的地方 |
结论很直接:语言可见性只挡住 “import 内部包”,挡不住 “循环依赖”;后者必须靠依赖规则(ArchUnit/arch-go/自研脚本)。这也是 “边界要能被机器校验” 的具体含义——四种手段里只有后两种能覆盖 “方向”。
反模式
| 反模式 | 后果 |
|---|---|
| 只有目录分层,没有依赖规则 | 半年后跨层直接调用遍地,退化成大泥球 |
| “公共模块” 装一切 | 变成事实上的共享数据库,谁都依赖它 |
| 模块之间共享表 | 边界在数据层被击穿,改表要全量回归 |
| 用事件总线做模块间同步调用 | 单体里引入 “伪分布式”,延迟与调试成本上升 |
| 模块内再分 “service/dao/util” 横切 | 按技术分层而非业务能力,边界与变更频率错位 |
| 没有模块 owner | 边界无人维护,规则形同虚设 |
| 以为用了 internal/package-private 就有边界 | 循环依赖照旧,边界只是“不能 import” |
| 规则只查 import 不查调用方向 | 通过公共类型/事件间接成环,规则看不见 |
三条铁律
🔑 边界必须被 CI 校验,否则它只是注释。
🔑 模块间只走 API,不共享表;数据共享是边界的真正漏洞。
🔑 按业务能力切模块,不按技术层切;切法要对齐变更频率。
自检
Q1 模块化单体和 “分层清晰的单体” 差在哪?
▸ 答案:差在边界的强制力。分层只是 “上下不越级”,模块化要求 “左右不直连”:订单不能直接调用户模块的内部实现,只能走它暴露的 API。没有 CI 规则时,两者在代码里长得几乎一样,直到某天有人图省事 import 了内部包。
Q2 为什么 “共享表” 是模块边界最致命的漏洞?
▸ 答案:因为数据模型是最常变的部分:只要两个模块读写同一张表,任何一方的 schema 变更都会波及其他模块,边界在部署与回归上失效。正确做法是每模块拥有自己的表/schema,跨模块通过 API 或事件同步。
Q3 模块化单体什么时候该继续演进、什么时候该停?
▸ 答案:看三个信号:独立扩缩容需求(某模块负载是其他的 10 倍)、独立发布节奏(不同团队要按天/按周发)、故障隔离要求(一个模块崩不能拖垮全部)。三者都没有时,模块化单体是性价比最高的形态。
Q4 为什么 Go 的 internal 包是 “弱边界”?
▸ 答案:它只能阻止其他模块导入,但同模块内任何包都能互相 import;且 internal 不限制 “上层包被下层反向依赖”。真正的边界要配合依赖方向规则(ArchUnit/arch-go)与代码评审。
Q5 模块间用同步调用还是事件?
▸ 答案:默认同步(单体内调用便宜、易调试);只有 “一方不关心结果、且允许最终一致” 时才用事件。用事件做同步调用(发出去等回包)是反模式:既没有分布式的好处,又引入了异步的复杂度。
Q6 一个 20 人团队,8 个模块,模块间调用混乱,先做什么?
▸ 答案:先加依赖规则(把现有违规变成 CI 失败,允许先加白名单),再处理共享表(按模块拆 schema),最后才谈拆分进程。顺序反了会在 “边界还没立住” 时先付分布式成本。
Q7 为什么 “循环依赖” 比 “跨模块 import 内部包” 更难发现?
▸ 答案:因为跨模块 import 往往有明确的名字(xxx/internal)可 grep,而循环依赖是两个合法依赖叠加的结果:order→payment 与 payment→order 单独看都 “合理”,合起来才成环。它需要图分析(不是单文件扫描),而且编译、单测都不会报错,只能靠专门的规则或定期扫描。
故障复盘(典型模式,脱敏)
| 阶段 | 现象 | 判断 | 动作 |
|---|---|---|---|
| T+0 | 支付模块一次小修复上线后,订单创建开始报错 | 两模块共享 orders 表 | 回滚支付变更 |
| T+20m | 回滚无效——订单代码已依赖新字段 | 边界在数据层,回滚不彻底 | 手工补字段 |
| T+2h | 定位到 3 个模块读写同一张表 | 共享表 = 隐式契约 | 排期拆 schema |
| 之后 | 每次改表都要拉 3 个团队评审 | 协调税 | 加依赖规则 + 表 owner |
根因:模块边界只画在目录上,数据层没有 owner。代价:一次回滚失败 + 2 小时停摆。根治:每模块独立 schema,跨模块只走 API/事件。