专业书籍精读 · Database Internals · 第 12 章
Database Internals · Ch 12 · Alex Petrov · 2019
你在群里发了条消息:多数人秒收,有人过了十分钟才刷出来,还有个人手机关机一整天——开机后消息照样补上了。《Database Internals》(《数据库系统内幕》)第 12 章讲的就是「照样补上」这件事是怎么做到的。上一章说「几份数据终究会一样」,这一章问:究竟靠什么让它一样?
你删掉的一条数据,过几天自己回来了。听着像灵异事件,原理却很朴素:你删的时候有台机器正好离线,它手上那份还是「有」。等它回来,它发现同伴手上没有这条,就热心地把「自己有的」补给了同伴——删除被撤销了。所以真正要设计的不是「怎么把数据送到」,而是「没送到的那部分,事后怎么把账补对」。
最直觉的办法是「发的时候确保每个人都收到」。可那意味着每次写入都要等最慢的那台机器,有一台关机就谁也别想写——这正是上一章算过的账:太贵。于是真实系统选择先写完再说,欠的账另立机制还。麻烦在于,这笔账不会自己消失:漏掉一条写和漏掉一百万条写,对系统来说是同一种状态——不一样,而且没人知道差在哪。
这一章的骨架,就是三条互补的还账通道:
还有一类信息(谁活着、谁加入了集群)要让所有机器都知道。挨个通知太慢,指定一个「广播员」又会因它一挂全断。这一章用的办法是传闲话:每台机器每秒随便挑一个同伴,把自己知道的都说一遍。没有中心、断几条线也无所谓,而且扩散极快——知道的人数每轮翻几倍,机器数翻倍也只多一两轮。代价是:谁都说不准到底几秒钟传完,它只给「很快」,不给承诺。
「终究会一样」不是等来的,是花钱买来的。系统要专门养三条补账通道——写时代收、读时顺手校、闲时对账,再加一种像传闲话一样的扩散方式。谁都不跑,那几份数据就会永远不一样,而且删掉的东西还会自己爬回来。
想进到具体机制、记号和示意图? → 切到精读版
上一章的最终一致性承诺了「停写后终将收敛」,本章揭穿它:那个「终将」不是时间的恩赐,是一笔持续的运营支出。复制协议只负责尝试送达,送不到的部分必须由反熵(anti-entropy)另行补齐——书里把补齐的时机拆成三条互补通道(读时修复、写时暂存、后台比对),再用 gossip(流行病式传播)解决「怎么把信息扩散到全集群」。要建立的认知是:收敛是你雇人去做的事,不是协议的副产品;没人跑 repair 的最终一致集群,「最终」等于「永不」。
f。gc_grace:删除写成一条「此处已删」的标记(墓碑),保留一段宽限期后才真正清掉。本章属 Part II「分布式系统」,接在 Ch11(复制与一致性)之后:上一章把一致性排成一条谱系,并把最便宜那档定义为「停写后终将收敛」;本章负责把那个「终将」兑现,也顺带给出集群元信息(成员、故障、schema 版本)的扩散办法。下接 Ch13–14(分布式事务与共识)——那是另一条路:不事后补账,而是事前协调。对应现实里 Cassandra 的 hints / read repair / nodetool repair / gossip 四件套、DynamoDB 与 Riak 的后台 AAE、Consul 与 Serf 的成员传播。
先看一个不算故障的场景:三副本、QUORUM 写(w=2)。协调者拿到两个确认就向客户端返回成功——此刻第三个副本上根本没有这条数据,而系统认为一切正常。发散不是异常路径,它就在正常路径上。
再放大:一台节点滚动升级离线 20 分钟,期间集群承接 3000 QPS 写入、其中 1/3 归它负责——回来时它缺了约 360 万条写。而系统并不知道自己差在哪:漏一条和漏三百万条,对外都只表现为「有时读到旧值」。
不解决会怎样?三件事,一件比一件严重:① 陈旧读永久化——那条旧值不会自己消失,会一直被读到;② 删除复活——墓碑过了宽限期被清理,而落后副本手上还留着老数据,下次比对时它会把「自己有、同伴没有」当成对方缺失,热心地把删掉的数据传回来;③ 「最终一致」沦为口号——没有上界的「终将」,工程上等于没有保证。
本章按「什么时候补账」组织而不是按算法。三条通道覆盖面与成本各异,互为补充而非互相替代——这是整章最该先装进脑子的结构。
读一次本来就要联系多个副本,那就顺便比一比。工程上的关键细节是摘要读:只让一个副本返回完整数据,其余只返回哈希摘要——摘要一致时网络上只走一份数据,对不上才补取全量。「顺手校对」的常态成本因此接近于零。
另一个必须分清的开关是阻塞式还是非阻塞式:阻塞式先把修复写下去、等够一致性级别再返回,读延迟里多一次写往返,换来「单调 quorum 读」(不会出现上一章那种两次读先新后旧);非阻塞式只在协调者内部合并出正确答案返回,不去修那个落后的副本。
它的根本局限只有一句:只修被读到的数据。冷数据、归档分区、写完再没人碰的 key,永远轮不到。所以读修复是压低常见陈旧度的好工具,但绝不是收敛的保证。
目标副本联系不上时,协调者不必让整次写失败,而是把这条变更连同「本该给谁」存成一条提示(hint),等对方复活再重放。它和 sloppy quorum(松散 quorum)是一对:写落在「前 n 个健康节点」上,而不必是本该负责的那 n 个——短时故障于是不再转化为写失败。
代价在三处:① hint 有保质期(Cassandra 默认 3 小时),超时即丢弃,那个副本就一直落后到下次 repair;② hint 本身没有冗余——存它的协调者也挂了,这笔账直接蒸发;③ 恢复瞬间的重放流量容易把刚起来的节点再压垮。所以它的定位是「尽力而为」,不是保证。
后台兜底要回答的是:两个各存上亿条记录的副本,怎么在不传全量的前提下找出分歧?答案是哈希树——叶子是每段数据的哈希,父节点是子哈希的哈希,一路收到根。比对时先换根哈希:相同就整棵树跳过;不同则取两个子节点哈希,只沿不一致的那侧下降,直到定位到叶子。通信量于是从「数据量」降到「树高」量级,记作 O(log n)——数据翻一倍,只多问一层。
三个必须一起记住的代价:① 粒度——叶子不是单条记录,而是一段 key 范围,一个叶子对不上就得传整段(过度流式),修 1 行可能搬几百兆;② 重算——树要覆盖全部数据才有意义,所以构建它要读全量数据,磁盘与网络 IO 都不便宜;③ 拓扑变化即失效——加减节点会让 key 范围重新划分,对应的树得重建。
书里也给了针对「重算」的改进:位图版本向量(bitmap version vectors)——不比数据,只记「我见过谁的第几号写」,用一个位图紧凑表示已收到的写序号集合。副本交换这份摘要就能直接算出对方缺哪几条,只补这几条,完全不扫全量。代价是它只对近期缺失有效;长期失联或静默损坏仍要 Merkle 兜底。
另一半问题是传播:成员变更、故障怀疑、schema 版本这类元信息要让全集群都知道。直接广播要每个节点联系所有人(消息量随节点数平方增长),固定广播树又一断就整条支路失联。gossip 的答案是概率性扩散:每个节点每隔一个周期(Cassandra 是 1 秒)随机挑 f 个同伴交换状态。
书里借流行病学的三态刻画它:易感(还没收到)、感染(收到且正在转发)、移除(收到但不再转发)。从「感染」转为「移除」的时机,就是这个协议真正的调参点:转得太早可能传不全网,转得太晚则满网重复消息。三种交换方式各有脾气:push 早期快、后期浪费;pull 收尾快;push-pull 收敛最快,代价是每轮消息更大。
收敛轮数随节点数对数增长——记作 O(log N),意思是集群从 100 台涨到 1000 台,传遍全网只多几轮而不是多十倍。这就是为什么成员管理几乎都用 gossip:它对规模不敏感,对故障也不敏感。
纯随机 gossip 的痛处是消息冗余:同一条消息被同一个节点收到七八次很常见,带宽白烧。而一棵固定的生成树(每条消息只走 N-1 条边)效率最优,却一个内部节点挂掉,整条支路失联。本章给出的调和方案是混合 gossip(hybrid gossip),代表是 Plumtree:
最后一块拼图是部分视图(partial views):节点不必认识全集群,只维护一小撮活跃邻居(log N 量级)加一份备用名单,挂了就补位(HyParView 一系)。单节点开销于是不随集群规模线性增长——上千节点才可能只靠 gossip 维持成员关系。
表 1 · 三条反熵通道:各补什么、漏什么
| 通道 | 触发时机 | 覆盖范围 | 常态成本 | 诚实代价 / 漏洞 |
|---|---|---|---|---|
| 读修复 | 每次读,搭便车 | 只有被读到的数据 | 很低(摘要读时几乎为零) | 冷数据永远修不到;阻塞式还会给读加一次写往返 |
| 提示移交 | 写时发现副本离线 | 故障窗口内的写,且限时 | 低,但故障期写放大 | hint 过期(默认 3 小时)或协调者自身故障即丢;恢复瞬间重放易压垮新节点 |
| Merkle 树 repair | 后台定期 | 全量数据,唯一的保证 | 高:要读全量、建树、跨节点传输 | 叶子粒度导致过度流式;拓扑变化要重建树;跑得太少就等着数据复活 |
| 位图版本向量 | 后台,近期缺口 | 近期写的缺失 | 很低:只比元数据、不读数据 | 只覆盖近期;长期失联与静默损坏仍要 Merkle 兜底 |
表 2 · 传播方式:把消息送到全集群的四条路
| 方式 | 消息冗余 | 故障鲁棒性 | 延迟 | 什么时候选它 |
|---|---|---|---|---|
| 直接广播 | 无冗余,但每个节点要联系所有人 | 差:漏一个就是漏一个,无自愈 | 最低(一跳) | 节点数很少、成员稳定(几十台以内) |
| 固定生成树 | 最低:N-1 条边 | 最差:内部节点一挂,整条支路失联 | 低(树高) | 拓扑稳定、有人维护树结构 |
| 纯 gossip | 高:同一消息被重复收多次 | 最好:无中心、路径天然冗余 | O(log N) 轮,但无上界保证 | 成员管理、故障怀疑、大规模易变集群 |
| 混合 gossip | 低:正文走树,其余只发通告 | 好:通告发现缺口后自愈重建树 | 接近生成树 | 要广播较大负载(如数据本身)又要容错 |
判据压成两句。补账通道要全选:它们是三层防线不是备选项,只按数据热度调配比重——热数据靠读修复就能把陈旧度压到很低,冷数据只能指望 repair 的节奏。传播方式看「消息多大、成员多稳」:小而多变的元信息用纯 gossip;负载大又要容错,才值得上混合 gossip 那套复杂度。
这一章的实用价值是把一句运维直觉变成可设计的东西:最终一致系统的健康度,等于你的反熵预算。架构评审里最常见的错,是把「我们用了最终一致的存储」当结论,却没人回答「repair 多久跑一轮、跑得完吗、跑不完会怎样」。面试同理:能说清「读修复为什么替代不了 repair」「hint 过期后会发生什么」,比背出 gossip 三个字有分量得多。
对照真实系统:Cassandra / ScyllaDB 把四件套(hints、read repair、nodetool repair、gossip)全套摆出来,是本章最直接的实现;DynamoDB 与 Riak 走 Dynamo 原路,Riak 的主动反熵(AAE)常驻哈希树;Consul / Serf 用 SWIM 式 gossip 管成员与故障;Redis Cluster 用 gossip 传播槽位与节点状态。反例同样重要:etcd 与 CockroachDB 走共识路线,写入时就必须过多数派,因此根本不需要反熵——这正说明反熵是「事后补账」这条路线特有的运营成本。
3 h,超时则「the destination replica will be permanently out of sync until either read-repair or full/incremental anti-entropy repair propagates the mutation」。Apache Cassandra 官方文档 · Dynamo(architecture) / Hintsread_repair_chance / dclocal_read_repair_chance 在 4.0 里被删掉了。Apache Cassandra 官方文档 · Repair / Read repairSELECT 覆盖到的数据,冷分区一辈子轮不到;把它当收敛保证,就是在等数据复活。① 复制只负责尝试送达;反熵是把没送到的那部分补齐的机制。发散就在正常路径上——w=2 的成功写本来就可能漏掉第三个副本。
② 本章按时机组织:读时(读修复)、写时(提示移交)、后台(Merkle 树 / 位图版本向量),三者互补、不可互替。
③ 读修复靠摘要读几乎白嫖读请求,但只修被读到的数据;阻塞式保住单调 quorum 读,代价是多一次写往返。
④ 提示移交 + sloppy quorum 把短时故障挡在写失败之外,但 hint 有保质期(默认 3 小时)、无冗余,本质是「尽力而为」。
⑤ Merkle 树先比根、再逐层下降,把找分歧的代价压到 O(log n);代价是叶子粒度导致过度流式、建树要读全量、拓扑一变就重建。
⑥ 位图版本向量只比元数据不读数据,补近期缺口极便宜,但兜不住长期失联。
⑦ gossip 用冗余换鲁棒:每轮随机挑 f 个同伴,轮数随节点数对数增长;调参点是「何时停止转发」,push-pull 收敛最快。
⑧ 纯随机 gossip 浪费带宽、固定生成树一断就残,混合 gossip(正文走树 + 通告兜底)让树能自愈;部分视图让单节点开销不随集群规模增长。
⑨ 一句话带走:最终一致性的「最终」是运营支出,不是协议赠品——宽限期 10 天就意味着 7 天必须跑完一轮 repair,否则删掉的数据会自己回来。