专业书籍精读 · Database Internals · 第 13 章
Database Internals · Ch 13 · Alex Petrov · 2019
你点一下「转账」,钱从你的账户扣掉、加到对方账户上;要是这两笔只成了一笔,就是灾难。单台数据库里这事早就解决了——这一章问的是更难的版本:当两个账户根本不在同一台机器上,怎么保证「要么都成、要么都不成」?这就是分布式事务(distributed transaction)。
想象三个人在三个城市要签同一份合同,必须全签或一个都不签,而他们只能打电话。常规办法是找个牵头人跑两轮:第一轮挨个问「你能签吗」——谁回了「能」,就等于把笔攥在手里、位子占住了,不许再反悔;第二轮收齐「能」之后,挨个说「签」。
怪事就在这两轮之间。牵头人收齐「能」、还没通知任何人之前,手机掉进了水里。剩下两人签也不敢(万一他其实决定撤销?)、撤也不敢(万一有人已经签了?),只能攥着笔、占着会议室干等。
这不是办法笨,而是绕不开。在剩下两人看来,「牵头人没做决定」和「做了决定但我没收到」长得一模一样,只要第二种可能还在,谁也不敢自己拍板。于是这套办法有个硬伤:保证绝不出错,但不保证一直能动。在数据库里,「攥着笔干等」就是那几行数据被锁着、别人读不了也写不了,而牵头人重启可能要几分钟。
多打一个电话有用吗?有一点:牵头人先通知「我要让你们签了」,之后谁等不到下文就默认签。但电话线要是从中间被掐成两半,两边各按默认行事,就可能一半签了一半撤了——比卡住更糟。真正管用的三招都不是改进那两轮,而是换地基:
① 先排号,再办事。不问「能不能」,先把所有人要办的事排成一个公认的号码顺序,各自照号办。顺序一样、办法一样,结果必然一样——不用商量。
② 牵头人不止一个。让这个角色由一小组人共同担任,决定一做出来组里好几个人都记着;掉一个手机不影响大局。
③ 把「谁签了」写在合同本身上。约定以某一页的签名为准,那页签了整份就生效;后来翻到的人顺手把其他页补齐或划掉——不需要全程在场的牵头人。
三招都要付学费:排号法要求你事先把要干的事全交代清楚,不能边做边想;多牵头人法要靠极准的时钟和更贵的机房。所以工程上常有个更好的第四答案:先看看这事需不需要凑成一个事务。
跨机器的「要么全成要么全不成」,难点在于必须先让大家承诺不反悔,才能做决定——而承诺之后、决定到达之前那一小段,牵头人一倒,所有人只能攥着锁干等。后来的办法全在换地基:先排号、让牵头人变成一群人、把状态写进数据本身,或者别开这个事务。
想进到 2PC / 3PC 的具体协议、Calvin 与 Spanner 的地基差别和示意图? → 切到精读版
这一章讲原子提交(atomic commitment):怎么让横跨多个节点的事务「要么全生效、要么全不生效」。核心洞察是——它和共识(consensus)是近亲,但要求更苛刻:共识只要多数同意就能推进,原子提交要求全体一致,于是任一参与者失联就推不动。两阶段提交(2PC)用阻塞买下「绝不出错」;三阶段提交(3PC)想赎回活性,却在网络分区下破功。真正的出路是换地基:Calvin 先对顺序达成共识、执行时不再协商;Spanner 保留 2PC 但让每个角色都是一个 Paxos 组;Percolator 把事务状态写进数据表自己;协调避免则论证哪些事务压根不需要协调。
0.5ms、跨大洲常 70–150ms;协议多一轮就多付一个 RTT。本章在 Part II「分布式系统」的尾部。第 11、12 章解决的是单个键的复制与一致性(quorum、读修复、反熵),但那些机制对「两个键一起改」无话可说;本章补上这一块,而它用到的「多数同意」原语,由紧接着的第 14 章「共识」给出。现实对应物:MySQL/PostgreSQL 的 XA、Google 的 Percolator 与 Spanner、FaunaDB(Calvin 的商业实现)、CockroachDB、TiDB。
单机事务的原子性靠一份 WAL 就够:日志在一个磁盘上,写成功就是成功。事务一旦跨分片,「写成功」就不再有单一的真相——A 落盘了、B 没有,而网络不会告诉你 B 是挂了还是只是慢(第 9 章的结论)。不解决的下场只有两种:出现「钱扣了没到账」的半完成状态,或者退回「禁止跨分片写」、把一致性全推给业务代码事后对账。所以本章要立一个精确问题:在一组可能宕机、消息可能延迟的节点上,怎么让它们对「提交还是回滚」得出同一个、不可撤销的答案?
这是全章最该先装进脑子的区分。共识允许多数决定结果,少数派事后跟着走;原子提交规定只有全体投 Yes 才能提交,任何一个 No 都必须全局回滚。两个后果:结果不是「选出来的」,而是被任一参与者的否决权唯一确定;多数存活不够用,少一张票就推不下去。所以 2PC 不是「比 Paxos 差的共识算法」,它解的是另一个问题,而那个问题在异步网络下拿不到安全与活性的两全。
两阶段提交分投票与提交两轮。协调者先发 Propose,参与者把「我已 prepared」落盘后回 Yes 或 No;回了 Yes 就交出反悔权,此后必须有能力提交,锁得一直持到第二轮到达。第二轮协调者统票,先把决定写进自己的日志——这一刻起决定不可撤销,哪怕随即宕机,重启后也只能照原决定继续——再发 Commit/Abort 收 ack。
两种故障,严重程度完全不同。参与者投票前挂掉很好办:协调者收不到票就全局回滚,代价只是白跑一次。协调者挂掉才是真问题——已投 Yes 的参与者处于「不能自己提交、也不能自己回滚」的不确定态,只能持锁等它恢复。参与者互相打听(协作终止)只救得了「有人已收到决定」的场景;若活着的都只投过 Yes,整组只能干等。所以 2PC 的代价不是那 2 个 RTT(跨大洲约 150–300ms),而是最坏情况下持锁时长等于协调者的恢复时间——分钟量级。
三阶段提交在中间插入一轮 PreCommit(「我准备让你们提交了」),并给双方装上超时:参与者收到 PreCommit 后等不到下文就自行提交,收到之前等不到就自行回滚。协调者宕机不再使人卡死,活性回来了。代价有两层:显性的是多一个 RTT;致命的是它只在「节点会停但网络不分区、延迟有上界」的模型里成立。网络一从中间断开,收到过 PreCommit 的那半提交、没收到的那半回滚,原子性直接被撕开——比卡住更糟。这就是 3PC 几乎不被工业界采用的原因。
Calvin 换了提问方式:既然难点是「对结果达成一致」,那就只对输入的顺序达成一致。定序层(sequencer)把事务收成固定时长的批次(论文用 10ms 一个 epoch),用 Paxos 把这份顺序复制到各副本;之后每个副本按同一顺序、同一套确定性加锁规则执行。输入和顺序都一样,结果必然一样——于是不需要提交协商,也不需要把执行结果再复制一遍。Calvin 论文(Thomson 等,SIGMOD 2012)报告在 100 台普通机器上达到约每秒 50 万笔 TPC-C 事务。代价很实在:事务必须一次性提交全部逻辑(存储过程式,不支持「读一下再想下一步」的交互式事务),读写集还要提前知道;不知道时得先跑一次探路查询(OLLP),数据变了就重跑。
Spanner 让 2PC 跑在 Paxos 之上:每个分片是一个 Paxos 组,协调者与参与者各由一个组承担。于是「协调者宕机」退化成「组内换个主」,不确定窗口从分钟级压回秒级——阻塞没有消失,而是被降级成一个可恢复的故障。另一半功劳归 TrueTime:它把时钟读数返回成带不确定区间的值(区间宽度 ε 呈锯齿波、量级个位数毫秒),提交时等够 ε 再放行(commit wait),让时间戳顺序对齐真实先后。读写事务走两阶段锁 + 2PC;只读事务拿一个时间戳读快照,不加锁、不走 2PC。
Percolator 是 Google 建在 Bigtable 上的事务层,思路很反常:不要常驻协调者,把协调状态存进数据库本身。每个逻辑列拆成三列——data(各版本的值)、lock(锁)、write(已提交版本的指针)。事务拿到 start_ts 后,prewrite 给各行写入 data 与 lock,指定其中一行为主锁(primary lock)、其余锁指向它;commit 只要把主锁那行的 write 写成功,整个事务就算提交——那一个写就是提交点,其余锁留给后来的读者清理(主锁已提交就补齐,不在就回滚)。它拿快照隔离换掉了协调者这个单点,代价是读者替写者打扫,而且锁一撞就只能回滚重试,高冲突下很吃亏。
最后一节的立场最锋利:协调之所以贵,是因为很多时候它压根没必要。判据叫不变量合流性(invariant confluence, I-confluence):若两个各自满足不变量的状态合并后仍满足该不变量,这类事务就能完全不协调地并发执行。往集合里加元素是 I-confluent 的;唯一性约束、「余额不得为负」则不是——两边各自合法、合起来违规,这类必须协调。RAMP 事务给一个更弱但够用的保证:读原子性——要么看到一个事务的全部写入,要么一个都看不到;它靠多版本加少量元数据,发现「读到一半」就多补一轮读来修,无锁、无 2PC。
这一章的灵魂就在这张表上:这些方案换的不是「谁更快」,而是把不确定性放在哪里、由谁付代价。
| 方案 | 提交点在哪 | 协调者 / 主失效时 | 代价 | 什么场景选它 |
|---|---|---|---|---|
| 2PC(XA) | 协调者的日志 | 阻塞:已投 Yes 者持锁等恢复,分钟级 | 2 个 RTT;协调者单点;长时间持锁 | 同机房、参与者少、协调者能快恢复;跨服务慎用 |
| 3PC | PreCommit 已送达多数之后 | 不阻塞(靠双边超时自行决定) | 多 1 个 RTT;分区下撕裂原子性 | 基本不选,理论价值大于工程价值 |
| Calvin 式 (确定性定序) | 顺序被 Paxos 复制的那一刻 | 换主即可,执行阶段无需协商 | 只支持一次性提交的事务;读写集要预知 | 高争用、可写成存储过程的 OLTP |
| Spanner 式 (2PC over Paxos) | 协调者组的 Paxos 日志 | 降级为组内换主,秒级 | 要 TrueTime 式时钟设施;写要等 commit wait | 通用交互式 SQL + 跨地域强一致,且付得起硬件 |
| Percolator 式 (状态入表) | 主锁那一行的 write 记录 | 无常驻协调者;残锁由读者清理 | 读者替写者打扫;高冲突下回滚重试多 | 建在现成 KV 之上、快照隔离够用(TiDB 即此路) |
| 协调避免 (I-confluence / RAMP) | 各自本地提交 | 无此概念 | 只适用于可合流的不变量;保证更弱 | 不变量可合流时——最便宜的分布式事务是不做它 |
选型先问两句:①这事务非跨分片不可吗?(能按用户 ID 把相关数据放进同一分片,整章的麻烦就消失)②业务能接受的最弱保证是什么?(只要读原子性就别上 2PC)
这一章给的是一把尺子:看一个数据库的分布式事务,先问提交点在哪、那个点失效时谁在持锁等。拿它去量:XA 是教科书 2PC,协调者挂掉留下持锁的「孤儿事务」;TiDB 走 Percolator 路线,提交点是主键那一行;CockroachDB 把状态写成一条事务记录,第二阶段只改那一条;Spanner 与 FaunaDB 则是 Spanner 式与 Calvin 式的商业实现。面试被问「跨库事务怎么做」,能讲清这几个提交点比背两轮消息有用得多。
PENDING 的事务记录,提交时只改这一条——「Lower latency than traditional 2PC commit protocol (w/o contention) because second phase requires only a single write to the transaction record instead of a synchronous round to all transaction participants.」CockroachDB 设计文档(docs/design.md)· github.com/cockroachdb① 本章的问题是原子提交:跨节点的事务要得出同一个、不可撤销的「提交或回滚」。
② 它不是共识:共识靠多数,原子提交要全体同意,任一参与者的否决权唯一决定结果——多数存活不够用。
③ 2PC:投票轮 + 提交轮;投了 Yes 就不许反悔并持锁;真正的提交点是协调者自己的日志。它的病不在 2 个 RTT,而在协调者宕机留下的不确定窗口——参与者持锁干等到它恢复。
④ 3PC 加一轮 PreCommit + 双边超时换来非阻塞,但分区下会撕裂原子性,工业界基本不用。
⑤ Calvin:对输入顺序达成共识(10ms 批次 + Paxos),各副本确定性执行、不再协商;代价是事务须一次性提交、读写集需预知。
⑥ Spanner:2PC 跑在 Paxos 之上,协调者变成一个组、阻塞降级为换主;TrueTime 的 ε 与 commit wait 对齐时间戳与真实先后。
⑦ Percolator:data / lock / write 三列,主锁那行的 write 写成功即提交点,残锁由后来的读者清理,无需常驻协调者。
⑧ 协调避免:不变量可合流(I-confluence)时就不必协调;RAMP 用多版本 + 元数据提供读原子性。工程第一问永远是这事务能不能不跨分片。