给订单加一个来源字段,最后改了九个文件、三个服务、两张表,外加一个定时任务里的 SQL。评审时没人反对这个字段,反对的是排期——谁也没料到收尾要两周。
"可维护性"大多是形容词,评审里争不出结论。变更半径是它唯一算得出来的一面:一次改动要碰多少地方,可以数、可以比、可以排。数得出来的东西才能谈判。
1. 半径的四个组成与计数口径
| 层 | 计数对象 | 为什么算它 | 计数陷阱 |
|---|---|---|---|
| 文件 | 改动涉及的文件数 | 最小粒度,决定评审量与冲突概率 | 生成物、纯格式化会把数抬高 |
| 模块 | 越过模块边界的次数 | 反映耦合,不反映工作量 | 同模块内多文件不算跨模块 |
| 服务 | 需要同时发布的部署单元 | 决定发布协调成本 | 只改配置不算服务半径 |
| 数据表 | 需 DDL 或数据回填的表 | 决定不可逆程度 | 加索引与改字段的风险不同级 |
─── 一次改动的半径记录,贴在 PR 描述里,四行就够
files: 9 ← 排除生成文件与纯格式化
modules: 4 ← 跨模块边界 4 次
services: 3 ← 必须同时发布的单元
tables: 2 ← 1 张加列(可回滚),1 张回填(不可逆)
口径必须先统一,否则数字会骗人:九个文件里六个是同模块的生成产物,真实半径只有三个文件。四条都要记——只记文件数会高估,只记服务数会低估,而"不可逆程度"只有 tables 那一行说得清。
services 这一层单独解释一句:它不是"涉及几个仓库",而是"要同时动几个部署单元"。同一个仓库里两个服务也是 2;一次只改一个服务但需要上下游配置同步,也算 2——判断标准是发布时要不要协调窗口。
2. 半径大的三种成因
| 成因 | 典型表现 | 判别信号 | 处置方向 |
|---|---|---|---|
| 抽象层次错位 | 一个字段在 DTO/VO/DO/Entity 里各出现一次 | 同一概念在多个模型里重复定义 | 定哪层为主,其余只做投影 |
| 共享状态过多 | 一张"通用配置表"被六个服务读 | 改它的 PR 总有跨服务影响 | 切出各服务私有配置 |
| 边界没有执行 | 跨模块 import 在编译期合法,靠约定不跨 | 违规只在评审时被发现 | 把边界变成构建期断言 |
─── 先判清是哪一种,再选手段:三种成因的解法互不通用
1. 同一概念出现几次? 按类名/字段名 grep,看落在几个模型里
2. 谁在共享? 按部署单元去重统计读取方
3. 边界能执行吗? 把依赖删掉再编译,能过=边界没有执行
最常见的误判是把"边界没有执行"当成"抽象层次错位",于是加一层抽象——半径没变小,耦合只是搬进了新层,还多了一个需要维护的东西。
"抽象层次错位"的根子通常在读写模型混用:写入时的校验规则、读出的展示结构、持久化的列定义,被当成了同一个对象的三个视图。一旦有人要求"这个字段到处都要有",三处就必然一起改。收窄的办法不是删模型,而是明确谁是主、其余由它投影出来。
3. 三种测量方式与各自的偏差
| 方式 | 做法 | 偏差方向 | 适合回答 |
|---|---|---|---|
| 按提交统计 | 数一个 PR 触及的文件与模块 | 高估:含格式化、生成物、顺手重命名 | 历史趋势 |
| 按需求回溯 | 一个需求从提出到上线实际改了什么 | 低估:漏掉上线后补的配置与数据修正 | 单次真实成本 |
| 按依赖图推演 | 从改动点沿依赖反向传播 | 高估:静态依赖远多于运行时依赖 | 最坏情况、评审预判 |
三种都不精确,组合才有意义:依赖图做预判,需求回溯校准真实成本,提交统计看趋势。单次值不可靠,趋势可靠——中位半径连着三个季度上升,比任何一次"这次改动很大"的抱怨都更有说服力。
需求回溯的可操作性比听起来好:一个需求单号通常能拉出全部关联 PR、迁移脚本与配置变更,把它们并成一个集合再数一次。它专治"上线后才发现还改了别处"——漏掉配置与数据修正的改动,在提交统计里根本看不见。
─── 依赖图推演的最小做法
1. 找出被改符号的全部引用(编译期依赖)
2. 只保留运行时会执行的路径,去掉测试与样例里的引用
3. 按部署单元聚合,得到"必须同时发布"的集合
4. 与同类改动的实测半径对比,偏差多少要记录,否则预判不会变准
4. 半径、回滚粒度与收窄手段
半径与回滚之间有一条硬约束:半径大于回滚单元时,回滚必然连带。你只能整体退回三个服务,而真正错的只有一处——这不是保守,是当前粒度下唯一正确的动作。"能不能只回滚这一处"的答案,在改动设计阶段就写定了。
| 手段 | 做法 | 收窄什么 | 代价 |
|---|---|---|---|
| 把变化点集中 | 同一概念的变体收敛到一处 | 文件数与模块数 | 要一次迁移,迁移当期半径变大 |
| 把共享状态显式化 | 共享物写成接口与契约,不留隐式依赖 | 服务数与连带回滚 | 多一层维护,契约得有人管 |
| 把边界变成断言 | 构建期检查,违规即失败 | 越界次数 | 要配构建规则与例外流程 |
| 数据与代码变更分离 | 先加列双写,再切读,最后删旧列 | 不可逆程度 | 周期变长,两版共存 |
// 断言比文档可靠:它不会被人跳过
─── 模块依赖规则示例(按你所用构建工具的语法核对)
禁止 domain → web、infra → domain 的反向依赖
违规即 CI 失败;例外必须写进白名单文件,并带失效日期
"加一层抽象"是双面的:它能收窄半径——把 N 处变化收进 1 处;也能放大半径——新层被所有人依赖之后,改它等于改全部。判据是新层的外部依赖方数量:被两个以上团队依赖的抽象层,每次改动都会产生跨团队半径。
四种手段有先后:先做"边界断言",因为它不改变结构、只暴露违规;再做"共享状态显式化",因为它需要拆依赖;"变化点集中"和"数据与代码分离"放在后面,前者要一次迁移,后者要两个版本共存。顺序反了,会在迁移途中同时面对越界、隐式依赖和不可逆数据变更三件事。
小而频繁的改动与大而少的改动,风险性质不同:前者单次风险低但暴露面多,靠回滚和观测兜住;后者单次风险高且常常不可逆,靠评审和演练兜住。用同一套流程管两种改动,一定会有一边过重、另一边过轻。
| 指标 | 怎么取 | 读法 |
|---|---|---|
| 中位半径 | 每季度 PR 半径的中位数 | 上升=耦合在长 |
| 半径尾部 | p90 或 p95 的半径 | 尾部失控才是排期失控的真因 |
| 跨服务改动占比 | 跨服务 PR / 总 PR | 高说明边界没在执行 |
| 连带回滚次数 | 回滚时被一起退回的单元数 | 大于 1 说明粒度小于半径 |
5. 排错:六种"半径说不清也降不下来"
| 现象 | 根因 | 处置 |
|---|---|---|
| 半径一直大,说不清哪层大 | 只记文件数 | 四条一起记,看哪一层在涨 |
| 每次改动都牵动同一个服务 | 共享状态集中在那里 | 显式化,或切出私有配置 |
| 回滚总是连带三个服务 | 回滚单元小于半径 | 拆发布单元,或把连带写进预案 |
| 加了抽象层后更难改 | 抽象层成了新的耦合中心 | 看它的依赖方数量,必要时拆薄 |
| 预判半径总远大于实测 | 依赖图算进了测试与样例引用 | 只保留运行时路径 |
| 新人一上手就改出跨服务改动 | 边界只有文档,没有执行 | 把规则变成构建期断言 |
最后一条最值得花力气:把边界写进文档,只能约束愿意读文档的人;写成构建期断言,才会约束所有人。这也是"边界"与"约定"的分界线——约定靠自觉,边界靠失败。
还有一条容易被忽略的因果:半径偏大的改动,评审往往反而更宽松。评审者面对九个文件时会本能地降低细读深度,于是大改动既贵又漏。收窄半径不只是省时间,也是把评审重新变成有效动作。
6. 边界:什么时候不该优化半径
| 场景 | 判断 | 更好的做法 |
|---|---|---|
| 代码量极小(单文件脚本级) | 半径天然是 1 | 不测量,看测试与回滚即可 |
| 变更频率极低(一年几次) | 收益摊不回来 | 接受大半径,写进变更预案 |
| 一次性迁移期 | 半径大是任务本身 | 只保回滚粒度,不优化半径 |
| 强一致、强监管领域 | 连带变更是正确行为 | 不追求收窄,追求可验证 |
// 三个月看一次的四个判断(只看趋势,不看单次)
1. 中位半径在涨吗? 涨 → 先找涨在哪一层
2. 尾部半径有没有失控? 有 → 那是排期风险的真正来源
3. 跨服务改动占比在涨吗? 涨 → 边界没在执行
4. 连带回滚多于单元数吗? 多 → 回滚粒度小于半径,先拆粒度
一句话收口:变更半径不能证明系统好,只能证明它可预测;而可预测的半径,是排期、评审与回滚预案三者唯一共同的输入。
优化的时机也要挑:在新功能刚稳定、改动频率低的那段时间做收窄,代价最低;在迁移中途或需求高峰做结构调整,等于把两种半径叠在一次发布里。
自测
- 半径的四个组成里,哪一项决定"不可逆程度",而不是工作量?
- 三种成因中,哪一种最容易被误判成"抽象层次错位"?误判后的后果是什么?
- 按提交统计半径时,偏差往哪个方向?为什么它仍值得用?
- "半径大于回滚单元"会导致什么必然结果?这个约束在哪个阶段就定下了?
- 加一层抽象什么时候会放大半径?判据是什么?
- 为什么说单次半径值不可靠,而趋势可靠?