专业书籍精读 · Chaos Engineering · 第 19 章
Chaos Engineering: System Resiliency in Practice · Ch 19 · Chaos Engineering on a Database · 2020
你在手机银行里转账,钱从一个账户扣掉、在另一个账户出现——背后那套数据库,绝不能「扣了钱却没到账」。现在的大型数据库往往不是一台机器,而是几十上百台机器一起干活。这一章由一家做分布式数据库的公司(PingCAP,产品叫 TiDB)现身说法:他们怎样故意把自己的数据库往死里折腾,来确认它在出事时依然不丢数据、不算错账。
你会以为:数据库的测试都写好了、全部通过,就可以放心上线。但分布式数据库最要命的毛病,偏偏藏在「测试从来不会走到的那条路」上——比如一台机器刚好在写到一半时断电,同时另外两台之间的网线又抖了一下。每一种故障单独测都没问题,凑到一起才出事。而这种巧合,在成百上千台机器、每天运行的生产环境里,迟早会碰上。
好处是:那些「千年一遇」的巧合故障,在测试环境里一天就能被撞上好几次,问题在用户碰到之前就被抓住。一句诚实的代价:这套折腾只能证明「找到了错」,永远不能证明「已经没有错」,而且搭建和维护这样一个日夜运转的模拟器,本身就要花不少人力和机器。
数据库的错是安静的——所以要一边主动制造事故,一边用「总账不能变」这类铁律去对账。
想看 TiDB 的架构里故障打在哪、四个层面怎么注入、「银行测试」凭什么能抓到一致性 bug,以及自动化平台 Schrodinger 和 Chaos Mesh 是怎么来的? → 切到精读版
你以为数据库靠测试用例保证正确,其实分布式数据库最致命的 bug 藏在多种故障恰好叠加的路径上,而且它们是「安静」的——不崩溃,只是悄悄算错。本章是 PingCAP 团队在 TiDB 上的实战记录:在应用、CPU / 内存、网络、文件系统四个层面系统性注入故障,用「不变量」(如转账总额守恒)判定正确性,再把整个过程交给自动化平台日夜运行——这套平台的经验后来沉淀为开源的 Chaos Mesh。
全书第 19 章,Part V「演进」,作者是 PingCAP 的 Liu Tang 与 Hao Weng。上接 Ch18 讲系统中的人,下启 Ch20 安全混沌工程;本章是全书少有的「一家数据库厂商的完整实践报告」,对应现实里所有自研或运维分布式存储(NewSQL、分布式 KV、消息队列)的团队。
TiDB 是一个兼容 MySQL 协议的分布式数据库,生产集群动辄几十到上百个节点,每天承接海量事务。对这种系统,「不丢数据、不出错账」是不可谈判的底线——Web 服务挂了五分钟可以道歉,数据库少记一笔转账可能要停业对账。
难点有三:其一,故障是常态——磁盘慢、进程崩、网络断、时钟跳,规模一大必然天天发生;其二,故障会叠加——单个故障的处理路径通常有单元测试覆盖,但「Leader 切换进行到一半时又遇到网络分区」这种组合,人很难事先写出用例;其三,数据库的错是安静的——它不报警,只是给了一个错误的结果,等发现时可能已经扩散。本章要回答:怎样把「故障下依然正确」从口头承诺变成每天被验证的事实。
要摇一个系统,先得知道它的骨架。TiDB 拆成三部分:
这张图决定了实验的问题清单:杀掉 TiKV 的一个副本,Raft 能否选出新 Leader 且不丢已提交的写?PD Leader 挂了,时间戳还单调吗?SQL 层和存储层之间网络断了,事务是回滚还是卡死?
本章把注入手段按「打在哪一层」分成四类,从最精细到最贴近真实:
① 应用层:failpoint。在代码里预埋「故障开关」,平时是空操作,测试时打开就让某一行返回错误、睡几秒或直接 panic。PingCAP 为 Go 写了 pingcap/failpoint、为 Rust 写了 fail-rs,思路来自 FreeBSD 的 failpoint。它的价值是精准:能让「Raft 快照发送到一半失败」这种极窄的时间窗被稳定复现。
② CPU 与内存。用压测工具抢占 CPU、吃掉内存,模拟「邻居进程把机器打满」。数据库在资源饥饿下会出现心跳超时、Leader 频繁切换等连锁反应,这些在空闲机器上永远测不到。
③ 网络。用 Linux 的流量控制(tc)注入延迟、丢包、乱序,用防火墙规则(iptables)切断特定节点之间的连通,制造网络分区。这是分布式数据库最该摇的一层,因为共识协议的所有精妙之处都在「消息丢了 / 晚到了怎么办」。
④ 文件系统。拦截 I/O 调用,让读写变慢或返回错误(如磁盘满、读出坏数据),检验存储引擎会不会把半截写入当成功、会不会在错误后带病继续运行。
另外两类贯穿始终:直接杀进程 / 重启节点,以及时钟偏移——把某个节点的时间拨快或拨慢。TiDB 的事务时间戳由 PD 统一发放,节点本地时钟不直接决定事务顺序,但依赖超时与租约的逻辑仍然可能被时钟跳变干扰,所以照样要摇。
以最典型的实验为例:3 副本的 Region,把 Leader 所在节点 A 和 B、C 隔开。稳态假设是——少数派那侧(A)不能再提交写入,多数派那侧(B、C)在选举超时后选出新 Leader 继续服务,已提交的数据一条不丢;网络恢复后旧 Leader 退位、追上日志。任何一条不成立都是 bug:比如 A 仍以为自己是 Leader 并返回了旧数据(违反线性一致读),或两边都接受了写入(脑裂)。
注入故障只是一半,另一半是检测。本章给出两类信号:
不变量的妙处在于:你不需要预知 bug 长什么样,只需要说清「什么永远不该发生」。这和 Jepsen 的思路一脉相承——记录操作历史,再检查它是否符合声称的一致性模型。
手工跑一次分区实验不难,难的是每天、对每个新版本、以各种组合跑上千次。PingCAP 为此建了内部平台 Schrodinger(取「薛定谔的猫」之意——不打开盒子,不知道系统是死是活),跑在 Kubernetes 上,流程大致是:
Schrodinger 里沉淀的故障注入能力后来被抽出来开源,成为 Chaos Mesh:用 Kubernetes 自定义资源(CRD)声明「对哪些 Pod、注入什么故障、持续多久」,任何跑在 K8s 上的系统都能用。这也是本章的落点——混沌工程对数据库不是一次性演练,而是一条和 CI 并行的持续验证流水线。
核心取舍:注入越精细,越好复现、越好定位,但离真实故障越远;越贴近真实,越能发现想不到的问题,但越难复现。
各层故障注入手段对比
| 手段 | 擅长发现 | 可复现性 | 代价 / 局限 |
|---|---|---|---|
| 代码 failpoint | 极窄时间窗的竞态、错误处理分支 | 高,可精确到某行 | 要改代码预埋;只能测想到的点 |
| 杀进程 / 重启 | 崩溃恢复、Leader 选举、日志回放 | 中 | 粗粒度,测不出「慢而不死」 |
| 网络(tc / iptables) | 分区、脑裂、超时与重试逻辑 | 中 | 需 root / 网络命名空间;组合爆炸 |
| 文件系统 I/O | 半截写入、磁盘满、静默损坏 | 中 | 需要拦截层,有性能开销 |
| 时钟偏移 | 租约、超时、依赖本地时间的逻辑 | 中低 | 影响面难预判,易误伤测试工具自身 |
| CPU / 内存压力 | 心跳超时、抖动引发的连锁切换 | 低 | 结果受机器差异影响大 |
验证「故障下仍正确」的几条路线
| 路线 | 代表 | 强项 | 代价 |
|---|---|---|---|
| 单元 / 集成测试 + failpoint | 开发者日常 | 快、可定位 | 只覆盖预想路径 |
| 黑盒历史检查 | Jepsen | 对照一致性模型,找出隔离级别漏洞 | 单次分析投入大,非持续 |
| 持续混沌平台 | Schrodinger / Chaos Mesh | 真实二进制、真实环境、日夜运行 | 集群与人力成本;难复现 |
| 确定性模拟 | FoundationDB | 任何失败都可按种子完全重放 | 须从第一天按可模拟方式设计系统 |
判据:一个分布式数据库至少要有「不变量检查 + 持续注入」这一组合;只有注入、没有对账,等于只测了可用性没测正确性。
面试或架构评审里被问「你怎么证明这个存储在故障下不丢数据」,空谈「我们用了 Raft」不够——共识算法是对的,实现不一定对。可信的回答是:列出要验证的不变量(已提交写不丢、总额守恒、单调读),说明在哪些层注入哪些故障,以及这些实验是否在每次发版前自动跑。本章给出了一个可抄的完整模板。
大厂实证
① 分布式数据库最危险的 bug 藏在多种故障叠加的路径上,而且是不崩溃的「安静的错」。
② 先看清架构:TiDB = 无状态 SQL 层 + Raft 复制的 TiKV + 负责调度与时间戳的 PD;每个节点和连线都是注入点。
③ 四层注入:应用(failpoint)、CPU / 内存、网络(tc / iptables)、文件系统;外加杀进程与时钟偏移。
④ 网络分区实验的稳态:多数派选出新 Leader、少数派停写、已提交数据不丢、无脑裂。
⑤ 检测靠两条腿:指标看可用性,不变量(银行测试总额守恒)看正确性。
⑥ 不变量不需要预知 bug 长什么样,只需定义「什么永远不该发生」。
⑦ 自动化是关键:Schrodinger 每天对新代码构建、部署、注入、校验,经验沉淀为开源 Chaos Mesh。
⑧ 权衡:越精细越好复现、越真实越能发现意外;持续混沌、Jepsen、确定性模拟互为补充。
⑨ 易错:「用了 Raft 就一致」「没挂就通过」都是误解;找到 bug 后能否复现同样决定成败。