专业书籍精读 · DDIA · 第 8 章
Designing Data-Intensive Applications · Ch 8 · Martin Kleppmann · 2017
前面几章都在讲「把数据放到很多台机器上,能得到什么好处」。这一章反过来,专门讲坏消息:一旦你的系统由许多台机器组成、靠网络互相说话,会冒出一大堆单机时代根本不存在的麻烦。DDIA(《数据密集型应用设计》)第 8 章就是把这些坑一个个摊开来吓你——不是让你别做,而是让你别再幻想「一堆机器会像一台大机器那样乖」。
想象你和几个同事分散在不同城市,只能靠寄信联系。你寄出一封信问「这事你办了吗」,然后……杳无音信。问题来了:是信没寄到?是对方病倒了?是他回信了但回信丢了?还是他其实在慢慢办、只是还没回?你根本分不清。一台电脑对着网络另一头的电脑喊话,就是这种感觉——沉默有无数种解释,而你必须在分不清的情况下继续把事情办对。
一台电脑有个好脾气:它要么好好干活,要么彻底崩掉(死机、蓝屏)——崩了你一眼就看见,重启就好。它很少「半死不活」。可一旦是一群电脑,就会出现「部分失效」:有的机器好、有的坏,有的网络通、有的不通,而且你常常没法知道到底哪台出了事。这种「说不清谁坏了」的不确定,才是分布式最折磨人的地方。
这一章教你对三样东西时刻保持怀疑:① 网络——信会丢、会晚到、会乱序,你唯一的工具是「等一会儿还没回就当它没了」,可「等多久」纯靠猜。② 时钟——每台机器的表都走得略有出入,就像一屋子人的手表对不齐,所以你不能靠「谁的表显示得更晚」来判断谁后动手,否则会把数据判错、判丢。③ 突然的停顿——一台机器可能毫无征兆地卡住几秒(就像人说着话突然打了个盹),醒来还以为没事,继续干它早该停手的活。
应对的心法就一句:凡是能出错的都当它会出错,然后设计成「就算出错了,结果照样是对的」。一个经典小招是「编号取号」:给每次操作发一张号码牌,号越来越大;万一你打盹醒来还攥着旧号去办事,柜台一看号过期了,直接拒绝——脏活就干不成了。
分布式系统之所以要用「多数投票」「超时重试」「取号拒旧」这一堆看着啰嗦的机制,正是因为网络、时钟、别的机器,一个都不能全信。诚实的代价是:这套处处设防的机制,让分布式系统比单机慢得多、也复杂得多——这就是「不能信任何东西」要付的学费。
许多机器凑一起,最大的麻烦不是「某台坏了」,而是「某台坏没坏你都说不清」。网络会骗你、时钟会骗你、机器会突然打盹——把它们全当不可信,再设计成出了错也不出乱子,才是分布式的正道。
想进到具体机制、记号和示意图? → 切到精读版
这一章是全书的「冷水澡」:它系统地清点分布式系统里一切会出错的东西——部分失效(partial failure)、不可靠的网络、不可靠的时钟、无预警的进程停顿——并教你一种系统性的悲观:假设凡能出错的都会出错,再把系统设计成即便如此也依然正确。它不给解法,只把「坑」讲透,为下一章的共识(consensus)铺路:先认清分布式为什么这么难,才谈得上我们还能保证什么。
本章属于 Part II「分布式数据」,紧接复制(第 5 章)、分区(第 6 章)、事务(第 7 章)之后。前几章默默假设网络、时钟、机器大体靠谱;这一章把这些假设一条条戳破。它是 Part II 的「问题篇」——只摆麻烦、不给答案;紧随其后的第 9 章「一致性与共识」才是「解法篇」:在承认这么多不确定之后,我们究竟还能可靠地保证什么。读懂第 8 章的悲观,才配得上第 9 章的乐观。
单机有个被我们视作理所当然的好性质:确定性——同样的输入给同样的结果;出了硬件故障,它通常是整机崩溃(要么全对、要么全停),你一眼看得见、重启即可,很少「一半好一半坏」。分布式把这份安稳彻底打碎:部分失效成了常态——某个节点宕了、某段网络断了、某台机器慢成蜗牛,而系统整体还在跑。更要命的是不确定性:你发出一个请求却收不到回应,压根分不清是请求没送到、对方宕机了、回应在半路丢了、还是对方只是慢。这一章要解决的不是「修好某一个故障」,而是逼你换一套世界观:不要再把一群机器当成一台可靠的大电脑;把所有部件都当作随时可能以最坏方式失灵,再想办法在这之上盖出可靠的系统。
把单机和分布式摆一起就看清了:单机像一个可靠的处理器,同样操作永远同样结果,坏就坏得干脆。分布式是无共享的一群机器,靠网络说话——于是引入了部分失效:系统有的部分坏了、有的还好,且你往往无法确知边界在哪。这种「说不清谁坏了」的不确定性(nondeterminism),才是分布式工程的核心难点。承认它,是本章一切设计的起点:与其奢望造出永不失效的部件,不如用会失效的部件,造出仍能正确工作的整体。
无共享系统只能靠异步分组网络(asynchronous packet network)通信——「异步」意味着网络不对送达时间作任何承诺:包可能丢失、延迟、乱序、重复。最扎心的场景是:你发出请求后收不到回应,可能是 ①请求丢了 ②对方宕机 ③回应丢了 ④对方只是慢——四种情况从发送方看完全一样,你无从区分(见图 1)。
唯一能用的工具是超时:等一段时间还没回,就假定对方挂了。但超时是把双刃剑——设太短,会把只是暂时卡顿的健康节点误判为死、触发不必要的故障切换(failover),甚至一个动作被执行两次;设太长,真宕机了也要干等很久才反应。而理论上异步网络的延迟没有上限,所以不存在完美的故障检测器。延迟的大头常来自排队(queueing):网络交换机、操作系统、虚拟机、乃至接收方进程忙不过来时,包都在排队。DDIA 的态度很干脆:网络的可靠性得靠实测,而非假设——连数据中心内网都不例外。
每台机器都有自己的石英钟,会漂移。机器靠 NTP 定期校准,但校准受网络延迟影响、精度有限。DDIA 给的量级很直观:Google 对服务器按 200 ppm(百万分之 200)的漂移来估算——相当于每 30 秒校一次也会漂 ~6 ms,若 NTP 一整天失灵则可漂到 ~17 秒。这点误差看着小,却足以让「谁先谁后」判错。
关键要分清两种时钟:① 墙上时钟(time-of-day clock)返回「现在几点」,会被 NTP 拨快拨慢、甚至往回跳(闰秒、人工校时)——绝不能用来测量时间间隔或排事件先后。② 单调时钟(monotonic clock)只保证「一直往前走」,适合测「过了多久」,但它的绝对值没有意义、跨机器不可比。最危险的误用是拿墙上时钟给写入打时间戳、再用「最后写入者胜(last-write-wins, LWW)」解冲突:只要两台机器的钟没对齐,一个物理上更晚发生的写,可能因为时间戳更小而被判为「旧」、被默默丢弃(见图 2)——数据就这么无声无息地没了。
更诚实的做法是承认时钟带置信区间——「现在大约是 X,误差 ±e」。Google 的 Spanner 正是把这点做到极致:它的 TrueTime API 直接返回一个时间区间而非一个点,并在提交事务时主动等待掉这段不确定(commit wait),从而给全球事务排出可信的先后。
就算网络和时钟都乖,一个节点还可能在任意时刻、无预警地停顿:最常见是垃圾回收(GC)的「stop-the-world」——整个进程冻结几十毫秒到几秒,极端情况(大堆)可达数分钟;也可能是虚拟机被挂起迁移、笔记本合盖、操作系统换页抖动。要命的是停顿的进程对此毫不知情:它冻结前以为自己好好的,解冻后还接着干,浑然不觉世界已经变了。
经典翻车场景:某节点从锁服务拿到租约(lease)成为主,正要写共享存储时GC 停顿了 15 秒;这期间租约早已过期,系统另选了新主。等它解冻,仍以为自己是主、带着过期的授权去写——脑裂(split-brain),数据被两个「主」同时改坏。解法是栅栏令牌(fencing token):锁服务每次授权都发一个单调递增的号,存储端只接受号比上次大的写、拒绝更小的。打盹醒来的旧主攥着旧号,一写就被挡下(见图 3)——系统不再信任「我以为我是主」,只信任令牌上的号。
本章的「灵魂」是:分布式没有免费的确定性,处处是权衡。先看两个最要命的旋钮——超时长短与要不要防拜占庭。
表 1 · 超时设长设短:都是错,只能挑「错得可接受」的那种
| 超时设太短 | 超时设太长 | |
|---|---|---|
| 后果 | 把只是暂时慢 / 卡顿的健康节点误判为死 | 真宕机了也要干等很久才发现、才切换 |
| 连带风险 | 不必要的故障切换;一个动作可能被执行两次;切换本身给系统加负载,可能级联雪崩 | 故障期间用户持续受影响,RTO(恢复时间)变长 |
| 根因 | 异步网络延迟无上限 → 不存在完美的故障检测器,只能在两种错误间取舍 | |
表 2 · 两种故障假设:防不防「说谎的节点」,成本天差地别
| 非拜占庭(崩溃 / 遗漏) | 拜占庭(可能说谎 / 任意行为) | |
|---|---|---|
| 假设 | 节点可能宕机、变慢、丢消息,但只要出声就说真话 | 节点可能发错误 / 矛盾 / 恶意的消息(故障或被攻破) |
| 代价 | 协议相对简单、便宜(Paxos / Raft 走这条) | 需 BFT 协议,通常要 > 2/3 节点诚实,贵且慢 |
| 适用 | 受控的数据中心——绝大多数系统的默认选择 | 航空航天、多方互不信任、区块链等对抗环境 |
再往上一层,是选择用哪套系统模型(system model)来推理正确性——这决定了你的算法「假设世界有多坏」。
表 3 · 系统模型:给「世界有多不可靠」定个假设
| 时序假设 | 含义 | 现实性 |
|---|---|---|
| 同步模型 | 网络延迟、进程停顿都有已知上限 | 过于乐观,现实几乎不成立 |
| 部分同步 | 大多数时候有界,偶尔会超出 | 最贴近真实系统,多数算法据此设计 |
| 异步模型 | 对时序不作任何假设,连时钟都不能用 | 最保守;能在此证明正确的算法最稳健 |
还要区分两类正确性性质:安全性(safety)——「坏事永不发生」(如令牌不会认错、不给出错误结果),必须时刻成立;活性(liveness)——「好事终会发生」(如请求最终会有回应),允许附带「最终」二字。工程上的通行取舍是:宁可牺牲活性(多等一会、暂时不可用),也要死守安全性(绝不返回错误结果)——GitHub 那次事故正是这个选择的现实版(见下)。
这一章之所以是分布式工程师的「祛魅必修课」,是因为几乎每一次大规模线上事故,都能在它列的清单里对号入座:时钟出错、网络分区、进程停顿、故障检测误判。它给了一套排查与设计的共同语言:谈「主挂了没」,落到「这是真宕机还是网络分区 / GC 停顿」;谈「谁的写为准」,落到「别信墙上时钟,上栅栏令牌 / 版本号」;谈「切不切主」,落到「超时设多少、会不会误切级联」。真实系统里,ZooKeeper / etcd 用共识 + 栅栏令牌守护关键状态,Spanner 用 TrueTime 把时钟不确定量化并等待掉,而 Jepsen 则专门在网络分区下捶打各家数据库、揪出它们名不副实的一致性承诺。
0.2% 的 DNS 查询受影响——活生生印证了本章「时间会往回跳、别假设时钟单调」;修复只改了一个字符(把「等于零」改成「小于等于零」)。Cloudflare Blog「How and why the leap second affected Cloudflare DNS」, 2017 ↗43 秒的网络中断,触发 Orchestrator 自动故障切换、主库在两地分叉,最终演成24 小时+的服务降级——印证了本章「网络分区 + 自动切换」的连锁危险;官方明确「宁可牺牲可用性也要保数据完整性」,正是安全性优先于活性的现实抉择。The GitHub Blog「October 21 post-incident analysis」, 2018 ↗<10 ms,TrueTime 直接返回时间区间并在提交时「等掉」这段不确定,从而支持全球外部一致的事务——把本章「给时钟带上置信区间」变成了工业产品。Corbett et al.「Spanner」, OSDI 2012 ↗① 一句话:分布式的本质困难是部分失效 + 不确定性——不是「某台坏了」,而是「某台坏没坏你都说不清」。
② 别把一群机器当一台可靠大电脑;用会失效的部件造出仍正确的整体,靠的是系统性悲观。
③ 网络不可信:异步网络延迟无上限,「收不到回应」有四种无法区分的解释;超时是唯一工具,但本质是猜、且没有完美故障检测器。
④ 时钟不可信:分清墙上时钟(会跳、别用来排序)与单调时钟(只测间隔);拿时间戳做 LWW 会静默丢数据;正解是带置信区间(Spanner TrueTime)。
⑤ 进程会无预警停顿(GC / 迁移 / 换页):持锁者可能正在打盹,醒来还以为自己是主 → 脑裂。
⑥ 栅栏令牌(单调递增号,存储只认更大的)挡住打盹醒来的旧主——不信「我以为我是主」,只信令牌。
⑦ 关键权衡:超时设长设短都错、只能挑错得可接受的;数据中心默认不防拜占庭;用部分同步模型推理;安全性优先于活性。
⑧ 意义:给了一套排障与设计的共同语言,并为第 9 章「共识」立靶——认清麻烦,才谈得上还能保证什么。