需求本身很小:把某配置的默认值从 30 改成 60,顺便"它发现这里有个 typo"。AI 提交上来 830 行 diff、14 个文件,其中 9 个文件是"顺手统一了格式与 import 顺序"。评审挂了三天,最后的结果是——没人真的看过那行关键的 60。
可审性(reviewability)不是代码质量的子项,它是独立的一笔账:一份 diff 如果超出 reviewer 的认知预算,它就等于没被审。而 AI 特别容易把 diff 撑大,因为它没有"这次只管这一件事"的边界感。
1. review 的认知预算是有上限的
| 规模 | 实际发生什么 | 结果 |
|---|---|---|
| < 100 行、1–3 文件 | 逐行读 | 能发现问题 |
| 100–400 行 | 重点区域精读、其余扫过 | 大概率漏边界 |
| 400–800 行 | 只看 diff 摘要与关键函数 | 形式化通过 |
| > 800 行 或 > 10 文件 | 看标题与 CI 结果 | 等于没审 |
第三行是"LGTM 文化"的物理来源:不是 reviewer 不负责,是人眼在 400 行之后的注意力断崖。而这个上限与代码难度无关——再资深的人也一样。
// 一次 830 行 diff 的实际构成(真实形状)
核心改动 12 行 1.4% ← 只改了默认值 + 一行注释
格式化/import 610 行 73.5% ← 顺手统一
重命名 95 行 11.4%
顺带重构 113 行 13.6%
// 结论:reviewer 要在 98.6% 的噪音里找那 12 行
2. 三类噪音,破坏方式不同
| 噪音类型 | 为什么破坏 review | 是否可机械识别 |
|---|---|---|
| 格式化(空格/引号/import 顺序) | 让真实改动淹没在白色噪音里 | 可以(.git-blame-ignore-revs) |
| 跨文件重命名 | 打断"改动意图"的连续性 | 部分(rename detection) |
| 顺带重构(抽方法、改结构) | 改变了行为假设,却混在功能改动里 | 不能 |
第三行最危险:格式化至少"语义不变",而顺带重构会引入新行为。当它和功能改动混在一个 diff 里,reviewer 无法区分"这行是为了实现需求"还是"这是他顺手改的"——于是要么全信,要么全不信。
3. 判据:核心改动占比与可独立回滚性
不要用"行数"作为唯一判据,用两个更硬的:
| 判据 | 阈值 | 说明 |
|---|---|---|
| 核心改动占比 | ≥ 30 % | 低于此说明噪音太多 |
| 文件数 | ≤ 5(业务改动) | 超过就要说明为什么 |
| 可独立回滚 | 是 | 只回滚功能部分,重构留下 |
| 单一意图 | 是 | 一句话能说清"这单做了什么" |
// 噪音比:两个数字一除,就知道这单该不该拆
$ total=$(git diff --numstat HEAD~1 | awk '{s+=$1+$2} END{print s}')
$ sem=$(git diff -w --numstat HEAD~1 | awk '{s+=$1+$2} END{print s}')
$ echo "total=$total semantic=$sem"
// total=830 semantic=108 → 核心占比 13%,低于 30% 就该拆单
第三行常被忽略,但它才是关键:如果回滚这单会把无关的重构一起撤掉,那这单就不该存在。可独立回滚要求把"行为不变"的改动与"行为变化"的改动分成两个提交。
// 提交前自查:这单到底改了多少、噪音多少
$ git diff --stat HEAD~1 | tail -3
9 files changed, 812 insertions(+), 18 deletions(-)
$ git diff -w --stat HEAD~1 | tail -1 // 忽略空白后的真实规模
5 files changed, 96 insertions(+), 12 deletions(-)
// -w 之后的数字才是"语义改动"的规模;两者差 8 倍就是明确的噪音信号
4. 拆分:两段式提交,而不是一个巨型 PR
| 阶段 | 内容 | 期望 diff | 回滚含义 |
|---|---|---|---|
| 1 纯重构 / 格式化 | 行为不变 | 可以很大 | 回滚无风险 |
| 2 功能改动 | 行为变化 | 应该很小 | 回滚即恢复旧行为 |
| 3 独立提交说明 | 每段一句话 | — | 便于 bisect |
第一段可以被快速批准(因为它理论上不改行为,且可以用"测试全绿"作为证据),第二段才需要认真审。这个顺序不能颠倒——先功能后格式化,会让功能 diff 也被迫放在噪音里。
// 一个可执行的门禁(放在 CI 里,不靠自觉)
review_gate:
max_files: 5
max_semantic_lines: 300 // git diff -w 之后的行数
core_ratio_min: 0.3 // 核心改动 / 总改动
require_split_when:
- rename_count > 0 && feature_change == true
- reformat_count > 200 && feature_change == true
// 触发即失败,提示"请拆成两单",而不是警告
5. 什么时候"顺手改"是对的
不是所有附带改动都该拆。判断标准是"它是否会改变别人的行为假设"。
| 场景 | 可以顺手改 | 理由 |
|---|---|---|
| 同一个文件内的 typo | ✅ | 零风险、零认知负担 |
| 新代码里的 import 顺序 | ✅ | 新文件无历史包袱 |
| 被改函数内的局部变量重命名 | ✅ | 作用域封闭 |
| 相邻文件的格式化 | ❌ | 制造跨文件噪音 |
| 顺手抽个公共方法 | ❌ | 引入新行为 + 影响其他调用方 |
| 顺手升级依赖 | ❌ | 完全独立的风险面 |
第五行是最常见的"善意破坏":抽公共方法看起来是改进,但它改变了调用链,而这个改变没有对应的需求与测试。真要抽,另开一单,让它有自己的评审与回滚路径。
6. 排错:五种"review 失效"的现场
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| PR 挂着没人看 | diff 太大,无人愿意开始 | 拆成 ≤ 400 行 |
| 合了之后才发现 bug | 核心改动被噪音淹没 | 用 -w 重看 + 拆单 |
| 回滚把重构也撤了 | 两类改动混在一个提交 | 两段式提交 |
| reviewer 只留 LGTM | 认知预算已耗尽 | 缩小 diff,而不是要求更认真 |
| 同一个文件反复被改 | 每次都在"顺手统一" | 一次性格式化 + ignore-revs |
// 同样的 diff,两种读法,结果差一个 bug
A 逐行读 830 行(从头到尾) → 120 行后注意力下降,漏掉核心那 12 行
B 先看 git diff -w 的 96 行 → 再看文件结构 → 最后核对格式化
// 成本差一分钟;前者是"尽力了但没看见",后者是"看见了"
第二行给出一个很实用的动作:评审时先看 git diff -w(忽略空白),把噪音过滤掉,再决定要不要逐行。这个动作能把 830 行降到 96 行,成本是一秒钟。
7. 边界:什么情况不该拆
| 场景 | 为什么不该拆 | 说明 |
|---|---|---|
| 线上故障热修 | 拆分会拉长恢复时间 | 允许大 diff,事后补拆 |
| 原子性的迁移 | 中间态不可运行 | 必须一次完成 |
| 生成代码(protobuf 等) | 拆分无意义 | 标注"generated" |
| 一次性脚本/工具 | 无长期维护 | 不必强求 |
| 单人项目 | 没有 review 成本 | 按自己的判断 |
判断口径一句话:可审性是为"别人要读懂并承担风险"付的成本——只要存在第二个要 review 或要维护的人,这单就该守 400 行与 5 文件;而当恢复时间比评审更重要(热修),允许例外的同时要在事后把例外补成常规,否则例外会变成习惯。
8. 自测
- review 的认知预算为什么会在 400 行之后断崖?它与代码难度有关吗?
- 三类噪音里,哪一类"语义不变"、哪一类"会引入新行为"?为什么后者更危险?
- 判据里"可独立回滚"具体指什么?为什么它比行数更硬?
- 两段式提交的顺序为什么不能颠倒?
- 什么情况"顺手改"是合理的?判据是什么?
- 线上热修时允许大 diff,事后必须补什么?
9. 一句话总结
可审性是独立的一笔账:超出认知预算的 diff 等于没被审。噪音(格式化/重命名/顺带重构)的真实代价不是行数,而是把核心改动埋掉、并让回滚失去边界。三条硬判据——核心占比 ≥ 30 %、git diff -w 后 ≤ 300 行、可独立回滚;做法是两段式提交(先行为不变、再行为变化)。判据的边界也很清楚:有人要读懂并承担风险时守这个规矩;恢复时间优先时允许例外,但事后必须补成常规。