> 故障注入要包在组合层上——把故障率做成运行时可调的原子量

Systems & Infrastructure Intermediate 2026-10-09 00:11 2026-10-09
#testing #fault-injection #go #storage #wal #decorator #concurrency

给存储层做故障注入,有两个容易做错的决定:

// TABLE_OF_CONTENTS

结论先行

给存储层做故障注入,有两个容易做错的决定:

  1. 把故障率写成启动时的配置。应该做成运行时可原子调整的量—— 因为最有意思的用例是"先正常写入,再在运行中打开 100% 失败,验证它失败, 再关掉,验证它恢复"。编译期或启动期的开关做不到这个序列。
  2. 只在最底层注入。真正要测的恢复逻辑通常不在被注入的那一层, 而在它上面的组合层。

第 2 条是重点。一个 FaultyEngine 装饰 Engine 接口,很自然; 但 WAL 的价值恰恰在于"底层挂了之后还能恢复", 所以必须测 WALEngine(FaultyEngine(realEngine)) 这个组合, 而不是单独测 FaultyEngine。单独测它只能证明注入本身有效。

为什么

装饰器包一个接口,注入点就和真实实现一样多:

配置项 类型 作用
putFailRate / getFailRate / deleteFailRate int32,0–100 按调用计数取模判定失败
putDelay / getDelay Duration 人工延迟,模拟慢节点
diskFull 原子布尔 Put 返回 no space left on device

全部用原子操作读写,所以测试运行期间可以改,而且并发安全。

有了这个之后,值得写的用例不是"100% 失败时会不会失败",而是这几类:

  • 恢复:打开 diskFull → Put 失败 → 关掉并重置失败率 → Put 必须成功。 只测失败不测恢复,等于只测了一半——而恢复路径才是容易写错的那一半。
  • 部分失败:50% 失败率跑 100 次,断言成功数和失败数都 > 20。 这个断言形状值得学:它排除了"其实全成功"和"其实全失败"两种退化, 而单看"有失败发生"是排除不掉的。
  • 慢节点:注入 50 ms 延迟,断言耗时 ≥ 50 ms 且操作仍然成功。 慢不等于错,这两件事要分开断言。
  • 组合:WALEngine 包在 FaultyEngine 外面。正常写入成功; 底层 100% 失败后写入失败。这一条才真正在测 WAL。
  • 压力下的驱逐:容量 500 字节、连续写 100 条 50 字节。 断言三件事——驱逐确实发生了、内存没超限、仍有存活条目。 第三条防的是"驱逐把所有东西都清掉了"这种过度正确。

怎么用

给自己的存储 / 缓存 / 客户端层加故障注入时:

  1. 包接口,不改实现。 装饰器拿到的类型和真实实现一样, 所以生产代码一行都不用动,也不会有 if testing 分支漏进去。
  2. 所有旋钮用原子类型,允许测试中途调整。这是能写"失败→恢复"序列的前提。
  3. 每个故障都配一个恢复用例。 清单上两倍的行数,但漏掉的那一半是更贵的那一半。
  4. 断言要同时排除两端。 部分失败就断言两边都够多; 驱逐就断言既发生了又没清空。只断言一端的测试会被退化实现通过。
  5. 一定要有一个组合用例,把真正的恢复逻辑放在注入点的上游。 问自己:「我要验的那段代码,在不在被注入的那一层?」不在就要再包一层。

出处

~/Engineering/s3-like-storage 的 tests/fault/fault_injection_test.go, 7 个故障注入用例(FaultyEngine 装饰 storage.Engine)。 相关:用 any() 写的回归测试是假保证——它只要求最容易过的那个夹具通过、手写的自检清单永远是全绿的——它只包含你刚修过的东西、 最危险的工具故障是"静默少做"——技能在、后端不在。