3000 字
约 10 分钟
21
S18-A11 · 模块化单体:切分成本与边界治理

"拆了八个服务,发一次版要动五个仓库,联调排到下周三,最后发现还不如原来那个单体。"这句话不是在反对微服务,而是在说:拆分买到的独立性和付出的协调成本没有对过账。网络延迟可以加机器解决,协调成本不行。

模块化单体的位置很清楚:把"边界"这份收益留在进程内,把"分布式"这份账单先欠着。它成立的前提是边界能在编译期被强制;做不到这一点,它只是一个换了目录名的单体。


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 治理成本高于收益,先分目录就够
单一团队、发布频率一致 独立部署无收益,边界只是仪式
领域尚未稳定(产品在试错) 边界会随需求重划,先按变更频率粗分
热点模块占比极高 直接拆服务更划算,模块化单体只是过渡
强一致要求遍布全系统 进程内同库事务是优势,别急着动

自测

  1. 拆分收益里哪一条是"唯一无法用加机器弥补"的,为什么?
  2. 为什么共享表 + 跨模块事务是边界划错的硬证据,而不是实现细节?
  3. Gradle 的 api 与 implementation 分别在什么情况下用错会让边界失效?
  4. 把跨模块调用全部改成异步,单体里会重新引入哪一类成本?
  5. 变更影响面中位数 4,说明边界哪里出了问题?
  6. 模块数只有 4 个时,为什么划边界的收益会低于成本?
S18-A11 · 模块化单体:切分成本与边界治理
https://www.clxhxhhr.top/posts/6196/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。