专业书籍精读 · Database Internals · 第 11 章
Database Internals · Ch 11 · Alex Petrov · 2019
你在微信里换了头像,刷新一下还是旧的那张;过两秒再刷,新的出来了。《Database Internals》(《数据库系统内幕》)第 11 章讲的就是这两秒里发生了什么——你的数据其实同时存了好几份,而它们并不总是一样。这一章要给出的,是一套说清「读到的到底是哪一份」的语言。
把数据想成一份要紧的文件,抄成三份交给三个人保管——这样任何一个人请假,文件都不会丢。但麻烦跟着来了:你改了甲手里那份,乙和丙手里还是旧的。此时有人去问乙,拿到的就是旧答案。更怪的是:先问甲得到新答案,再问乙得到旧答案——在提问者看来,数据「往回退了」。
最直觉的解法是「改的时候让三个人同时改完,谁都别抢跑」。可这意味着每次改动都要等最慢那个人点头。同城机房打个来回约 1 毫秒,跨国要几十毫秒——把「保证读到最新」做到极致,代价就是每次读写都多等好几趟问询,而且只要有一个人联系不上,谁都别想改了。反过来图快,就得接受偶尔读到旧数据:这是绕不过去的取舍。
这一章最重要的观念:「一致不一致」不是是非题,而是一排明码标价的承诺档位,从贵到便宜——
挑哪一档,取决于读到旧值会怎样:转账余额和点赞数,显然不该买同一档。
这一章末尾有个漂亮的点子:有些数据可以被设计成「怎么合都合得上」。比如统计点赞,让每台机器只记自己收到的那部分,合并时按机器逐项取各自的最大值再相加——重复、乱序、重发都不影响结果。这类数据压根不用商量谁说了算,自己就会收敛。代价是:只有一部分数据类型能这么设计,而且「合上了」不等于「合出来的是人想要的」。
存多份是为了不丢,但多份之间一定有先后差。所以别问「数据一致吗」,要问「我愿意为哪一档承诺付多少延迟」——从「永远读到最新」到「终究会一样」,中间还有好几档很划算的选择。
想进到具体机制、记号和示意图? → 切到精读版
复制(replication)是为了可用,而一致性模型(consistency model)是为复制开出的账单。副本一多,「数据库返回了一个值」就不再自明——你必须先约定「哪些返回值算合法」。本章把这个约定做成一条可精确定义的谱系:从寄存器语义(safe / regular / atomic)到线性一致、顺序一致、因果一致、会话保证、最终一致,再到无需协调就能收敛的 CRDT。要建立的认知是:一致性不是性能旋钮,是合约——你调 quorum 调出来的,往往不是你以为的那一档。
n。n/2 + 1 个。本章属 Part II「分布式系统」,是 Ch8(分布式引论)→ Ch9(故障检测)→ Ch10(领导者选举)之后的第四站:前三章解决「谁还活着、谁说话算数」,本章追问「多副本之下,一次读该返回什么」。下接 Ch12(反熵与传播:只保证最终一致时,用 gossip / Merkle 树 / 读修复把副本拉回来)与 Ch13–14(分布式事务与共识:把最强那档真正实现出来)。对应现实里 Cassandra / DynamoDB 的一致性级别、ZooKeeper / etcd 的线性一致读、Spanner 的外部一致性。
可用性靠冗余,冗余意味着多份副本,而多份副本会短暂地不一样——于是一个看似无需定义的问题变成了必须定义的合约:读,该返回哪一份?
具体化:一个三副本、跨三可用区的键值存储,写入落到副本 A 成功即返回,紧接着一次读被路由到副本 B。跨 AZ 复制往返约 1–2 毫秒,而这 1–2 毫秒里 B 手上就是旧值。不解决会怎样?不是「偶尔有点旧」这么温和——是系统失去可推理性:同一请求重试两遍得到两个答案,你却无法判断哪个是 bug。所以本章做的事是:把「一致」这个形容词,拆成一组可判定、明码标价的合约。
几乎所有关于一致性的误解都从「CAP 三选二」开始。Petrov 特意提醒:CAP 成立的前提是一个很窄的模型——异步网络、没有时钟、消息可任意丢失;它说的 C 专指线性一致性,A 专指「每个到达非故障节点的请求必须在有限时间内返回」,而非日常说的「高可用」。真正的结论是:只有分区真的发生时,你才被迫在 C 和 A 之间选一个;不发生分区的绝大多数时间,两者都能有。
更贴工程的替代提法是 Fox 与 Brewer 1999 年的收成与产出(harvest and yield):产出是成功完成的请求比例,收成是答案的完整度。允许「只搜到 90% 索引分片的结果」,比「全或无」更接近真实系统的降级方式——你是在牺牲收成换产出,可用性由此从 0/1 变成连续量。
要精确讨论「读到什么算合法」,得先有个最小模型。本章借用共享内存的视角:把整个分布式存储当成一个能读能写的格子(寄存器),然后问——一次读和一次写在时间上重叠时,读可以返回什么?三档:
这个阶梯的价值在于:它把「一致性」从形容词变成可判定的执行历史集合——给定一段读写历史,你能回答「合法吗」。
有了寄存器语义,常见模型就能排成一条谱系——越靠上,协调越多、延迟越高、分区时越脆。
sync。谱系那几档都是系统级的全局性质,因此昂贵。工程上常见的折中是把承诺范围缩到单个客户端会话:这类会话模型(session guarantees)只对「你这条会话」成立,于是便宜得多:
最便宜的实现是粘性会话(stickiness):同一客户端固定路由到同一副本,前两条几乎白送。代价是副本一挂保证就断——严肃场景要靠版本号(「不早于我上次看到的版本」)而非路由技巧。
w + r > n 并不给你线性一致Dynamo 一系(Cassandra、Riak、DynamoDB)把选择权交给每一次操作:写等 w 个副本确认、读等 r 个副本回答,副本总数 n。当 w + r > n 时读写集合必有重叠,至少一个应答者见过最新写。Dynamo 论文(SOSP 2007)给出的常用配置是 n=3, w=2, r=2。
这是本章最值得记住的坑:w + r > n 只保证「有人见过最新写」,并不保证线性一致。因为写可能中途失败(w 个里只成功一个便返回失败,而那个副本上的新值还在)或仍在进行中——两次连续的读就可能先新后旧,单调读被破坏。
要补上缺口,得让读把读到的最大值写回落后的副本(ABD 式两阶段读,工程上即读修复),读路径从一趟变两趟。换句话说:「调 quorum」调出来的是可用性与陈旧度的折中,不是谱系上那一档合约。
多数派容错要求副本数够多,但「够多」的成本大头是存储,不是投票。本章给了个精巧的省法:见证副本(witness replica)平时只存元数据、不存数据本体,却照样投票;只有全量副本失联时才临时接收数据、事后交还。于是 2 全量 + 1 见证保住了三副本的多数派容错,存储只花约 2/3。Cassandra 的 transient replication 与 Spanner 的 witness 副本就是这个思路。
最终一致性的痛处是冲突要有人裁决。最常见的裁法是 LWW——比时间戳,大的赢。但时钟一回拨,赢的可能是旧值,而且输掉那条写被静默丢弃,连报错都没有。
CRDT(conflict-free replicated data type,无冲突复制数据类型)换了个思路:把数据类型设计成「合并本身不需要裁决」。要求合并满足交换律、结合律、幂等——于是乱序、重复、重发都不改变结果,副本只要收到同一组更新(不论顺序)就必然到达同一状态。这个性质叫强最终一致性(strong eventual consistency):它把「收敛」从运维承诺变成数据类型的数学性质,完全不需要共识。
以增长计数器(G-Counter)为例:每个副本只增自己那一格,合并时逐格取最大值,总值是各格之和。
再加一格「减量」就是可增可减的 PN-Counter;集合类有 G-Set、观察-移除集(OR-Set)等变体。实现分两派:状态型(CvRDT)互传整个状态,操作型(CmRDT)只传操作、更省带宽但要求投递保证。
代价很实在:只有部分数据类型做得出 CRDT;为正确处理删除要留墓碑,元数据随时间膨胀;而且「收敛」不等于「语义正确」——副本完全可以一致地得出一个谁都没想要的结果。
表 1 · 六档合约怎么挑
| 档位 | 挡住哪些异常 | 协调代价 | 什么时候选它 |
|---|---|---|---|
| 线性一致 | 全部单对象异常:陈旧读、读倒退、写乱序 | 最贵:每次读写一轮多数派;跨 AZ 1–2ms、跨洲几十 ms | 配置元数据、分布式锁、库存 / 余额扣减、选主状态 |
| 顺序一致 | 读倒退、写乱序;不挡陈旧读 | 中:写要协调,读可本地应答 | 读远多于写、能容忍整体延后的协调服务 |
| 因果一致 | 因果倒序(先见回复后见原帖);不挡并发写分歧 | 较低:只需版本向量,无需共识 | 社交时间线、评论区、多地写入的协作场景 |
| 会话保证 | 仅在你这条会话内挡陈旧读 / 读倒退 | 很低:粘性路由或带版本号即可 | 面向用户的读写:改完资料要立刻看到 |
| 最终一致 | 几乎什么都不挡,只保证终将收敛 | 最低:本地写完即返 | 计数、日志、缓存、可后补的统计 |
| CRDT(强最终一致) | 不挡陈旧读,但无需裁决即收敛、不丢写 | 零协调,成本转到元数据体积 | 离线优先、多地同时可写:协作文档、购物车、点赞 |
表 2 · 同一套三副本,配置不同差别有多大(n=3)
| 配置 | 读路径 | 写路径 / 可用性 | 诚实代价 |
|---|---|---|---|
两种极端w=3,r=1 / w=1,r=3 | 前者读最快,后者要等最慢副本 | 前者挂一台就写不了,后者写最快 | 都把全部代价压在一侧,挂一台即半瘫 |
w=2, r=2 | 两趟(含读修复) | 可容一台故障 | 默认选择,但仍不是线性一致 |
LOCAL_QUORUM | 本区域多数派,约 1–2ms | 整区域挂掉才不可用 | 跨区域可能读到旧值 |
EACH_QUORUM | 每区域都要多数派 | 任一区域分区即写失败 | 跨洲 p99 抬到 80–150ms |
| 2 全量 + 1 见证 | 同 w=2, r=2 | 容错等同三副本 | 省约 1/3 存储,全量副本失联时降级复杂 |
判据很朴素:先问「读到旧值会怎样」。答案是「用户看到旧头像」,会话保证就够;答案是「超卖 1000 件」或「两个节点同时以为自己是主」,就只能付线性一致的钱——而且得用共识实现,不是把 r 调大。
本章的实用价值在于把含糊的需求翻译成可实现的合约。架构评审里最常见的错就是把一致性当性能旋钮:「感觉有点不一致,把 r 调到 3」——延迟涨了、异常还在,因为 r 治不了读倒退。面试同理:能说清「w + r > n 为什么不等于线性一致」,比背 CAP 三选二有分量得多。
对照真实系统:etcd(Kubernetes 的唯一真相源)默认线性一致读,也留了更快的可串行化读作逃生门;ZooKeeper 读是顺序一致,要线性一致得显式 sync;Cassandra / DynamoDB 把档位交给每次调用;Spanner 用 TrueTime 与提交等待做到外部一致性;MongoDB 3.6 起提供因果一致会话;Riak 与协作编辑引擎走 CRDT。
operationTime 存进会话、在后续请求的 afterClusterTime 里带上、并在节点间 gossip 集群时间。同一规范也写明限制:「Causally consistent operations are not causally consistent with unacknowledged writes.」——不等确认的写进不了因果链。MongoDB 官方驱动规范 · Causal Consistency Specificationw + r > n 不等于线性一致。它只保证重叠、不保证顺序;要线性一致得加读回写,或干脆用共识。① 复制买的是可用性,一致性模型是这笔买卖的账单;副本一多,「读该返回什么」就必须写成合约。
② CAP 模型很窄:C 专指线性一致、A 专指「请求必须有限时间内返回」,只有分区真的发生时才被迫二选一;替代提法是收成 / 产出。
③ 寄存器三档(safe / regular / atomic)把一致性变成可判定的执行历史;atomic = 单对象线性一致。
④ 谱系从贵到便宜:线性一致 → 顺序一致 → 因果一致 → 会话保证 → 最终一致。因果一致是「可用 + 分区容忍」下可达的最强模型;线性一致可组合,弱模型不一定。
⑤ 会话保证是性价比之王:读己之写、单调读、单调写、写跟随读——粘性路由几乎白送,代价是副本一挂保证就断。
⑥ 最该记住的:w + r > n 只保证重叠、不保证顺序,因此不是线性一致;补齐靠读回写 / 读修复,读路径从一趟变两趟。
⑦ 级别是有价的:LOCAL_QUORUM 约 1–2ms,EACH_QUORUM 跨洲 80–150ms,换个级别 p99 差两个数量级。
⑧ 见证副本只投票不存数据:2 全量 + 1 见证 = 三副本的容错、约 2/3 存储;LWW 则会因时钟回拨静默丢写。
⑨ CRDT 把收敛变成数据类型的数学性质(交换 / 结合 / 幂等),零协调、不丢写;代价是类型受限、元数据膨胀,且收敛不等于语义正确。判据只有一句:先问「读到旧值会怎样」。