> count % 100 < rate 的故障率是对的,故障的排列是一列方波
一个故障注入装饰器,故障率做成了运行时可原子调整的百分比 (这个设计是对的,见 [故障注入要包在组合层上——把故障率做成运行时可调的原子量](/notes/fault-injection-must-wrap-the-composition/))。 判定失败的那一行是:
结论先行¶
一个故障注入装饰器,故障率做成了运行时可原子调整的百分比 (这个设计是对的,见 故障注入要包在组合层上——把故障率做成运行时可调的原子量)。 判定失败的那一行是:
func (e *FaultyEngine) shouldFail(rate int32) bool {
count := atomic.AddInt64(&e.callCount, 1)
return count%100 < int64(rate)
}
把它单独跑 300 次,记下成功/失败的连续段长度:
| 设定故障率 | 实际失败数 | 连续段长度 |
|---|---|---|
| 50% | 150/300 ✓ | 49, 50, 50, 50, 50, 50, 1 |
| 10% | 30/300 ✓ | 9, 90, 10, 90, 10, 90, 1 |
| 99% | 297/300 ✓ | 98, 1, 99, 1, 99, 1, 1 |
总量完全正确,排列是一列周期 100 的方波。 50% 故障率意味着 "连续失败 50 次,再连续成功 50 次",而不是"每次独立地有一半概率失败"。 1000 次调用里最长连续失败段 = 50。
后果:任何重试次数小于 50 的重试策略,在失败相位里必然耗尽重试, 在成功相位里必然一次就过。这个注入器永远造不出重试逻辑真正要处理的那个场景 —— 一次孤立的失败。
还有两处同源的问题:
callCount是 Put / Get / Delete 共用的一个计数器。Put 的失败时刻 取决于中间插进来多少次 Get。两个操作的故障计划互相耦合。shouldFail无条件自增,故障率为 0 时也在推进相位。 "这段测试里不注入故障"并不等于"这段测试不影响后面的故障安排"。
为什么¶
"故障率"这个词同时承诺了两件事,而只有一件被实现了:
- 边际概率 —— 长期看多少比例失败。
count % 100 < rate给对了。 - 到达过程 —— 失败怎么分布在时间上。它给的是确定性方波。
而所有容错逻辑依赖的是第 2 条。重试、退避、熔断、quorum 降级、hinted handoff, 判据全是"连续失败多少次""窗口内失败多少次",没有一个只看长期比例。 一个只对了第 1 条的注入器,在报告里看起来覆盖了故障,实际上把容错逻辑 最关心的那个维度钉死成了一个常数。
这也解释了为什么配套的断言看不出问题。50% 那条测试做 100 次 Put:
assert.True(t, successes > 20, ...)
assert.True(t, failures > 20, ...)
49 失败 + 51 成功,过。但"前 49 次全失败、后 51 次全成功"也过, 真随机也过,"奇偶交替"也过。 这个断言检验的是边际比例, 而区分这三种实现的恰恰是排列 —— 和 排序后聚类去找"两个模态",会把一次回退当成模态然后放行、 同一台机器上 6/9 个指标是双峰的——进程启动时二选一,然后整轮不变 是同一个判别问题: 离散程度相同、排列方式不同的两组数,意义可以完全相反。
最后一层:这套注入器的 7 个测试,断言的内容是
"设了 100% 失败率之后 Put 返回含 injected 的错误"——
即注入器注入了。没有一条断言"失败之后系统做了该做的事"。
去找那个恢复路径,它是:
etag, err := rm.putToNode(nodeID, key, data)
if err != nil {
logger.Error("Replication failed", ...)
// TODO: Add to retry queue
}
重试队列不存在。所以注入器造不出孤立失败这件事,暂时还没有代价 —— 但这正是"工具的缺陷和它要测的东西的缺陷互相掩护"的典型形状。
怎么用¶
写完故障注入器,先把它自己的输出序列打出来看一眼。 十几行代码:
for i := 1; i <= 300; i++ { fmt.Print(map[bool]string{true:"x",false:"."}[shouldFail(50)]) }
看到 xxxxx.....xxxxx..... 这种规律就说明它是调度器,不是随机源。
真随机的 50% 应该看到大量长度 1–3 的段,最长段在 300 次里约 8–9。
要随机就用随机源,并且把种子打出来。
rand.New(rand.NewSource(seed)).Float64() < rate,失败时打印 seed,
这样既是随机的,又是可复现的 —— 确定性和真随机不必二选一,
需要的是可复现的随机。
显式地测排列,而不是只测比例。 容错逻辑要测的是三个独立场景, 它们对应三种排列,一个比例参数表达不出来:
| 场景 | 注入方式 | 验证什么 |
|---|---|---|
| 孤立失败 | 第 N 次失败一次,其余成功 | 重试一次就该成功 |
| 突发失败 | 连续 K 次失败(K > 重试上限) | 该放弃、该计数、该告警 |
| 稳定劣化 | 独立 p 概率 | 长期成功率、退避是否收敛 |
每个操作一个计数器/一个随机源。 共用状态会让一条测试的行为 取决于另一条测试调了多少次别的方法,这是并发测试里最难查的那类不稳定。
注入器的测试断言"系统恢复了",而不是"注入生效了"。 后者只证明了你自己的 mock 能用。
出处¶
~/Engineering/s3-like-storage:tests/fault/fault_injection_test.go:21-76
(shouldFail 与三个共用 callCount 的调用点)、:151-167
(50% 那条测试的断言)、internal/storage/replication.go:343-351
(// TODO: Add to retry queue)。段长度与最长失败段是我 2026-10-11
把那三行逻辑抄进一个独立 Go 程序跑出来的(go1.26.0),仓库未改动。