> 故障注入要包在组合层上——把故障率做成运行时可调的原子量
给存储层做故障注入,有两个容易做错的决定:
结论先行¶
给存储层做故障注入,有两个容易做错的决定:
- 把故障率写成启动时的配置。应该做成运行时可原子调整的量—— 因为最有意思的用例是"先正常写入,再在运行中打开 100% 失败,验证它失败, 再关掉,验证它恢复"。编译期或启动期的开关做不到这个序列。
- 只在最底层注入。真正要测的恢复逻辑通常不在被注入的那一层, 而在它上面的组合层。
第 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 字节。 断言三件事——驱逐确实发生了、内存没超限、仍有存活条目。 第三条防的是"驱逐把所有东西都清掉了"这种过度正确。
怎么用¶
给自己的存储 / 缓存 / 客户端层加故障注入时:
- 包接口,不改实现。 装饰器拿到的类型和真实实现一样,
所以生产代码一行都不用动,也不会有
if testing分支漏进去。 - 所有旋钮用原子类型,允许测试中途调整。这是能写"失败→恢复"序列的前提。
- 每个故障都配一个恢复用例。 清单上两倍的行数,但漏掉的那一半是更贵的那一半。
- 断言要同时排除两端。 部分失败就断言两边都够多; 驱逐就断言既发生了又没清空。只断言一端的测试会被退化实现通过。
- 一定要有一个组合用例,把真正的恢复逻辑放在注入点的上游。 问自己:「我要验的那段代码,在不在被注入的那一层?」不在就要再包一层。
出处¶
~/Engineering/s3-like-storage 的 tests/fault/fault_injection_test.go,
7 个故障注入用例(FaultyEngine 装饰 storage.Engine)。
相关:用 any() 写的回归测试是假保证——它只要求最容易过的那个夹具通过、手写的自检清单永远是全绿的——它只包含你刚修过的东西、
最危险的工具故障是"静默少做"——技能在、后端不在。