专业书籍精读 · DDIA · 第 7 章
Designing Data-Intensive Applications · Ch 7 · Martin Kleppmann · 2017
你给朋友转账:你的余额扣了 100,朋友的还没加上,这一瞬间机房断电了。钱去哪了?DDIA 第 7 章讲的事务(transaction),就是数据库给出的答案——它保证一组操作「要么全部做成,要么当作从没发生」,绝不停在半路。这一章还告诉你:现实里很多数据库嘴上说的「安全」,其实没你以为的那么安全。
事务像搬家打包:你把一屋子东西装进一个箱子,贴上封条搬走。到了新家,要么整箱完好送到(提交 commit),要么半路出了岔子、原样退回旧家(回滚 abort)——绝不会「桌子到了、椅子丢在半路」。数据库替你把「扣钱 + 加钱」封进同一个箱子,一起成、一起败。
难点不在一个人慢慢改数据,而在好多人同时改。两个人同时抢最后一张票、同时给同一篇维基词条补一句话、同时把账户余额减一笔——如果数据库不管,就会有人的修改被悄悄覆盖,或者读到一半是旧、一半是新的「花账」。加上机器随时可能崩,混乱是常态。
数据库怎么让大家不打架?主流招数是发给每个事务一张「此刻的照片」——你这笔操作从头到尾都看着开始那一瞬的世界,别人后来的改动你看不见,也就不会被搅乱(这叫快照 snapshot)。等你说「提交」时,数据库再回头检查一下:我读的东西,中途被别人动过没有?动过就让你重来一次。各看各的照片、提交时再对账——这就是现代数据库并发的骨架。
医院规定至少留 2 个医生值班。此刻正好 2 人在岗,两人都想请假。他们几乎同时点了「请假」:小李看了一眼「现在有 2 个人呢,我走没事」,小王也看了一眼「有 2 个人,我走没事」——于是两人都走了,值班室一个不剩。各自看着都合规,合起来却闯了祸。这种「你读你的、我读我的,凑一块坏事」的坑(叫写偏斜 write skew),恰恰是很多号称安全的数据库挡不住的——这一章专门教你认出它。
数据库把「安全」做成一个可调的旋钮(叫隔离级别):拧到最松,快但容易出上面那些乱子;拧到最紧(叫可串行化,效果等于「大家排队一个一个来」),最安全但要么慢、要么老让你重试。麻烦的是名字会骗人:有的数据库把旋钮标成「可串行化」,其实只拧到了中间档。所以别只看名字,要看它到底挡住了哪些坑。
事务 = 「要么全成、要么全废」的一个箱子,替你挡住并发打架和中途崩溃两种混乱。但「安全」是可调的旋钮、且名字常常虚标——看它真挡住了哪些坑,别信它自称的档位。
一个诚实的代价:把旋钮拧到最紧(真正的可串行化),要么让事务排成单队慢下来,要么在高并发下频繁「撞车重来」——安全从来不是免费的。
想进到 ACID、隔离级别、MVCC 快照与两阶段锁的具体机制? → 切到精读版
事务(transaction)是数据库给应用的一层「假装无事发生」的简化:把一组读写打包成一个逻辑单元,让应用可以当作「并发问题和部分故障都不存在」来写代码。本章先讲清 ACID 四个字母到底保证了什么(尤其是被误解最多的 I / 隔离性),再把工业界常用的几档弱隔离级别会漏掉哪些并发异常一一摊开,最后给出可串行化的三条实现路——你会发现,很多数据库自称的「安全」,比名字弱得多。
本章属于全书 Part II「分布式数据」。前两章(复制、分区)解决「数据放在多台机器上」,本章暂时把镜头拉回单节点,专攻另一条正交的难题:并发访问同一份数据 + 随时可能崩溃时,怎么不出乱子。它上承第 5、6 章对多副本一致性的讨论,下启第 8、9 章(分布式系统的麻烦、一致性与共识)——理解了单机事务的隔离与异常,才谈得清分布式下更硬的一致性。对应现实里所有关系数据库、以及越来越多的分布式数据库的核心承诺。
数据系统里总有事与愿违:写到一半进程崩了、多个客户端同时改同一条数据、网络中断让请求真真假假。若让每个应用自己处理这些边角情况,代码会被防御逻辑淹没,而且几乎必然写错——竞态(race condition)极难测出、极难复现。事务的价值,就是把这一大类问题打包成一个抽象:应用只管把相关操作圈进一个事务,数据库负责保证「要么全成、要么全废、且并发互不干扰」。这是几十年来「数据库替应用挡下混乱」的看家本领。
但 DDIA 提醒得很直接:事务不是自然定律,而是有代价的工程选择。2000 年代 NoSQL 浪潮为了扩展性和高可用,一度把事务当包袱丢掉;后来发现没有事务,应用层的错误处理苦不堪言,于是又部分请了回来。这一章要回答的正是:事务到底给了什么保证、这些保证有多贵、以及在哪些地方它其实没兑现承诺。
ACID 是事务保证的经典缩写,但 DDIA 强调它更像营销口号——不同数据库对它的实现差异极大。逐个拆:
与 ACID 对立的是个含糊的营销词 BASE(Basically Available, Soft state, Eventual consistency),实际意思差不多就是「凡是不满足 ACID 的」——边界模糊,别太当真。
连一次单个对象的写也需要原子性和隔离:写一个 20KB 的 JSON 文档写到一半崩了,不能让别的读者读到半个损坏文档(靠日志实现原子恢复、靠行锁实现隔离)。但这只是基础。真正需要多对象事务(把对多行、多表的操作圈在一起)的场景是:外键引用要跨行保持一致、反范式化(denormalized)的冗余数据要一起更新、二级索引要与主数据同步——这些「几处必须同时对」的地方,缺了事务就会出现引用悬空、索引与数据打架。
最常见的默认档,给两条保证:① 不脏读(no dirty read)——只能读到已提交的数据,别人事务改了一半、还没提交的中间值你看不到;② 不脏写(no dirty write)——只能覆盖已提交的数据,两个事务同时写同一对象时,后者要等前者提交(靠行锁排队),不会把彼此未提交的写搅在一起。实现上:写用行锁;读则由数据库同时留着「旧值 + 新值」,事务提交前一律返回旧值。PostgreSQL、Oracle、SQL Server 等都默认它。但它挡不住下面这个坑。
先看读已提交漏了什么——读偏斜(read skew,又叫不可重复读):你有 A、B 两个账户各 500 元。你先读 A(还是 500),此时一笔转账把 100 从 A 挪到 B 并提交;你再读 B(变成了 600)。你看到的总额是 500 + 600 = 1100——凭空多了 100。数据其实一刻都没错,只是你「一半读到转账前、一半读到转账后」。对备份、对账、跑分析查询这种要一次读一大片的场景,这种花账是灾难。
快照隔离的解法很漂亮:每个事务从它启动那一刻的一致快照读数据——整个事务期间,看到的都是「开始那一瞬」的世界,别人后来提交的改动一概看不见。于是上面的总额永远是 1000。实现主流靠 MVCC:数据库为每条数据保留多个带版本号的副本,每个事务按「哪些事务在我启动前已提交」的可见性规则,各自读自己该看的版本。妙处在于:读者不阻塞写者、写者也不阻塞读者——长时间的只读大查询和高频写入可以并行,互不卡顿。
并发写的另一大坑是丢失更新:两个事务都做「读—改—写」循环(read-modify-write)——比如各自读计数器 42、各自加一、各自写回 43,结果本该 44 却只涨了 1,一次自增被悄悄吃掉。维基词条并发编辑、账户余额并发扣减都会中招。DDIA 给了几条对策:原子写操作(UPDATE … SET n = n + 1,把读改写压进数据库一步完成,最推荐)、显式加锁(SELECT … FOR UPDATE)、自动检测并中止重试(部分快照隔离实现能发现丢失更新并回滚,如 PostgreSQL 可重复读;但 MySQL/InnoDB 不检测)、以及比较并设置(compare-and-set)。
这是全章最该记住的那个坑,也是快照隔离挡不住的。写偏斜是丢失更新的推广:两个事务读了同一批数据,各自据此去写不同的对象,单看每个都合规,合起来却破坏了不变量。
经典例子(DDIA 原书就用它):医院规定至少 2 名医生值班,此刻正好 2 人在岗,两人都想请假。两个事务几乎同时执行:都先查「当前 ≥ 2 人?」——因为读的是各自快照,都查到 2、都判定「我走没问题」,于是各自把自己那行改成「不值班」。结果0 人值班,不变量被击穿。类似的还有:会议室 / 座位重复预订、抢注同一个用户名、账户双重花费(double spend)、多人游戏两人走到同一格。
它们的共同结构叫幻读(phantom):一个事务的写,改变了另一个事务某次查询的结果集——你基于「当前满足某条件的行有哪些」下了决策,可这个「有哪些」被别人的写改掉了。快照隔离能挡住只读查询里的幻读,却挡不住这种「先按查询结果决策、再写入」的写偏斜。要真正防住,只能上可串行化。
只有可串行化能一次性挡住上面所有异常——它保证并发结果等价于「某种串行顺序」。工业界有三条实现路:
30 年的老办法。读者与写者互相阻塞:读加共享锁、写加排他锁,一个写者会挡住所有读者、反之亦然;用谓词锁 / 索引范围锁连「未来符合条件的行」也锁住,才能防幻读。安全但慢:锁开销大、并发度低、易死锁(数据库检测到就中止一个重试),尾延迟(p99)极不稳定。SERIALIZABLE 与 FoundationDB 采用)。它在快照隔离之上不加锁、让事务先跑,只在提交时检查:我这笔操作依赖的「前提」(早先某次查询的结果),到现在被别人改掉了没有?被改了就中止重试。低竞争时性能远好于 2PL(读者不阻塞写者),高竞争时中止率上升。本章的灵魂是两张对照表:先认清每档隔离级别放行了哪些异常,再在三条可串行化实现路里按负载选一条。
表 1 · 隔离级别 × 挡不挡得住某个并发异常(DDIA 核心矩阵)
| 隔离级别 | 脏读 | 脏写 | 读偏斜 (不可重复读) | 丢失更新 | 写偏斜 / 幻读 |
|---|---|---|---|---|---|
| 读未提交 Read Uncommitted | 可能✗ | 挡✓ | 可能✗ | 可能✗ | 可能✗ |
| 读已提交 Read Committed | 挡✓ | 挡✓ | 可能✗ | 可能✗ | 可能✗ |
| 快照隔离 (可重复读) | 挡✓ | 挡✓ | 挡✓ | 看实现* | 可能✗ |
| 可串行化 Serializable | 挡✓ | 挡✓ | 挡✓ | 挡✓ | 挡✓ |
* 丢失更新:PostgreSQL 的可重复读会自动检测并中止;MySQL/InnoDB 的可重复读不检测。「可重复读」是 SQL 标准里定义最含糊、各家实现最不一致的一档——这也是名字最不能信的地方。
表 2 · 可串行化的三条实现路,怎么选
| 真正串行执行 | 两阶段锁 2PL | 可串行化快照隔离 SSI | |
|---|---|---|---|
| 思路 | 单线程一个一个跑 | 悲观加锁:读写互斥 | 乐观:先跑,提交时查冲突 |
| 并发/性能 | 受限单核;事务须短 | 锁开销大、并发低、易死锁 | 读不阻塞写,低竞争最快 |
| 尾延迟 | 稳定(无锁等待) | p99 抖动大(等锁 / 死锁) | 取决于中止重试率 |
| 硬约束 | 事务写成存储过程;数据须能进内存 | 需谓词锁防幻读 | 高竞争下中止率↑、要重试 |
| 代表 | VoltDB、Redis、Datomic | 传统关系库的 SERIALIZABLE | PostgreSQL 9.1+、FoundationDB |
| 适用 | 热点小、事务短的高频 OLTP | 竞争激烈、要强隔离的成熟系统 | 竞争不极端、想要串行化又要吞吐 |
表 3 · 防「丢失更新」的四种手段
| 手段 | 怎么做 | 注意 |
|---|---|---|
| 原子写操作 | UPDATE t SET n=n+1 WHERE id=…,读改写压进一步 | 最推荐;但只适合能表达成单句的更新 |
| 显式加锁 | SELECT … FOR UPDATE 先锁住再改 | 要自己记得加,漏一处就漏一次 |
| 自动检测 | 数据库发现丢失更新就中止、让你重试 | 依赖实现(PostgreSQL RR 有、MySQL RR 无) |
| 比较并设置 | 写回时校验「值还是我读到的那个吗」 | 无事务时的常见替代;注意 ABA 与快照读误判 |
事务是后端与数据库面试的高频重灾区,也是线上事故的常见根源。理解本章,你就能回答一串真问题:为什么你的数据库默认是读已提交而不是可串行化?为什么并发扣库存 / 扣余额会超卖(丢失更新)?为什么两个人能订到同一个会议室(写偏斜)?该用 SELECT FOR UPDATE、原子自增,还是把隔离级别调到 SERIALIZABLE?——这些决策的坐标系全在这一章。
更关键的一课是「别信名字、看它真挡住了什么」:Oracle 的 SERIALIZABLE 其实只是快照隔离(挡不住写偏斜),各家的「可重复读」行为也不一致。落到真实系统,业界既有验证也有踩坑:
SERIALIZABLE 实为快照隔离、挡不住写偏斜;「可重复读」在 SQL 标准里定义含糊、各家实现不一——看它挡住哪些异常,别看它叫什么。① 一句话:事务把一组读写打包成「全成或全废」的单元,替应用挡下并发与崩溃两类混乱;它是有代价的工程简化,不是自然定律。
② ACID:A 原子性=可中止可回滚;C 一致性是应用的责任(凑字母的);I 隔离性=并发互不干扰(坑最多);D 持久性=提交不丢。
③ 读已提交(多数默认):挡脏读 + 脏写,但漏读偏斜、丢失更新、写偏斜。
④ 快照隔离 / MVCC:每个事务读启动瞬间的一致快照,读者写者互不阻塞,挡住读偏斜;但挡不住写偏斜。
⑤ 丢失更新:并发「读—改—写」互相覆盖;对策为原子写、显式锁、自动检测、比较并设置。
⑥ 写偏斜 / 幻读(本章最该记的坑):读同一批数据、各写不同对象、合起来破坏不变量(值班医生、重复预订、双重花费)——只有可串行化能防。
⑦ 可串行化三条路:真串行执行(单核、事务须短)、两阶段锁 2PL(悲观、慢、p99 抖)、SSI(乐观、低竞争最快)。
⑧ 意义 & 铁律:别信隔离级别的名字,看它真挡住哪些异常——Oracle 的「可串行化」实为快照隔离;弱隔离是常态,安全要你显式打开。