27.4 大泥球的形成路径
一句话
大泥球不是 “一次设计失败”,而是五个可识别阶段的连续滑坡:共享数据库 → 跨模块事务 → 循环依赖 → “公共模块” 黑洞 → 无人敢改;每一阶段都有可观测信号,抓早一步,成本差一个数量级。
五个阶段
| 阶段 | 症状 | 可观测信号 | 阻断动作 |
|---|---|---|---|
| ① 共享数据库 | 所有模块读写同一批表 | 任意 schema 变更都要全量回归 | 每模块独立 schema |
| ② 跨模块事务 | 一次请求改 6 张表 | 事务耗时 > 100ms、锁等待上升 | 聚合边界 + 领域事件 |
| ③ 循环依赖 | 改 A 必改 B | jdeps/go list 出现环 | 提取接口 + 依赖倒置 |
| ④ 公共模块黑洞 | common 有 300 个类 | 任何改动都触碰 common | 限制为纯工具,业务下沉 |
| ⑤ 无人敢改 | 新需求一律 “加 if” | 需求评审出现 “这块不能动” | 绞杀者式重构(见 28.5 遗留系统集成 (绞杀者 / 防腐层)) |
代码:把 “环” 找出来
# Go:列出包依赖图,找环
go list -f '{{.ImportPath}} {{join .Imports " "}}' ./... > deps.txt
# 人工或用工具找环
go run github.com/loov/goda@latest graph ./... | head -30
# Java:包级依赖
jdeps -verbose:package build/classes | grep -- "->" | sort | uniq -c | sort -rn | head
# 判据:出现 A -> B 且 B -> A,或某个包被 20+ 包依赖(公共黑洞)
实测:三个可量化信号,一次跑出来
同一示例工程的静态分析输出(对应前面五个阶段中的 ② 与 ④):
| 信号 | 修复前 | 修复后 |
|---|---|---|
| 跨模块内部依赖 | 1 处 | 0 |
| 模块级循环依赖 | 1 对(order ↔ payment) | 0 |
| 最高扇入 | user/internal = 2 | 依赖公开接口后下降 |
这三个数就是 “泥球程度” 的可观测代理指标:环数、违规数、扇入。它们都能进 CI/看板,也都能向管理层解释 “为什么交付变慢”——不需要 “感觉代码很乱”。
治理顺序:先把环清零(②),再消内部依赖(①),最后压扇入(④)
理由:环让任何局部改动都可能影响全局,是成本最高的一类
反模式
| 反模式 | 后果 |
|---|---|
| 用 “重构大爆炸” 治理泥球 | 风险与工期不可控,通常半途而废 |
| 只拆目录不拆数据 | 表仍是公共契约,边界无效 |
| 把泥球整体包成微服务 | 分布式泥球:更难查、更慢 |
| 用 “加一层” 掩盖耦合 | 抽象层自身变成新的耦合点 |
| 没有度量就宣称 “在改善” | 循环依赖数、变更影响面都没降 |
| 只统计代码行数/模块数变化 | 泥球可能 “模块变多但耦合更重”,指标失真 | | 治理后不复测 | 没有前后对比,无法证明改善 |
两条铁律
🔑 先立规则、再动结构;没有 CI 规则的重构会被下一次 “赶需求” 打回原形。
🔑 一次只治理一类信号:先把环清零,再处理共享表。
自检
Q1 为什么 “共享数据库” 排在滑坡的第一位?
▸ 答案:因为它是所有后续问题的放大器:表一旦成为公共契约,跨模块事务、循环依赖、公共模块都会随之出现——改表要协调所有使用方,于是大家倾向于 “加字段而不是改结构”,泥球开始堆积。
Q2 “只拆目录不拆数据” 为什么是假重构?
▸ 答案:因为编译期边界可以被绕过(跨 schema 直接查),而数据模型才是运行期的真实耦合。判断重构是否有效,看 “改一张表要动几个模块”,而不是看目录多整齐。
Q3 泥球整体包成微服务会发生什么?
▸ 答案:得到分布式泥球:原来的本地调用变成网络调用,延迟与失败模式变多,但循环依赖与共享数据一个没少——只是从 “编译不过” 变成 “运行时报错”。拆分前必须先理清边界。
Q4 怎么向管理层证明 “泥球在恶化”?
▸ 答案:用三个可量化指标:循环依赖数、单次变更平均影响模块数、回归测试耗时。三者同向上升时,“需求交付变慢” 就有了归因,而不是 “开发不努力”。
Q5 为什么 “环” 一旦出现就会自我强化?
▸ 答案:因为环让 “改 A 必改 B” 成为常态:开发者为了绕开改动,倾向于在两边都 “加一个 if / 加一个字段”,于是环上的耦合越来越深,谁都不敢先动。破环必须先抽接口/事件,把 “双向依赖” 拆成 “单向依赖 + 回调”,这是一次结构性改动,不能靠局部修补。
Q6 “公共模块黑洞” 该怎么治,而不是 “禁止使用 common”?
▸ 答案:把 common 拆成两类:无业务语义的基础设施(错误码、时钟、日志封装)与被误放进去的业务概念(订单状态枚举、金额类型)。前者保留并版本化,后者下沉到真正的业务模块。治完后加一条规则:common 的新增必须评审,且不允许 import 任何业务模块(单向依赖)。
Q7 为什么 “环” 要优先于 “内部依赖” 治理?
▸ 答案:因为内部依赖通常可以用 “换公开接口” 局部修复,而环意味着两个模块互为前提:要打破它必须先抽出第三方的接口/事件,改动面更大,且它让 “任何一处修改都可能影响全局”。先清环,后续的局部重构才有意义。
故障复盘(典型模式,脱敏)
| 阶段 | 现象 | 判断 | 动作 |
|---|---|---|---|
| 需求评估 | 一个 “加个状态” 的需求评估 3 天变 3 周 | 环让改动扩散到 4 个模块 | 拆接口 |
| 上线 | 改一处,三处回归失败 | 共享表 + 隐式依赖 | 回滚并分批 |
| 复盘 | 环 9 个、最高扇入 12 | 已进入 “无人敢改” 阶段 | 先清环 |
| 治理后 | 环 0、扇入降到 4,评估回到 2 天 | 指标改善可量化 | 持续看板 |
根因:泥球不是一次设计事故,而是每季度 “先上线再说” 的累积。代价:交付周期 ×5。根治:把 “环数/违规数/扇入” 纳入季度目标,先清环再谈重构。