> count % 100 < rate 的故障率是对的,故障的排列是一列方波

Systems & Infrastructure Intermediate 2026-10-12 04:23 2026-10-12
#testing #fault-injection #go #randomness #failure-modes #retry

一个故障注入装饰器,故障率做成了运行时可原子调整的百分比 (这个设计是对的,见 [故障注入要包在组合层上——把故障率做成运行时可调的原子量](/notes/fault-injection-must-wrap-the-composition/))。 判定失败的那一行是:

// TABLE_OF_CONTENTS

结论先行

一个故障注入装饰器,故障率做成了运行时可原子调整的百分比 (这个设计是对的,见 故障注入要包在组合层上——把故障率做成运行时可调的原子量)。 判定失败的那一行是:

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 时也在推进相位。 "这段测试里不注入故障"并不等于"这段测试不影响后面的故障安排"。

为什么

"故障率"这个词同时承诺了两件事,而只有一件被实现了:

  1. 边际概率 —— 长期看多少比例失败。count % 100 < rate 给对了。
  2. 到达过程 —— 失败怎么分布在时间上。它给的是确定性方波。

而所有容错逻辑依赖的是第 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),仓库未改动。