线上出了个问题,回溯发现测试里有一条被 @Ignore 跳过了。这条事实很容易被处理成"某人的疏忽",但它真正说明的是:检测链条上有一个环节没有闭合,而没有人知道它没闭合。跳过那条用例的人可能留了备注,可能等了三天没人修 fixture,也可能根本没人注意到。
逃逸分析的对象不是缺陷本身,而是这条链条上的缺口。缺陷已经修完了,缺口还在;下一批缺陷会从同一个缺口继续漏出去。这篇按"缺口在哪 → 为什么通过 → 输出什么 → 什么时候不做"四层来拆。
1. 逃逸分析的对象:检测链条,不是缺陷
同一个未校验的边界值,在五个关口都会漏,但漏出的形态完全不同:
| 关口 | 漏出形态 | 缺口性质 |
|---|---|---|
| 需求 | 验收标准写成"合理返回",没有可判定的阈值 | 判据不可检验 |
| 设计 | 超时、重试、并发语义没写成约束项 | 约束只存在于口头 |
| 编码 | 异常分支直接 return null,路径不可达 |
分支不可观察 |
| 评审 | 只读 diff,不读调用方与被调方 | 上下文缺失 |
| 测试 | 用例覆盖了主路径,边界值靠"经验判断" | 断言缺失或环境失真 |
注意这张表里没有"人"这一列。归因到人,缺口就无法修复——把一条用例被跳过归因为"态度问题",处理方式是提醒,下个季度还会被跳过;归因为"fixture 依赖一个每月挂三次的外部服务",处理方式是把那条用例的依赖换成容器内桩,问题才真正消失。
─── 逃逸单的最小字段:让缺口可检索
defect_id: D-2841
escaped_to: prod
detected_by: [customer_report] // 线上是被谁发现的
chain_holes:
- stage: test
hole: case_skipped // 没测 / 没覆盖 / 没断言 / 环境不同
evidence: "@Ignore 存在 47 天"
- stage: review
hole: no_context_read // diff-only
related_change: PR-1182
chain_holes 是这张单的核心。没有它,逃逸分析会退化成"再补几条用例",而补错层的用例等于给已经闭合的环节加锁。
缺口只有四类,判定证据各不相同:
| 逃逸原因 | 判定证据 | 对应改进 |
|---|---|---|
| 没测 | 用例列表里没有该输入形态 | 补用例,先复现 |
| 测了没覆盖 | 用例存在但分支未执行(覆盖率报告指出空洞) | 补分支或用例参数化 |
| 覆盖了没断言 | 分支执行了,用例只断言"不抛异常" | 补业务不变量断言 |
| 断言了但环境不同 | 断言在预发成立、生产不成立 | 环境同构校验,而非加断言 |
四类的改进方向完全不同:前两类是"量"的问题,第三类是"判据"的问题,第四类是"环境"的问题。用同一句"加强测试"覆盖四种原因,等于什么都没做。
─── 静默跳过是缺口的放大器
@pytest.mark.skip(reason="flaky, 等 fixture 修好") // 没有到期时间
// CI 配置里 skip 必须二选一:
// strict: true → 出现 skip 直接失败
// skip_budget: 0 → 超过预算即失败,按季度清零
// 只允许带 issue 编号与到期日的 skip
上面这段配置的意义在于:让"没测"这件事在流水线上可见。只要跳过是静默的,缺口就永远不会进入任何报表。
2. "测试通过、线上失败"的三种机理
失败几乎都落在三类差异上,判断顺序建议从环境到数据再到时序——成本依次上升。
| 机理 | 典型表现 | 定位动作 | 能否被灰度拦住 |
|---|---|---|---|
| 环境差异 | 本地/预发通过,线上 500;版本、JVM 参数、中间件配置不同 | 拉两侧配置与依赖版本做 diff | 能,灰度环境若与生产同构 |
| 数据差异 | 特定字符集、NULL、超大值、并发写触发 | 从线上取真实样本回放 | 部分,取决于灰度流量是否含该数据形态 |
| 时序差异 | 重试叠加、消息乱序、缓存过期竞态、时钟漂移 | 构造可控时序,固定时钟与队列 | 很难,低频竞态要足够长的观察窗 |
三种机理的排查成本差一个量级:环境差异常常是配置 diff 十分钟出结论;时序差异可能要写一个可重复的并发脚本,跑几百轮才复现一次。
数据差异的取证有个硬要求:样本必须来自线上,不能手工构造。构造出来的脏数据总是恰好触发你想到的那个分支,而线上触发的是你没想到的那个组合。取真实样本时先脱敏,再固定成回放夹具——夹具里保存的是"能触发缺陷的输入形态",不是完整业务数据。
─── 补测试的顺序不能反
1 复现:在旧代码上让用例失败(红)
2 收窄:固定数据、固定时钟、固定并发度,失败必须可重复
3 补断言:断言落在"业务不变量"上,不是落在实现输出上
4 验证:改代码后转绿,再回滚代码确认重新变红
// 先补测试再验证 = 写了一条永远不会失败的用例
第四条是区分"补测试"与"补覆盖"的唯一手段:回滚代码必须重新变红。做不到这一点的用例,只增加了行覆盖率,没有增加检测能力。
3. 输出:把逃逸换成检测手段,而不是换成追责
逃逸分析的产物应该是检测手段的改动,可以落到三处:新增断言、新增环境同构校验、新增发布前检查项。写不出这三样,分析就只是在复述事故经过。
| 输出类型 | 合格形态 | 不合格形态 |
|---|---|---|
| 用例 | 能复现、能回滚变红 | "已补充相关测试" |
| 环境 | 配置项进同构校验清单 | "注意预发与生产一致性" |
| 流程 | 跳过用例必须在 CI 里显式失败 | "加强测试纪律" |
| 监控 | 新增可观测信号与阈值 | "加强线上巡检" |
定级必须与普通缺陷分开。逃逸缺陷的价值不在严重度,而在它证明某道关口失效;混进普通缺陷池按严重度排序,它会因为"影响面小"被排到最后,而它携带的缺口信息就此丢失。单独一个标签、单独一个池,按缺口类型聚合。
逃逸率作为度量,只看趋势与聚集,不看绝对值:生产发现缺陷 / 全部缺陷 的分母随登记习惯变化,跨团队比较没有意义;但同一团队连续四个周期的走向,以及"哪个关口漏得最多"的分布,是可靠信号。
─── 检测关口分布:找出漏得最多的那道关口
SELECT hole_stage, hole_type, COUNT(*) AS n,
ROUND(100.0 * COUNT(*) / SUM(COUNT(*)) OVER (), 1) AS pct
FROM escape_defect
WHERE escaped_at >= DATE_SUB(NOW(), INTERVAL 180 DAY)
GROUP BY hole_stage, hole_type
ORDER BY n DESC;
// 同一 (stage, hole) 组合连续两个周期都在前三 = 改进没落地
同一类逃逸重复出现,说明改进动作没有落到执行层。这时不该再写一条新规则,而该检查上一次的规则为什么没生效——是被绕过、被豁免、还是根本没人执行。
─── 四个度量,各自回答一个问题
escape_rate: prod_found / all_found // 门禁有效吗(只看趋势)
gate_distribution: group_by(stage, hole) // 漏在哪道关口
recurrence_rate: same_hole / resolved // 改进落地了吗
backfill_validity: red_on_revert / backfilled // 补的用例有检测力吗
window: 180d
exclude: [env_class, known_limitation]
// backfill_validity 最容易被跳过,也最能拆穿"已补测试"
backfill_validity 是这样算的:补进去的用例,把修复代码回滚后仍然变绿的比例。这个比例高,说明补的是覆盖而不是检测。它不需要全量统计,每月抽十条就足够看出问题。
4. 排错:分析做不下去的五种情况
| 现象 | 原因 | 处理 |
|---|---|---|
| 每张逃逸单都归到"人为疏忽" | 缺口字段没填,只能写人 | 强制 chain_holes 至少一条,且必须是环节名 |
| 补了测试,同类问题还漏 | 补在已闭合的关口 | 按缺口定关口,不按缺陷定关口 |
| 逃逸率忽高忽低 | 分母随登记习惯波动 | 改用绝对单数 + 关口分布 |
| 复盘变成追责会 | 结论要求"谁负责" | 只允许结论指向可改动的环节 |
| 灰度上线后仍逃逸 | 灰度流量不含该数据形态 | 检查灰度流量构成,而非扩大灰度比例 |
第三条值得展开:灰度与金丝雀能拦住的是行为差异——新逻辑对同一输入给出不同输出。它拦不住数据形态差异(灰度流量里没有那种脏数据)和低频时序(观察窗不够长)。所以"上灰度了"不能作为关闭逃逸分析的理由。
5. 边界:什么时候完整逃逸分析是过度投入
| 场景 | 该做的动作 | 不该做的 |
|---|---|---|
| 变更量极小(一行文案) | 记一条缺口备注,五分钟 | 开一小时的复盘会 |
| 系统已冻结、即将下线 | 记录不修,转成迁移清单 | 为旧系统补测试 |
| 缺陷影响可忽略(显示错位) | 单条登记 | 全链条回溯 |
| 同一缺口本月第三次出现 | 停掉单次分析,改流程 | 继续逐单复盘 |
| 逃逸原因明确且已修复 | 验证改进落地 | 再写一份分析文档 |
频率上,全量逃逸分析按事件触发即可:事故级必须做,普通线上缺陷抽样做(比如每月抽三张),同类重复时升级为流程动作。每张逃逸单都做深度分析,成本会迅速超过它挽回的损失——一次完整回溯通常要半天到一天人力,而多数缺陷的缺口是同一个。
判断过度的界线很朴素:分析的边际产出是"新的缺口类型"还是"第 N 张同类型单"。前者值得做,后者只值得记一笔。所以抽样不是偷懒,而是把预算留给还没被识别过的缺口形态。
6. 自测
- 逃逸分析的对象为什么不是缺陷本身?两者在产出上差在哪?
- "补测试"和"补覆盖"用什么动作区分?
- 三种失败机理里,哪一种最难被灰度拦住?为什么?
- 逃逸缺陷为什么要与普通缺陷分开定级?
- 同一类逃逸连续两个周期出现,下一步该做什么?
- 哪些场景下只登记、不做完整回溯?