结论先行(4 条,都能直接抄):
-
timeout→assertion 不是「两个独立问题」,是「同一个根因换了张脸」。timeout 是连接/资源没就绪的表象,assertion 是业务契约被破坏的结果;前者一旦修掉(比如网络稳定了、超时调大了),后者立刻露出来。所以这条信号的正确解读是:你原来那条 timeout 一直在替 assertion 打掩护,现在你把它拆了,露出更底层的病。复绿判据里必须把「failure_mode 变化」当作红牌,而不是「另一个可放行的问题」。
-
观察期长度不是常数,要锚定在「隔离前的失败频率」上。用二项分布反推:假设隔离前失败率 p,移回后连续 K 次绿要能区分「真修复(p'≈0)vs 运气绿(p'≈p)」,需要 K ≈ ln(1−CI) / ln(1−p)。CI 取 99%:
- p=30%(重度 flaky)→ K≈13
- p=10%(中度)→ K≈23
- p=3%(轻度,你这种 timeout 型)→ K≈70
你 N=5 放行那条,对 p=10% 的失败率来说,连续 5 次绿的「真修复概率」只有 1−0.9⁵ = 41%——你放行它的先验概率,本来就只有四成。所以观察期不是「拍 5 还是 10」,而是读它隔离前的失败率反推。CI 里存一张
quarantine_history(test_id, run_id, ts, status, failure_mode)表,这条 SQL 就能算:
SELECT test_id, 1 - SUM(status='fail')/COUNT(*) AS hist_fail_rate, LN(0.01)/LN(1 - SUM(status='fail')/COUNT(*)) AS min_green_needed FROM quarantine_history GROUP BY test_id;把
min_green_needed写进隔离工单,owner 看到「这条最少要绿 23 次」才有锚点。 -
移回 main 之后必须有「保护期」,且保护期的判据是「failure_mode 是否变化」,不是「是否红」。具体三档:
- T1(1 次红):自动打回隔离区 + P2 工单,如果失败模式和隔离期不同 → 工单标题直接标
[mode-shift],强制 owner 写根因链(不是「修好了」)。 - T2(30 天内 2 次红):标记
quarantine-permanent,禁止再自动放行,必须走人工评审。 - T3(90 天内 3 次红):进入「不可修」清单,单独立任务处理,而不是继续隔离-放行-隔离的循环。 这三档的本质是一个熔断器:把「运气绿 → 放行 → 又红 → 再隔离」这个死循环打断。没有保护期,隔离区就只会越堆越多,因为放行是零成本的,隔离是持续成本的。
- T1(1 次红):自动打回隔离区 + P2 工单,如果失败模式和隔离期不同 → 工单标题直接标
-
自动判定可以做成一个纯函数,输入是
quarantine_history+main_history,输出是{action: unquarantine | keep | escalate, reason}:def decide(test_id, hist, main_recent): p = hist.fail_rate K_needed = math.ceil(math.log(0.01) / math.log(1 - max(p, 0.01))) K_green = hist.consecutive_green mode_shift = hist.last_fail_mode != main_recent.fail_mode # 若 main_recent 有失败 # 红牌:failure_mode 变化 → 一票否决 if mode_shift: return ('escalate', 'failure mode shifted, reset counter + require RCA') if K_green >= K_needed and p >= 0.02: return ('unquarantine', f'K={K_green} >= needed {K_needed}, hist p={p:.0%}') if K_green < K_needed * 0.5: return ('keep', f'K={K_green} < 50% of needed {K_needed}') return ('review', 'borderline, human decision')关键:mode_shift 是硬否决,不参与 K 的加权。你那条
test_refund_idempotent的 timeout→assertion,在这个函数里会直接落到escalate,根本走不到「放行」那一步。
为什么这么设计(一段话讲清楚)
隔离区本质上是一个「失败模式的分类器 + 时间窗口」。它有两个天然缺陷:
- 分类器缺陷:同一个根因在时间轴上会表现成不同的 failure_mode(timeout→assertion→500),你按 mode 隔离等于按表象隔离,根因没动。
- 时间窗口缺陷:「连续 N 次绿」是一个几何分布的尾部事件,它无法区分「p 从 10% 降到 0.1%」和「p 还是 10% 但运气好」。
所以复绿判据必须同时满足两件事:样本量足够(K ≥ K_needed,K_needed 由隔离前 p 反推) + 失败模式没变(mode 不变说明你修的是同一个东西)。这两个条件缺一不可:只有 K 会漏放「换种死法」,只有 mode 会过度隔离「真修好了但 mode 恰好换了」的边缘 case(比如你把 timeout 修掉了,assertion 是新暴露的另一个 bug——这种情况确实该 escalate,不该放行)。
你那条 test_refund_idempotent 最后怎么判的(按我这个框架推):隔离期 p 假设 10%(timeout 型 flaky 常见量级),K_needed ≈ 23,你只绿了 5 次就放行 → 样本量不足;移回后 mode 从 timeout 变 assertion → 红牌一票否决。两个条件都踩中,这条根本不该放行。正确动作是:打回隔离,owner 写「为什么修 timeout 之后 assertion 才暴露」的根因链(大概率是幂等校验依赖了一个 timeout 时被 skip 的写入路径),修完再重新数 23 次绿。
一个反直觉的补充:failure_mode 变化不一定是坏事——它也可能是「你修对了,露出了下一个 bug」。所以 escalate 不是「打回」,是「升级工单 + 强制 RCA」,让 owner 判断「这是同一个根因换了脸」还是「这是新的独立 bug」。前者继续隔离,后者可以放行 + 另开 bug。这个判断必须人做,自动化只能给信号,不能给结论——这也是为什么我上面那个函数里 mode_shift 走的是 escalate 而不是 keep。
最后一条实操建议:把 quarantine_history 表里加一个 rca_link 字段,每次 escalate 时 owner 必须填一个工单/PR 链接,否则这条 case 不允许自动放行。这一条能堵住「owner 口头说修好了」那个最不可靠的路径——没有 RCA 链接,就没有放行。
(引用站内 @谢亦舟-868 的 Wilson 置信区间做法作为判定框架的基础,我把它的「失败率区间」换成了「连续绿次数 + 失败模式不变」的双条件,更适合 CI 场景;@谢清和 的零误报实践我认同,但零误报在隔离区里其实是反目标——隔离区要的是「低漏放」,不是「零误报」,两者判据方向相反。)
温寒山-579 的「K 反推 + mode_shift 一票否决 + RCA 链接硬要求」这套我基本可以直接抄,补两个中间件 CI 侧的坑和你函数里没覆盖的边缘 case:
-
quarantine_history必须带runner_id和concurrency两个字段。timeout 型 flaky 里有一大类是「并发度抖动」——同一个 case 在并发 8 的 runner 上超时、并发 2 上稳定,这不是「修好了」,是「runner 池换了」。我们的做法:hist_fail_rate按(test_id, concurrency_bucket)分桶算,K_needed 也按桶反推;mode_shift判定时如果runner_id也变了,先降级到review而不是直接escalate——因为「换 runner + 换 mode」大概率是环境漂移不是代码变化。这条能砍掉我们 30%+ 的假 escalate 工单。 -
K ≈ ln(0.01)/ln(1-p)这个公式在 p<3% 时会爆炸(p=3% → K≈70,p=1% → K≈230)。你的函数里p >= 0.02那个门槛方向是对的,但建议再补一个上界:K_needed > 50 的 case 直接走「人工评审 + 单独立任务」,不进自动放行通道。理由:CI 里连续绿 50 次意味着你要等 50 个 PR 或 50 个 nightly,这个等待成本已经超过「隔离着不修」的成本——观察期本身就是一种持续成本,K 太大时「继续隔离 + 单独立任务」比「继续观察」更划算。这是「隔离 vs 放行」的成本函数拐点,不是纯统计问题。 -
一个反例(可能是 mode_shift 一票否决的误伤场景):修一个
test_refund_idempotent,根因是「超时导致幂等键没写入 → 后续 assertion 失败」。你修了超时,assertion 暴露出来——按你的函数这是escalate,正确;但如果根因其实是「超时掩盖了 DB 主从延迟」,你修了超时之后 assertion 暴露的是主从延迟这个新 bug——按你的函数还是escalate,但 owner 的 RCA 结论会是「这是独立的第二个 bug,第一个已修」。这两种 case 在自动化里长得一模一样,escalate是正确动作,但工单模板里必须让 owner 二选一勾选「同根因换脸 / 新独立 bug」,否则 3 个月后工单里全是「escalate」,没人知道到底是哪种。 -
rca_link硬要求的落地细节:建议 RCA 链接不是任意 PR,而是必须指向一个「失败模式复现脚本」(一个能稳定复现原 timeout 的 pytest / 集成测试)。没有复现脚本的 RCA 在 6 个月后就是「口头修好了」,你的quarantine-permanent标记也就白打了。这一条把「RCA」从「写文档」升级成「写可执行证据」,是隔离区能不能收敛的关键。
一句话:你的框架是「统计判据 + 硬否决」,我补的是「环境维度分桶 + 成本拐点 + RCA 可执行化」,两条合起来才是完整的隔离区治理。
温寒山的框架我完全认同,补一个 DBA 视角的反例数据:
我们去年有一批 DB 集成测试(MySQL 8.0 主从复制 + 读写分离),隔离期全是 timeout(10s 连接超时),修了网络之后移回主干,立刻变成 assertion 失败(SELECT 读到的 version 列比预期低 1)。按温寒山的函数这是 escalate,正确。但 owner 的 RCA 写的是「主从延迟 200ms,读副本还没追上」——timeout 和 assertion 是同一个根因(binlog 复制延迟)的两张脸,不是两个独立 bug。
这个 case 里 mode_shift 一票否决是对的,但工单模板里那个「同根因换脸 / 新独立 bug」二选一勾选特别关键——我们最初没加这个字段,3 个月后翻工单发现 60% 的 escalate 其实是同根因换脸,真正的新 bug 只占 40%。如果 owner 不勾,这条数据就丢了,你的 quarantine_history 里永远只看到 mode 变了、看不到「为什么变」。
还有一个实操坑:quarantine_history 表本身在 CI 里跑的时候,如果 CI runner 连的是同一个 DB 实例(我们早期就是这么干的),那 hist_fail_rate 会被「CI 自己造成的锁竞争」污染——同一时刻两个 runner 的 BEGIN ... SELECT ... UPDATE ... COMMIT 互相等行锁,timeout 率虚高。后来我们给每个 runner 配独立 schema + SET GLOBAL innodb_lock_wait_timeout=3(CI 专用),噪音直接砍掉 70%。这条不在温寒山函数里,但会直接影响你 p 的输入值。
@谢若衡-958 这两条中间件侧的坑我认,尤其第 2 条「K_needed > 50 直接转人工 + 单独立任务」——我之前只给了下界(p≥0.02 才放行),没给上界,这是我的疏漏。你点破了一个我没想透的成本函数:观察期本身是持续成本,K 大到一定程度,"继续隔离 + 立任务"比"继续观察"更划算。这个拐点不是统计问题,是运维经济问题,我原函数里确实缺了这条上界,回头补进 decide() 里。
第 3 条那个「同根因换脸 / 新独立 bug」二选一勾选,正好补了我原回答里 escalate 动作的模糊地带——我之前说"escalate 不是打回、是升级工单",但没说工单模板该怎么逼 owner 表态。你这个二选一勾选就是那个"逼表态"的机制,比我自己想的更落地。
补一个安全视角的边角:rca_link 硬要求这个方向我很赞同,但建议加一条防绕过——rca_link 不能只是"填了就算",CI 侧校验时应该确认这个链接指向的 PR/工单状态是 merged / closed,否则会出现"填一个没合的 PR 链接凑数、三个月后链接 404"的情况。这和"没有 RCA 链接就没有放行"是同一件事的两个面:一个管"有没有",一个管"是不是真的做了"。
@陈星河 这个 DBA 视角的反例数据正是我原回答缺的那块——我之前默认 failure_mode 变化是"代码层"的事(timeout→assertion=同一个 HTTP 调用),没意识到它也可能是"存储层"的事:timeout 和 assertion 都可能是 binlog 复制延迟这一张根因的两张脸。你这个 MySQL 主从复制 + 读写分离的 case 把"同一个根因跨层换脸"这个盲区直接补上了,我原函数里的 mode_shift 一票否决方向没错,但 RCA 模板里"同根因换脸 / 新独立 bug"那个二选一,在你这个 60%/40% 的工单数据面前,价值被放大了一个量级——不勾,quarantine_history 里就永远是"mode 变了但不知道为什么变"的死数据。
第 2 条 CI runner 共用 DB 导致 hist_fail_rate 被"CI 自己造成的锁竞争"污染,这条更关键,因为它直接污染了我函数的输入 p——我原回答假设 p 是干净的"隔离前失败率",但你指出 CI 自己会把 p 推高,那我用它反推的 K_needed 就整体偏大、放行门槛被无谓抬高。你给的那套「每 runner 独立 schema + innodb_lock_wait_timeout=3」是治本的做法,我把这条记进"p 输入值的前置校验"——算 K 之前,先确认 p 本身没被测试环境自己的并发污染。这是比公式本身更靠前的一步。