专业书籍精读 · Database Internals · 第 8 章
Database Internals · Ch 8 · Alex Petrov · 2019
你在手机上点一下「确认支付」,这条消息要跨过基站、光纤、好几台机房里的机器,最后落到某个数据库上。前七章讲的都是一台机器内部怎么存怎么找。从这一章起,故事换了一个场地:多台机器之间,只能靠互相喊话来合作。这一章不教任何具体做法,它先告诉你:在这种场地上,有哪些事从原理上就办不到。
你给朋友发消息问「今晚八点行吗」,等了十分钟没回。可能是消息没送到;可能是他回了、但回复丢在路上;也可能他正低头打字。这三种情况,你从外面看完全一样。分布式系统里最硌人的就是这个「看起来一样」——超时了,你并不知道对方是死了、是慢了,还是回话丢了。
在一台机器上写程序,有三件事是白送的:调用一个函数,它一定会返回;读一个变量,读到的就是最新值;看一眼表,时间总是往前走。跨到两台机器,这三条一条不剩:函数调用变成了「寄一封信出去,可能永远没有回音」,「最新值」变成了「我上次听说的值」,两块表还可能一快一慢差出好几秒。
这一章的做法不是给解药,而是先把话说死——把含糊的「网络不太靠谱」变成三张写明白的假设表,后面所有算法都照着这三张表设计:
承诺越强,代价越大。最典型的是「不多不少、正好送到一次」——这件事在线路层面做不到:发信人收到失败时,永远分不清是「没送到」还是「送到了但回执丢了」。真正能做的,是多寄几次,让收件人按单号去重。支付的幂等键、消息队列的去重表,都是这一条的实例。
诚实的代价:这三张假设表买来的是「能讲清楚为什么对」,而不是可靠本身——假设定得太宽松,算法根本跑不动;定得太乐观,真实世界抽一次风就全盘崩。
分布式难的不是机器多,而是没有任何人能看见「现在」。每台机器手里只有一份过期的传闻。工程上唯一能做的,是先把「线路多可靠、时钟多可信、节点会怎么坏」写死成假设,再在上面盖楼。
想进到具体机制、记号和示意图? → 切到精读版
你以为分布式的难点是「机器多了不好管」,其实是没有任何观察者能看见「现在」。这一章不给一行算法,它给的是 Part II 全部算法共用的那张假设表——链路(link)有多可靠、时间有多可信、节点会怎么坏。把这三格填错,后面的共识、复制、反熵全都建在流沙上。
0.1–0.5 毫秒,跨大洲常在 100–200 毫秒。Part I 讲的是一台机器里的存储引擎(B-tree、LSM-tree、事务恢复);Part II 从这一章起跨出机器。它上承第 7 章、下启第 9 章故障检测、第 10 章领导者选举、第 11 章复制与一致性、第 12 章反熵与传播,直到第 13–14 章的共识。对应现实里的哪一类系统?所有多副本的东西——etcd / ZooKeeper、Cassandra / DynamoDB、Kafka、Spanner / CockroachDB,它们的分歧全在这一章画出的坐标系里。
单机程序员有三条白送的假设:调用必返回、读到即最新、时钟单调向前。跨机之后三条全废,而废掉的方式很隐蔽——不是立刻报错,是平时都对、压力一大就错。书里借 Peter Deutsch 的「分布式计算八大谬误」点名前几条:网络可靠、延迟为零、带宽无限、拓扑不变。
不解决会怎样?三种典型翻车:拿墙上时钟给事件排序——两台机器各自的 now() 在 NTP 校准之间可能差出几毫秒到几十毫秒,于是「后发生」的写被判成先发生,数据被静默覆盖;把超时当死亡——旧主只是 GC 停顿了 8 秒,新主已经选出来了,两个主同时接受写入(脑裂);网络错误就无脑重试——第一次其实成功了,于是用户被扣了两次款。三种错误都不是「代码写错了」,而是假设表填错了。
书里给的定义很朴素:分布式系统就是一组各自独立执行、只靠消息互通的进程。由此推出一个后果——系统里不存在「当前全局状态」这个东西。每个节点手上只有两样:自己的局部状态,和若干条已经到达的消息。没有上帝视角,没有可以一口气拍下来的快照。
所以任何「系统现在是什么样」的说法,都只是某个节点在某个时刻的猜测,而且天生落后——落后的下限就是一次单程网络时延。跨大洲部署时「最新」至少陈旧 50 毫秒,这不是实现不好,是光速。
本章最实用的一节。它把「网络」拆成三层抽象,层层加码:
要带走的一句:「可靠链路」不是网络的性质,是你用重试和记账在不可靠网络上盖出来的一层楼。而且盖得并不彻底——现实里「无限重发」永远是「重试 N 次 + 超时放弃」,所以完美链路是理想模型,不是你能买到的产品。
顺序保证也是阶梯,四档由弱到强:无序(到达顺序任意)→ FIFO 序(同一个发送者发出的消息,按发送顺序交付)→ 因果序(causal order)(有因果关系的消息不会颠倒,互不相干的可任意)→ 全序(total order)(所有节点看到完全相同的一串顺序)。越往上越贵:FIFO 只需每条链路一个递增序号;因果序要带上因果元数据(向量时钟);全序基本等价于要过一次共识,这就是第 13–14 章的活。
投递语义同样三档:至多一次(发了就不管,可能丢)、至少一次(重试到确认,可能重)、恰好一次。第三档是全章最该记住的结论:在链路层面,恰好一次不可能。发送方收到一个失败或超时时,它无法区分「消息没到」和「消息到了、处理了、但回执丢了」——唯一可信的状态是成功。工程上所说的「恰好一次」,实际上永远是至少一次 + 接收方幂等去重:消息带唯一 ID,接收方维护一张已处理 ID 表。
两将军问题(Two Generals' Problem):两支军队分处山谷两侧,只能派信使穿越敌营商定同时进攻,信使可能被截。结论是:无论交换多少轮确认,最后一条消息的发送方永远无法确定它送到了没有。在可能丢消息的链路上,两方无法就「同时行动」达成确定的一致。
FLP 不可能性(Fischer–Lynch–Paterson,1985):在异步系统里,哪怕只有一个进程可能崩溃,也不存在既保证终止又保证一致的确定性共识算法。根子还是那句老话——异步系统里分不清「崩了」和「只是很慢」。
关键不是「所以共识做不了」,而是它告诉你必须松开哪个螺丝:加时间假设(用超时把系统当成部分同步——Raft、Paxos 的路线)、放弃确定性(随机化共识)、或放弃「一定终止」的承诺(永不出错,但极端调度下可能一直选不出主)。真实系统几乎都选第一条:用超时换活性,并接受超时偶尔骗你。
同一个算法,在不同的时间假设下结论完全不同,所以这一格必须先填:
最后一格是「坏」的形状,从便宜到昂贵:
f 个崩溃需要 2f+1 个节点,容忍 f 个拜占庭需要 3f+1。数据库系统默认不做拜占庭假设——机器都在同一个组织的机房里,不值得为撒谎付三倍的账;把这一格打开的是区块链那一类互不信任的场景。
表 1 · 投递语义:你想承诺什么,就得付什么
| 语义 | 怎么实现 | 代价 / 风险 | 什么时候选它 |
|---|---|---|---|
| 至多一次 | 发出去就不管,不重试 | 可能丢,且你不知道丢了没 | 指标上报、埋点、可容忍采样丢失的遥测 |
| 至少一次 | 重试到收到确认为止 | 必然出现重复;顺序也乱 | 绝大多数消息系统的默认档 |
| 「恰好一次」 | 至少一次 + 唯一 ID + 接收方去重表 | 去重表要存储、要过期策略;只在某一个边界成立,跨系统就重新破功 | 扣款、下单、计费——任何重放会造成钱错的地方 |
表 2 · 时间假设:填错这一格,算法要么写不出、要么活不下来
| 模型 | 假设 | 能推出什么 | 现实里的位置 |
|---|---|---|---|
| 同步 | 传递与处理时间有已知上界 | 超时=可靠的故障判据,共识容易 | 几乎只存在于教科书与专用硬件网络 |
| 部分同步 | 多数时候有界,偶尔超出 | 用超时换活性,安全性永不依赖时钟 | Raft / Paxos 实现、etcd、CockroachDB |
| 异步 | 无任何时间上界 | FLP:确定性共识不可能 | 最贴近真实网络,也最无能为力 |
表 3 · 故障模型:假设越离谱,副本越贵
| 模型 | 节点会怎么坏 | 容忍 f 个所需节点 | 谁在用 |
|---|---|---|---|
| 崩溃停止 | 挂了就不回来 | 2f+1 | 理论分析的起点 |
| 崩溃恢复 | 挂了会回来,状态可能过期 | 2f+1 + 持久化日志 | etcd、ZooKeeper、所有实用共识实现 |
| 遗漏 | 活着但漏收漏发(GC、过载) | 同上,但故障检测会误判 | 真实生产中最常发生的一种 |
| 拜占庭 | 任意行为,包括撒谎 | 3f+1,且消息轮数更多 | 区块链、跨组织系统;数据库默认不做 |
这一章的价值在于:它把「分布式很难」这句废话,换成了三个能在设计评审上直接开口问的问题——我们假设的是哪一档链路?哪一档时间模型?哪一档故障模型?面试里同理:被问「怎么保证消息不重不漏」,答「用 MQ 的 exactly-once」是没分的,答「链路层做不到恰好一次,所以做至少一次投递 + 接收端幂等去重,去重表按业务 ID 建、保留 7 天」才是懂行的回答。
① 分布式系统=一组独立进程只靠消息协作;没有全局状态、没有快照、没有上帝视角——每个节点手里只有一份过期的传闻。
② 单机三条白送的假设(调用必返回、读到即最新、时钟向前)跨机后全废,而且是压力大了才发作。
③ 链路阶梯:公平损失(会丢会重会乱序)→ 固执(无限重发 ⇒ 最终必达)→ 完美(去重 ⇒ 有效性 / 无重复 / 无凭空创造)。可靠是垒出来的,不是网络给的。
④ 顺序阶梯:无序 → FIFO → 因果序 → 全序;全序基本等价于一次共识,最贵。
⑤ 恰好一次在链路层不可能。工程上的「恰好一次」永远是至少一次 + 唯一 ID + 接收方去重表。
⑥ 两将军:可能丢消息的链路上,最后一条消息的发送方永远无法确认它送到了。
⑦ FLP:异步系统里,哪怕只有一个进程可能崩溃,也没有既终止又一致的确定性共识——因为分不清「崩了」和「很慢」。
⑧ 出路是松开某个螺丝:加时间假设(部分同步)是现实系统的普遍选择——用超时换活性,但安全性绝不建立在时钟上。
⑨ 故障模型:崩溃停止 / 崩溃恢复 / 遗漏 / 拜占庭。容 f 个崩溃要 2f+1 台,容 f 个拜占庭要 3f+1;数据库默认不付拜占庭这笔钱。
⑩ 评审时的三问:我们假设的是哪一档链路、哪一档时间模型、哪一档故障模型?答不上来,后面的复制与共识都是空中楼阁。