"拆了八个服务,发一次版要动五个仓库,联调排到下周三,最后发现还不如原来那个单体。"这句话不是在反对微服务,而是在说:拆分买到的独立性和付出的协调成本没有对过账。网络延迟可以加机器解决,协调成本不行。
模块化单体的位置很清楚:把"边界"这份收益留在进程内,把"分布式"这份账单先欠着。它成立的前提是边界能在编译期被强制;做不到这一点,它只是一个换了目录名的单体。
1. 拆分的收益与账单:逐项对账
| 收益 | 成立判据 | 对应账单 | 账单落在谁身上 |
|---|---|---|---|
| 独立部署 | 该模块发布频率与其他模块差 3 倍以上 | 版本协调、契约兼容、联调排期 | 团队负责人 |
| 故障隔离 | 该模块确实会拖垮别人(线程/内存/CPU 争用) | 跨进程调用、超时重试、部分失败处理 | 值班人 |
| 独立扩缩 | 该模块资源需求与别人差一个量级 | 机器数上升、容量规划 ×N | 运维与成本 |
| 团队自治 | 有两个以上团队、各自独立 backlog | 跨团队对齐会、决策延迟 | 组织 |
判据写不出来的收益不算收益;账单落不到具体人头上的成本,半年后会以"发布很慢"的形式回来。
2. 模块化单体成立的四条件
| 条件 | 检验问法 | 不成立的表现 |
|---|---|---|
| 单一部署单元 | 是否只有一个团队决定上线节奏 | 多团队抢发布窗口,等于已经拆了服务 |
| 边界可强制 | 越界调用能否让 CI 变红 | 只能靠 review,三个月后失效 |
| 数据属主唯一 | 每张表能否指到唯一模块 | 共享表 + 跨模块事务 |
| 模块数足够摊薄治理成本 | 经验区间 5±2 | 三个模块也要走完整边界流程,纯仪式 |
四条里最关键的是第二条。边界的第一属性是可执行性,不是正确性:划得再漂亮,只要没人能自动判越界,它就会在第一个赶工期的周五被绕过。
3. 四种划法与各自的退化方式
| 划法 | 判据 | 优点 | 退化方式 |
|---|---|---|---|
| 按领域(限界上下文) | 边界内术语一致、不变量能一起维护 | 边界稳定、改动内聚 | 领域没想清就划 → 边界随需求漂移,最后按"谁改的"重划 |
| 按变更频率 | 两部分的提交历史重合度低 | 直接压低跨模块改动 | 频率是结果不是原因;重构期频率突变,边界失效 |
| 按团队(康威) | 有真实团队与 CODEOWNERS |
责任清晰、评审有主 | 组织一调整边界就废;小团队被切成一人模块 |
| 按数据所有权 | 每张表能被唯一模块写入 | 与库边界天然对齐 | 一旦允许"只读共享表",就退化回共享数据库 |
一张表同时命中"被两个模块写"和"跨模块事务",就是边界划错的硬证据,不用再讨论。
4. 可见性:编译期能不能拦住越界
包的 public 不是边界。手段按强度排:
| 手段 | 强度 | 代价 |
|---|---|---|
| 独立构建单元(Gradle/Maven module) | 强:跨模块只见 api 依赖 |
构建变慢、循环依赖必须拆开 |
JPMS module-info |
强:exports 决定可见 |
三方库多为自动模块,工具链摩擦大 |
| ArchUnit / 依赖检查规则 | 中:CI 期失败,非编译期 | 规则本身要维护,易被 baseline 放水 |
| 包名约定 + code review | 弱 | 半年后失效 |
// user-api/src/main/java/top/clxhxhhr/user/module-info.java
module blog.user.api {
exports top.clxhxhhr.user.api; // 唯一对外可见的包
// internal 包不 exports,跨模块 import 直接编译失败
}
// user-impl/build.gradle
dependencies {
implementation project(':order-api') // 实现依赖不外泄
// 禁止:api project(':order-impl') —— 一旦这么写,边界破在构建文件里
}
// 边界规则:既能查环,也能查内部包穿透
@AnalyzeClasses(packages = "top.clxhxhhr")
class ModuleBoundaryTest {
@ArchTest static final ArchRule 无环 =
slices().matching("top.clxhxhhr.(*)..").should().beFreeOfCycles();
@ArchTest static final ArchRule 只走api =
noClasses().that().resideOutsideOfPackage("..user.api..")
.should().accessClassesThat().resideInAPackage("..user.internal..");
}
规则上线时不要追求"全绿"。做法是把现存越界写成冻结清单,规则只拦新增——先止住腐化速度,再谈收敛。
5. 共享内核膨胀:单体腐化的主因
共享内核(common / shared / domain)的每一次"顺手放这里",都在把两个模块焊在一起。
| 放进共享内核的东西 | 是否合理 | 更合适的去处 |
|---|---|---|
| 金额、ID、时间段等值对象 | 合理 | 独立值对象模块,不含业务规则 |
| 字符串/日期工具 | 边缘 | 优先用 JDK 或成熟三方库 |
| 带业务规则的"通用服务" | 不合理 | 归某一模块,其他模块调接口 |
| 全系统实体与 DTO | 不合理 | 各模块私有,跨边界传 id 或事件 |
| 常量与枚举 | 看情况 | 会随业务增长的一律归领域模块 |
一句话判据:共享内核里出现 if/else 分支或业务规则,就该搬走。
# 膨胀量化:类数 + 被几个模块引用
find . -path '*/shared/*' -name '*.java' | wc -l
jdeps --multi-release 17 -recursive -verbose:class build/libs/*.jar \
| grep -c 'shared'
类数单调上升、删除数长期为 0,是共享内核失控最直观的曲线。
6. 跨模块调用要不要异步
| 场景 | 选同步 | 选异步 |
|---|---|---|
| 必须立即拿到结果或校验不变量 | 是 | 否 |
| 副作用跨模块(扣款、发券、扣库存) | 否 | 是(事件 + 幂等消费) |
| 调用方不能承受被调方变慢 | 否 | 是 |
| 同一事务内必须一起失败 | 是(本地事务) | 否(只能补偿) |
判据:同步调用把两个模块的可用性相乘,异步只把一致性变弱。 单体里最常见的错,是为了"解耦"把所有跨模块调用改成异步,结果本地事务变成最终一致,还得自己写补偿——把分布式那套账单又搬回来了,只是账单换成了自己的加班。
7. 模块边界与库表边界的错位
# schema-owner.yaml —— 表归属唯一属主,CI 校验
user: [user_account, user_profile, user_login_log]
order: [order_main, order_item, order_refund]
billing: [bill_invoice, bill_payment]
shared: [] # 必须为空:任何表都不许是"公共的"
-- 被两个以上模块写入的表 = 边界错位
SELECT table_name, COUNT(DISTINCT module) AS writers
FROM table_write_index
GROUP BY table_name
HAVING writers > 1;
错位有三种表现:跨模块 join、跨模块外键、一个模块直接 update 别人的状态字段。第三种最隐蔽,因为它常以"就改一个状态位"的样子出现在 review 里,而它是后续所有数据不一致的源头。
8. 三条约束怎么落地
| 约束 | 机器检查 | 人工检查 |
|---|---|---|
| 禁循环依赖 | ArchUnit beFreeOfCycles、jdeps |
新增模块依赖时的评审问题 |
| 禁直连他模块表 | schema-owner + mapper 扫描 |
PR 里出现跨模块 SQL |
| 公共接口显式声明 | 只有 *.api 被 exports |
api 包只放 DTO 与接口,不放实现 |
三条约束的价值不在"正确架构",而在把架构争论变成 CI 输出。争论留在会议室里的边界,一定会输给交付压力。
9. 该把一个模块拆出去的判据
| 信号 | 量级(按本团队历史核对) | 说明 |
|---|---|---|
| 资源占用是其他模块的 10 倍以上 | 内存/CPU 实测 | 只有拆出去才能单独扩缩 |
| 发布节奏日更 vs 周更 | 发布记录 | 单体里物理上做不到 |
| 故障会拖垮整个进程 | 事故复盘 | 隔离有真实收益 |
| 有独立团队接手 | 组织事实 | 没有则不拆,拆了没人担 |
| 技术栈硬约束(GPU、Python 生态) | 不可绕 | 进程内做不了 |
反向判据同样重要:拆完仍要与主库同库同事务,那拆出来的只是多一跳网络和一套新的失败模式。
10. 度量
| 指标 | 采集方式 | 变坏的方向 |
|---|---|---|
| 模块间依赖数 | ArchUnit slices 统计、jdeps |
上升 = 边界在漏 |
| 变更影响面 | 一次 PR 触及的模块数 | 中位数 > 2 = 边界位置不对 |
| 构建时长 | CI 记录 | 单模块构建 > 全量 1/3 = 粒度太细 |
| 共享内核类数 | 目录统计 | 只增不删 = 腐化 |
| 跨模块 SQL 数 | 静态扫描 | 非 0 即债 |
# 一次改动波及几个模块:最近 200 次提交的目录分布
git log --name-only --pretty=format: -n 200 \
| grep -v '^$' | cut -d/ -f1 | sort | uniq -c | sort -rn
11. 排错
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 加规则后一半类报错 | 规则按理想边界写,现实是历史耦合 | 冻结清单 + 只拦新增,再逐模块收敛 |
| 拆分后构建时间翻倍 | 粒度太细、每模块跑全量测试 | 只测受影响模块 + 聚合校验 |
| 共享内核什么都往里塞 | 没有归属判据 | 立"含业务规则即搬走"的评审口径 |
| 改异步后数据对不上 | 缺补偿与幂等消费 | 回同库同事务,或补 outbox |
module-info 编译不过 |
三方库是自动模块 | 退回 Gradle 模块边界 + ArchUnit |
| 边界评审每次吵起来 | 判据是"架构好坏"而非"改动落点" | 换成"一次改动碰几个模块"的量化口径 |
12. 边界
| 场景 | 判断 |
|---|---|
| 模块数少于 5 | 治理成本高于收益,先分目录就够 |
| 单一团队、发布频率一致 | 独立部署无收益,边界只是仪式 |
| 领域尚未稳定(产品在试错) | 边界会随需求重划,先按变更频率粗分 |
| 热点模块占比极高 | 直接拆服务更划算,模块化单体只是过渡 |
| 强一致要求遍布全系统 | 进程内同库事务是优势,别急着动 |
自测
- 拆分收益里哪一条是"唯一无法用加机器弥补"的,为什么?
- 为什么共享表 + 跨模块事务是边界划错的硬证据,而不是实现细节?
- Gradle 的
api与implementation分别在什么情况下用错会让边界失效? - 把跨模块调用全部改成异步,单体里会重新引入哪一类成本?
- 变更影响面中位数 4,说明边界哪里出了问题?
- 模块数只有 4 个时,为什么划边界的收益会低于成本?