同一类空指针三个季度里被修了三次,三份复盘都写得很长,三份都写了"加强测试"。失效点不在篇幅,在产出物:复盘产出的是叙述,不是规则。叙述只能在会议室里让人点头,规则才能在别人下一次提交代码时说话。
要回答的不是"这次为什么错",而是"要改什么,才能让这个形状的错不可能再出现一次"。判据只有一条:这条结论能不能写成检查清单的一行,或者自动化里的一个断言。
1. 两种产出物的差别
| 维度 | 叙述型 | 规则型 |
|---|---|---|
| 产出物 | 时间线 + 根因描述 + "加强 XX" | 可判定的检查项 + 自动化断言 |
| 载体 | 人的记忆、wiki 深处 | 仓库文件、CI 配置、IDE 设置 |
| 执行者 | 当时开会的人 | 后来的人、机器 |
| 失效方式 | 人一换就消失 | 过期不清理变成噪声 |
| 验收 | 文档写完 | 同类缺陷复发率降下来 |
"加强测试"不是措施,是愿望:谁加强、加到哪个类、什么输入下必须失败,一个字都没说。检验办法是能不能写出反例——说不出"下次出现这个形状的代码时,哪一步会拦住它",结论就还停在叙述层。
2. 四个维度与对应的预防手段
分类不是为了归档,是为了让手段可以直接推出来。一个缺陷至少要在三个维度上有坐标,否则选出来的预防手段是猜的。
| 维度 | 取值 | 推得的预防手段 | 代价 |
|---|---|---|---|
| 发现阶段 | 编码 / 单测 / 集成 / 预发 / 生产 | 越靠后,手段越贵,且只能补网 | 生产发现一次的成本远高于编码期 |
| 逃逸路径 | 没写用例 / 用例没跑 / 跑了没断言 / 没评审到 | 补哪一层,取决于路径而不是阶段 | 补错层等于白做 |
| 根因类型 | 需求理解 / 设计缺口 / 编码疏忽 / 环境差异 | 需求类只能改需求侧动作 | 编码侧手段对需求类缺陷无效 |
| 修复成本 | 分钟 / 小时 / 天 / 事故 | 决定要不要固化成规则 | 只有高成本或高频才值得 |
这张表的用法是先定根因类型,再看阶段:根因是"需求理解偏差"的缺陷,无论发生在预发还是生产,加强代码评审都不会有效——要改的是需求确认的产出物(验收标准写没写、反例问没问)。
3. 抽象阶梯:个案 → 模式 → 规则 → 门禁
| 层 | 形态 | 判据 | 常见错误 |
|---|---|---|---|
| 个案 | "3 月 12 日订单接口空指针" | 有没有具体输入 | 停在这一层,视为已复盘 |
| 模式 | "外部回调的字段可能为空" | 能否举出第二个同类样例 | 只有一个样例就宣布是模式 |
| 规则 | "回调入口的参数必须显式校验" | 能否被机器判定 | 写成"注意判空"这种不可判定句 |
| 门禁 | CI 里跑、IDE 里提示、运行时兜底 | 违反时是否真的失败 | 只提示不阻断,规则变建议 |
上跳的关键动作是找第二个样例:只出现一次的大概率是个案,为它建规则是过度反应;出现两次以上、分布在不同人手上的,才是模式。
# rules/DEF-021-callback-null.yaml
id: DEF-021
from_defect: INC-2213
pattern: 外部回调 / 消息消费入口的可选字段被直接解引用
rule: 入口方法首个参数必须经 validate(...) 或显式 null 分支
enforced_at: [ci, ide]
exceptions:
- 内部服务间调用且契约已标注 required
owner: order-team
review_by: 2026-06-30 # 到期必须复核,否则自动失效
4. 逃逸缺陷必查三问
逃逸到生产的缺陷,价值高于被拦住的缺陷——它暴露的是流程的孔。三问必须都有答案,缺一个就等于没查:
| 问题 | 要的答案形态 | 不合格的答案 |
|---|---|---|
| 为什么测试没抓到? | 缺用例 / 用例没在这条路径上 / 断言太弱 | "用例覆盖不够" |
| 为什么评审没看到? | 改动太大 / 缺少上下文 / 检查项里没这条 | "评审不够仔细" |
| 为什么上线没拦住? | 灰度没覆盖该流量 / 缺少对账 / 告警阈值太宽 | "下次注意" |
第二问最常被跳过,因为它指向组织问题。但评审漏看的真实原因通常很具体:单次 diff 超过几百行,或改动散在十几个文件——该改的是分批规则。
5. 规则落到门禁的三层
| 层 | 手段 | 反馈延迟 | 适用规则 | 失效风险 |
|---|---|---|---|---|
| IDE | 检查插件、模板、snippet、保存时格式化 | 秒级 | 命名、结构、常见空值、禁用的 API | 可被本地关闭 |
| CI | 静态检查、架构测试、必跑用例、依赖审查 | 分钟级 | 分层依赖、越界调用、契约兼容 | 可以被 --no-verify 绕过 |
| 运行时 | 断言、校验、降级、对账任务 | 小时级 | 数据不变量、外部输入、跨系统一致性 | 发现问题但已影响用户 |
三层不是备份关系,是成本递增的漏斗:能在 IDE 拦的不要留到 CI,能在 CI 拦的不要留到运行时。反过来的错误更常见——需要上下文判断的规则塞进 IDE 提示,全员点"忽略"后规则就废了。
# PR 检查清单片段:只保留"人看才有效"的项
[ ] 新增外部入口是否走统一校验层(规则 DEF-021)
[ ] 数据变更是否带对账/回滚脚本
// 架构规则写成测试,而不是写成文档:违反即构建失败
@ArchTest
static final ArchRule callbacks_must_validate =
methods().that().haveNameMatching("onCallback|onMessage")
.and().areDeclaredInClassesThat().resideInAPackage("..adapter..")
.should().beAnnotatedWith(Validated.class);
6. 缺陷密度的偏差:密度低可能是测得少
| 指标 | 常见口径 | 偏差来源 | 决策含义 |
|---|---|---|---|
| 缺陷密度 | 缺陷数 / KLOC | 分母是存量代码,新增代码的问题被摊薄 | 重构期会"变好",纯属算术 |
| 逃逸率 | 生产缺陷 / 总缺陷 | 测得少则分子分母同时缩 | 分母小的时候不可比 |
| 复发率 | 命中既有规则的新缺陷 / 规则覆盖的缺陷 | 分类粒度决定一切 | 唯一适合衡量预防的指标 |
密度的方向性错误很典型:砍掉一半单元测试后密度跟着下降,被解释为"质量提升"。密度低有三种解释——代码真干净、测得少、缺陷记到了别处。它只能做同口径环比。
真实分布上,多数组织的缺陷根因集中在需求理解与设计缺口,而不是编码疏忽:把预算全押在静态检查上,等于给非主要矛盾加杠杆。
7. 规则的生命周期与规则库膨胀
| 阶段 | 触发条件 | 动作 | 不做的后果 |
|---|---|---|---|
| 新增 | 同类缺陷第 2 次出现 | 写成规则 + 指定执行层 | 第三次还会来 |
| 加固 | 规则被绕过(有例外但没登记) | 补例外清单或收紧判定 | 规则名存实亡 |
| 失效 | 承载它的代码/框架已下线 | 标记 deprecated,一个版本后删除 | 噪声掩盖真信号 |
| 清理 | 连续两个季度零命中 | 删除或降级为文档说明 | 规则库变成没人读的清单 |
规则库膨胀是必然的,需要配额:规则总数与误报率同时设上限。误报率超阈值时正确的动作是删规则,不是加解释——加解释等于把成本转给每一个看到告警的人。
8. 同人反复 vs 系统性复发
| 现象 | 判据 | 对策 | 错误对策 |
|---|---|---|---|
| 同一人反复犯 | 集中在 1–2 人,其他人零命中 | 一对一复盘具体判断过程、结对、缩小其负责面 | 全员培训 |
| 不同人系统性犯 | 分布均匀,新人老人都有 | 改工具、改模板、改门禁 | 逐个谈话 |
| 老手也犯 | 集中在某类接口/某段时间 | 查上下文缺失、查并行任务切换 | 归因为"责任心" |
区分办法很简单:把缺陷按人聚合一次。集中度高是个体问题,分布平坦是系统问题。用错对策的代价是双向的——对系统问题做个体问责,会让人开始隐藏缺陷。
9. 复盘会的组织:只谈事实,产出物明确
时长 45 分钟,硬性时间盒
0–5 :只读事实(时间线、日志、影响面),不允许评价
5–15 :逃逸路径三问,必须有具体答案
15–30 :根因定位,停在"可改动的环节",不做人品归因
30–40 :产出规则候选,每条带执行层与判定方式
40–45 :认领与到期日;无人认领的规则直接丢弃
会前不发材料、会上现读,是最大的低效来源:参与者第一次看到时间线,四十分钟全花在理解事实上。
10. 度量:只看复发率
-- 复发率:新缺陷中落在既有规则覆盖范围内的比例
SELECT date_trunc('month', d.created_at) AS month,
count(*) FILTER (WHERE r.id IS NOT NULL) * 1.0 / count(*) AS recurrence_rate
FROM defects d LEFT JOIN defect_rules r ON d.rule_id = r.id
WHERE d.source = 'production'
GROUP BY 1 ORDER BY 1;
总缺陷数不是预防指标——它同时受需求数量、发布频率、测试强度影响。复发率只在预防动作改变时动。
11. 排错:预防体系自身的失效
| 现象 | 原因 | 处理 |
|---|---|---|
| 规则写了但没人违反过,缺陷照旧 | 规则判定的不是真实触发路径 | 拿最近三个缺陷回放,看规则是否命中 |
| CI 全绿但线上仍出同类问题 | 执行层选错(该运行时兜底却只在 CI) | 按"数据在运行时才能判定"重新分层 |
| 团队开始绕过检查 | 误报率高,或反馈太慢 | 删低信噪比规则,别加免责说明 |
| 老规则频繁误报 | 承载代码已演进,规则没跟 | 触发 review_by 到期清理 |
12. 边界:什么不值得建规则
| 场景 | 做法 | 理由 |
|---|---|---|
| 一次性脚本、导入导出工具 | 不建规则,只留注释 | 生命周期短于规则维护成本 |
| 外部依赖故障(第三方超时、证书过期) | 建监控与降级,不建编码规则 | 规则管不到别人的行为 |
| 偶发环境差异(只在某台机器复现) | 固化环境,不写检查项 | 修环境比修规则便宜 |
| 单人项目、发布频率极低 | 用清单代替门禁 | 门禁的固定成本摊不开 |
| 规则只能靠人判断(命名好坏、抽象是否合理) | 留在评审,不进 CI | 不可判定的规则会训练出点"忽略"的习惯 |
13. 自测
- 一条复盘结论要满足什么条件,才算进了"预防"层?
- 同类缺陷出现第几次才值得建规则?为什么是这个次数?
- 逃逸缺陷的三个必查问题里,哪一个最常被跳过,为什么?
- 缺陷密度下降可能对应哪三种解释?
- 为什么"同一个人反复犯"与"不同人系统性犯"的对策完全不同?
- 哪类规则不该放进 CI,放进去的代价是什么?