专业书籍精读 · Database Internals · 第 9 章
Database Internals · Ch 9 · Alex Petrov · 2019
你给同事发了条消息,十分钟没回。他在开会?手机没电?还是出事了?你分不清。一堆机器合作干活时,每台机器每秒都在替彼此回答同一个问题:那台机器是死了,还是只是慢?《Database Internals》第 9 章讲的就是这件事——怎么判断一个节点挂了,以及为什么这件事根本做不到「判得准」。
人走了不会自己发讣告。机器崩溃时也不会留一句「我要没了」的告别——它就是不说话了。只能反着推:这么久没吭声,八成是没了。可「这么久」到底多久?没人知道。这就是整章的死结:沉默既可能是死亡,也可能只是堵车,从外面看一模一样。
最朴素的办法是定个闹钟:3 秒不回就当他死了。等太短——对方只是网络抖了一下、或者正清理内存卡了两秒,你就把一个活人宣判死亡。这叫误杀,代价很贵:系统以为头儿没了、重新选一个,结果两个头同时下令;或者把几百 GB 数据往别处搬,把本来只是抖了一下的网络彻底压垮——检测器亲手制造了它要检测的那场故障。等太长——真死的机器,请求还在往它身上发,用户干等三十秒。短了误杀,长了迟钝,中间不存在「刚刚好」的数字。
这一章最好用的想法是:别再逼系统回答是非题。四个办法层层递进——
真正的取舍不是「怎么判得更准」,而是「判错了,哪个方向更亏」:银行转账宁可慢也不能误杀——冒出两个头会把账写坏;聊天软件的小绿点判错无所谓,要的是快。
诚实的代价是:这些办法一个都不保证正确——只要消息可以任意迟到,「慢」和「死」就分不开,所有检测器都在赌概率。
你永远无法确定另一台机器是死了还是只是慢。所以别追求「判得对」,而要把「死 / 活」的是非题换成「有多可疑」的分数,让不同的动作按自己的代价各自定线——同时承认:你迟早会判错,系统必须扛得住那一次判错。
想看具体算法、阈值怎么定和示意图? → 切到精读版
你以为故障检测是「判断一台机器死没死」,其实它真正的目标是:在「永远判不准」这个前提下,把判错的代价管住。这一章先把死结摆明——异步网络里「崩溃」和「很慢」从外部观察不可区分——再给出工程上最有价值的那一步转向:别让检测器输出布尔值,让它输出一个可调的怀疑度,把定线的权力交给用它的人。
Part II「分布式系统」的第二章。上承第 8 章——那一章刚把链路有多可靠、时钟有多可信、节点会怎么坏这张假设表填好;下启第 10 章领导者选举:选主的第一步永远是「先认定老的那个死了」。再往后的复制、反熵、共识,都把故障检测当成一个可插拔的底层零件。对应现实里哪些东西?Cassandra 判定节点存活、ZooKeeper / etcd 的会话过期、Kubernetes 把节点标成 NotReady、Kafka 发现 broker 掉线——全是这一章。
先说不解决会怎样,两个方向都很贵。
漏判的代价:一个 3 节点的强一致集群,主挂了却没人发现,整个集群就写不进去——不是慢,是彻底不可写,直到有人手动介入。故障检测决定「多久之后系统开始自救」,这个数字直接就是 RTO(恢复时间目标)的下限。
误杀的代价更隐蔽、也更贵:两百节点的集群把 5 个健康节点判死,会立刻触发副本重建和数据再平衡——跨机架搬几百 GB,把本来只抖了 200 毫秒的网络彻底打死,于是更多节点被判死,雪崩。这就是本章最该先建立的认知:故障检测器不是传感器,是执行器。它一开口,系统就切主、挪流量、搬数据,全是很贵或不可撤销的动作。所以误判不是「读数不准」,是真实的破坏行为。
而「做准」本身就不可能。同机房一次往返通常 0.2 毫秒上下,抖到 5–20 毫秒很常见;一次 JVM Full GC 能把进程冻住 0.5 到 5 秒;跨区链路抖几百毫秒是家常便饭。超时设 1 秒,一次 GC 就足以误杀健康节点;设 30 秒,真死时用户干等 30 秒。这不是参数没调好,是异步模型下的原理性障碍:不设延迟上界,就永远分不开「慢」和「死」。这一章要回答的是既然判不准,那该怎么办。
书里沿用 Chandra 与 Toueg 的经典框架,用两条性质给检测器打分:
异步网络里两条不可兼得,拉扯方向也一目了然:超时调长,准确性升、完备性变慢;调短则反之。书里的处理很务实——完备性很便宜(只要肯一直等,早晚会怀疑到它头上),难的是准确性。所以真实检测器一律走同一条路:保证完备性,尽力压低误判,并允许撤回怀疑——先标成「可疑(suspect)」,对方又说话了就取消;只有在不可逆的动作前(比如踢出集群)才升级成「定罪(convict)」。这个两级结构是本章所有算法共同的骨架。
拿信息只有两种姿势:ping 是监控方主动问、等对方回;心跳是被监控方定期报平安。心跳省掉一半消息但要求对方配合,ping 顺带能量出往返时延。两者软肋完全一样:唯一判据是超时,而超时只说明「这段时间内我没收到」。
第一个跳出超时的思路(Mostéfaoui、Mourgaya、Raynal 提出):不看时钟,只看相对顺序。每个进程周期性向所有人广播带轮次号的心跳;收方每轮只等最先到的 n−f 个(n 是节点数,f 是允许崩溃的上限),凑够就进下一轮,把持续垫底的标为可疑。
直觉:不问「他慢了几毫秒」,只问「在所有人里他是不是总垫底」。好处很漂亮——机房整体变慢时大家一起变慢、排名不变,不会集体误杀,而这恰是固定超时最容易翻车的场景。代价是两条前提:最多 f 个进程崩溃、链路公平损失(无限重发必有送达);而且每轮全员广播,消息量 O(n²)——节点翻倍消息翻四倍,适合几十个节点,不适合上千。
一个便宜又有效的技巧:A 收不到 B 的回应时先别定罪,而是请 k 个邻居代自己去探 B;只要任一邻居听到 B 的回音,A 就撤销怀疑。它把两件常被混为一谈的事拆开了:「A 到 B 这段路坏了」和「B 真的坏了」。多数「某台机器莫名掉线又回来」的事故,根子都在前者。代价是多一跳延迟和 k 倍探测消息。
全章最值得带走的设计(Hayashibara 等人 2004 年提出)。三步:维护最近若干次(实现里常取几百到上千次)心跳到达间隔的滑动窗口 → 用样本拟合一个分布 → 当下已沉默 t 毫秒时,算出「正常心跳比 t 还晚才到」的概率,取负对数输出:φ = -log₁₀ P(比 t 还晚)。
白话讲,φ 就是「这么久没动静有多离谱」的分贝数:φ=1 意味着此刻定罪大约有 10% 的可能是冤枉的,φ=2 是 1%,φ=3 是 0.1%——每加 1,误判概率降一个数量级。它不需要你读懂这个式子,只需要记住这个换算。
最关键的设计意图常被忽略:它把「定阈值」从检测器手里交还给了使用方。同一份怀疑度,路由层可以在 φ=3 就把新请求挪走(可撤销、代价低),选主逻辑等到 φ=8 才定罪(不可逆、代价高)——一个检测器同时服务代价迥异的几方,布尔输出做不到。它还自适应:机房整体变慢时窗口里的分布跟着右移,阈值一字不改,判死时间自动放宽。代价:需要热身期攒样本;对突变(网络瞬间从 1 毫秒劣化到 1 秒)反应比固定超时还慢;常用的正态分布假设在长尾很重的真实网络里并不总成立。
前面的方案都隐含「谁监控谁」的拓扑,gossip 式检测(van Renesse 等 1998 年提出)把这层结构也去掉:每个节点维护一张表,记着它所知道的每个节点的心跳计数。每轮把自己那格加一,随机挑几个同伴交换整张表,合并时同一行取较大的计数。某行计数在 T_fail 内没涨过就本地判它可疑,再等 T_cleanup 才从表里删掉——这个缓冲是为了防止一条迟到的旧消息把它「复活」。
好处三条都很硬:没有单点;每节点每轮消息量固定、与集群规模无关(传遍全场的轮数只随节点数的对数增长,几百节点通常几轮之内);多条转述路径天然抗丢包。坏处同样实在:你手上的信息是二手的——判 B 可疑的依据可能是 C 三秒前的转述,所以 T_fail 必须给传播留余量,判死一定更慢。gossip 是用「慢」换了「省」和「不会散架」。
本章最后换了个问法:前面所有方案都在答「B 死了吗」,FUSE(Dunagan 等,2004)问的是我们这次协作还能不能继续。它把一组相关进程绑成一个失败组,只承诺一件事——只要组里任何一处出问题(某个进程崩了,或网络把这组切开),所有还活着的成员都会在有限时间内收到通知;任何成员也能主动引爆它。
价值在于:上层不必再各自维护一份互相矛盾的「谁还活着」,而是拿到一个全组一致的信号——「不确定」被换成「统一放弃、统一重来」。代价同样清楚:粒度从单节点粗化到整组,一个成员出事全组陪葬,是共命运(shared fate)的设计,误判爆炸半径更大,只适合本来就一荣俱荣的协作。
这一章的灵魂不是「哪个算法最好」,而是你愿意往哪个方向犯错。先横着比机制:
表 1 · 五种故障检测思路的代价对照
| 机制 | 判死延迟 | 误杀率 | 消息开销 | 适合谁 |
|---|---|---|---|---|
| 固定超时 ping / 心跳 | 由超时值直接决定,秒级 | 高,且随网络抖动漂移 | 每对节点每周期 1–2 条 | 小集群、网络稳定、要实现最简单 |
| 无超时(相对排序) | 随网络快慢自适应 | 低,整体变慢不误杀 | O(n²),几十节点为限 | 中小集群,怕整机房抖动雪崩 |
| 外包心跳 | 多一跳,略增 | 低,能分清「路断」与「人死」 | 每次怀疑多 k 份探测 | 跨机架 / 跨可用区、链路不对称 |
| φ-accrual | 由使用方阈值决定,可分级 | 可定价:φ 每 +1 降一量级 | 同心跳,多一点内存与计算 | 上下游对误判代价差异很大时 |
| gossip 心跳表 | 慢,须留传播余量 | 中,依据是二手信息 | 每节点恒定,与规模无关 | 数百节点以上、不要中心裁判 |
再竖着问:同样是判错,错向哪边更贵?这决定阈值往哪偏。
表 2 · 判错的方向,哪种更亏
| 场景 | 误杀活节点的代价 | 漏判死节点的代价 | 该偏向 |
|---|---|---|---|
| 选主 / 切主 | 极高:脑裂,两个主同时收写,数据永久对不上 | 高:集群不可写,但可恢复 | 宁慢勿错,配 fencing |
| 副本重建 / 再平衡 | 极高:白搬几百 GB,把抖动变雪崩 | 中:副本数暂时不足 | 阈值最保守,加延迟触发 |
| 读写流量路由 | 低:绕开一台好机,损失点容量 | 高:请求打进黑洞,用户直接超时 | 宁可误绕,快速可撤销 |
| 在线状态 / 成员展示 | 很低:小绿点闪一下 | 很低 | 只求快 |
一条能直接用的经验:把动作按「可不可撤销」分层,越不可撤销的用越高的阈值。挪流量可撤销,几秒就该动;切主和搬数据不可撤销,值得多等十几秒换准确性。φ-accrual 在工业界赢下来,正是因为它是唯一天然支持这种分层的输出形式。
任何「这套系统怎么做高可用」的架构讨论或面试题,第一刀都落在这里——切主之前总得先有人认定老主死了。把「超时设多少」答成「输出连续的怀疑度、让不同动作按代价分级取阈值、并在执行端加 fencing」,档次立刻不一样。
点名几个真家伙:Cassandra 用 gossip 传播心跳、用 φ-accrual 的变体判定;Akka Cluster 同样用 φ-accrual;Consul / Serf 走 SWIM 系的 gossip 加「疑似」中间态;Kubernetes 让 kubelet 每 10 秒上报状态,控制面在数十秒宽限期后把节点标成 NotReady 再驱逐 Pod。etcd / ZooKeeper 则走另一条路——租约:不是别人判你死,而是租约到期自动失效。它的安全性容易证明(只要时钟漂移有界),代价是判死时间的下限被租约长度锁死。
p99)补位。① 核心命题:异步网络里「崩溃」与「很慢」不可区分,故障检测天然不可靠;目标不是判得准,是判错了代价可控。
② 两把尺子:完备性(该抓的终会抓到,属活性)与准确性(不冤枉好人,属安全性)不可兼得;完备性便宜,准确性才难。
③ 所有算法共用「可疑 → 定罪」两级结构:怀疑可撤回,只有不可逆动作才升级为定罪。
④ 心跳 / ping 的唯一判据是超时,于是两难:调短误杀(一次 GC 停顿就够),调长迟钝。
⑤ 四条出路:相对排序(不看时钟看谁垫底,抗整体变慢,代价 O(n²))、外包心跳(换条路再问,分清路断与人死)、φ-accrual(输出连续怀疑度,φ 每 +1 误判降一个数量级)、gossip(无中心、开销与规模无关,但判死更慢)。
⑥ FUSE 把问题反转:不问「谁死了」,而是保证组内所有人都收到失败通知——用「统一重来」换掉「不确定」,代价是共命运。
⑦ 选型的真问题是错向哪边更贵:切主与数据重建宁慢勿错,流量路由宁可误绕;按动作可不可撤销分层设阈值。
⑧ 真实系统:Cassandra / Akka 用 φ-accrual,Consul / Serf 用 SWIM,etcd / Kubernetes 用租约与宽限期;最大的坑是灰故障——检测器全绿、应用已挂,必须靠业务指标补位、靠 fencing 兜底。