第 7 章讲数据库里最重要的抽象之一:事务(transaction)。事务把若干读写组成一个逻辑单元,要么整体成功(提交)要么整体失败(中止/回滚)。这让应用能假装并发问题和部分失败不存在——一个巨大的简化。但事务不是自然法则;它是一个取舍,许多分布式数据存储为性能削弱或丢弃了它们。本章考察事务实际提供什么保证、"隔离"能以什么意外方式泄露,以及真正完全可串行化(serializable)需要什么。
- ACID 既是营销也是定义——原子性(全有或全无)、一致性(一个应用关切,异类)、隔离性(并发事务不互相干扰)、持久性(已提交数据存活)。
- 隔离级别是折中的层级——read committed → snapshot isolation → serializable,每个以更高成本防更多异常。
- Read committed 阻止脏读和脏写;snapshot isolation(MVCC)给每个事务一个一致的时间点视图,使读者和写者从不互相阻塞。
- 丢失更新、写偏斜、幻读是经典竞态——且 snapshot isolation 不防写偏斜。
- 可串行化是唯一排除所有竞态的保证,三种方式实现:实际串行执行、两阶段锁(2PL)、可串行化快照隔离(SSI)。
- SSI 是现代胜利——乐观并发,让事务在快照上运行,只中止那些在提交时真正冲突的。
事务使一组操作原子且隔离,这样应用无需推理部分失败或交错。"隔离"分级:较弱的(read committed、snapshot isolation)快但允许丢失更新、写偏斜等微妙异常;可串行化禁止它们全部。数据库通过字面串行执行、悲观锁(2PL)或乐观冲突检测(SSI)达到可串行化——最后一个是最佳通用方法。
ACID 的含义
事务的安全保证通常用缩写 ACID 概括——但 Kleppmann 强调这个词已被稀释成营销口号,每个数据库意思略不同。精确地说:
- 原子性(Atomicity)——事务的写全有或全无。若中途出错,整个被中止、已做的任何写被丢弃。更好的名字是可中止性(abortability):关键特性是出错时能丢弃一切并安全重试。
- 一致性(Consistency)——应用的不变量(如贷方等于借方)成立。这是异类:它是应用的属性,不是数据库的。数据库能强制一些约束,但定义和保持不变量最终是应用的活。(Joe Hellerstein 注意到 C 是被硬塞进来凑缩写的。)
- 隔离性(Isolation)——并发执行的事务不互相踩;结果就像它们一次一个运行。这是丰富、微妙的部分,占本章大部分。
- 持久性(Durability)——一旦事务提交,它的数据不会丢,即便崩溃。用预写日志和复制实现。(完美持久性不存在——每份副本都可能被毁——所以它关乎降低风险。)
单对象与多对象操作
原子性和隔离性对单对象容易提供(大多数数据存储都做)。难的、有价值的情况是多对象事务,几行/文档必须一起改——如账户间转账,或保持一个反规范化计数器与它计数的行同步。许多分布式存储放弃了多对象事务,因为它们跨分区难实现;本章主张它们比 NoSQL 运动假设的更有价值。当一个操作确实失败并中止,重试是正确的恢复——但有注意事项(重试一个实际成功但确认丢失的事务能双重应用副作用)。
弱隔离级别
可串行化隔离有性能成本,所以数据库历史上提供较弱级别,防一些异常但不防其他。这些弱级别是无数微妙 bug 的来源,正因为它们在测试里看起来没问题、只在并发下破裂。
Read Committed
最基本级别。它做两个保证:无脏读(你只看到已提交的数据——绝不看到另一个事务未提交的写)和无脏写(你只覆写已提交的数据——防止两个事务的写糟糕地交错)。它通常通过每行持一个写锁实现,而读返回最后已提交的值(在写进行中时记住旧值),所以读从不阻塞。
Snapshot Isolation 与 Repeatable Read
Read committed 仍允许读偏斜(read skew,不可重复读):若你在跨另一个事务提交的两个时刻读两行,你能看到不一致组合——像一个看似在转账中途丢了钱的银行余额。Snapshot isolation 修复这个:每个事务从它开始那一刻数据库的一致快照读,所以它看到每个其他事务的全有或全无。关键实现想法是多版本并发控制(MVCC):数据库保留每个对象的几个已提交版本,每个事务看到它开始时已提交的版本。大回报:读者从不阻塞写者、写者从不阻塞读者。Snapshot isolation 对备份和分析这类长读非常宝贵。(令人困惑的是,许多数据库叫这个 "repeatable read"。)
防止丢失更新
丢失更新(lost update)问题是一个 read-modify-write 竞态:两个事务读一个值,各修改它,一个写盖掉另一个。
# 两个客户端自增同一计数器(从 42 开始)
T1: read(counter) → 42
T2: read(counter) → 42 # 两者都看到 42
T1: write(counter = 43)
T2: write(counter = 43) # 应该是 44!丢了一次自增
解法,大致按偏好顺序:
- 原子写操作——让数据库做自增(
UPDATE ... SET n = n + 1);适用时的最佳选项。 - 显式锁——
SELECT ... FOR UPDATE锁住你即将修改的行。 - 自动丢失更新检测——一些数据库(带 snapshot isolation)检测竞态并中止冒犯的事务。
- compare-and-set——只在值自你读后没变时才写。
- 冲突解决——对复制数据,允许并发写并合并它们(避免 LWW,它只是丢掉一个)。
写偏斜与幻读
写偏斜(write skew)是丢失更新的泛化:两个事务读同一组对象,然后各基于读到的更新不同对象——它们合起来违反一个各自单独都会保持的不变量。经典例子:一家医院要求至少一名医生值班。两名当前都在值班的医生同时请求下班;每个事务检查"还有别人在值班吗?有"并继续——结果没人值班。这是一个幻读(phantom):一个事务里的写改变另一个事务里搜索查询的结果。Snapshot isolation 不防写偏斜,因为两个事务读和写了不同的行、从未直接冲突。防御包括对被查询行显式加锁,或通过引入要锁的行来物化冲突(materializing conflicts)。
最常见的陷阱是假设 snapshot isolation(即 "repeatable read")对一切都安全。它防脏读/不可重复读和读偏斜,但不防写偏斜或幻读。若你的正确性依赖于对一组行的 check-then-act("只在没人订过这个时段时"),弱隔离最终会咬你——你需要可串行化或显式锁。
| 异常 | Read Committed | Snapshot Isolation | Serializable |
|---|---|---|---|
| 脏读 | 已防 | 已防 | 已防 |
| 脏写 | 已防 | 已防 | 已防 |
| 读偏斜(不可重复读) | 可能 | 已防 | 已防 |
| 丢失更新 | 可能 | 有时被检测 | 已防 |
| 写偏斜 / 幻读 | 可能 | 可能 | 已防 |
可串行化(Serializability)
可串行化隔离是最强的:它保证即便事务可能并发运行,最终结果与它们以某种顺序一次一个、串行运行相同。它排除上面所有竞态。有三种方式实现它。
实际串行执行
最简单的想法:字面上一次执行一个事务,在单线程上。这数十年不切实际,但两个变化使它可行:RAM 变得便宜到能把许多数据集完全放内存,且 OLTP 事务通常短而少。VoltDB 和 Redis 这样做。要让它工作你必须避免交互式多语句事务(网络往返会拖住单线程),所以事务作为存储过程提交,运行到完成。吞吐受限于单个 CPU 核,所以要扩展你分区数据——但跨分区事务随后需要协调且慢得多。
两阶段锁(2PL)
约 30 年里可串行化的标准算法。规则:读者和写者互相阻塞。要读一个对象你取一个共享锁;要写它你取一个排他锁;一个写必须等所有读者的共享锁释放,反之亦然(这是与 snapshot isolation 的区别,那里读者和写者从不阻塞)。锁持到事务结束。为防幻读,2PL 用谓词锁(predicate lock),或更实际地,覆盖一个可能匹配范围的索引范围锁(index-range lock)。2PL 正确但有真实缺点:大量锁开销、频繁死锁(事务互相等待,需检测和中止),以及高度可变、常很差的延迟。
可串行化快照隔离(SSI)
一个较新的算法(2008),以好得多的性能交付完整可串行化,被 PostgreSQL 的 serializable 级别和 FoundationDB 使用。它是乐观的:不在锁上阻塞,事务在一致快照上进行(像 snapshot isolation),数据库跟踪读和写。提交时它检查事务的读是否仍有效——即一个并发事务是否写了某物会改变这个事务的决定。若检测到一个危险的读-写依赖,数据库中止冒犯的事务并让它重试。SSI 在争用低时(少中止)表现好、避免 2PL 的锁争用,同时仍排除写偏斜和幻读。
事务用一点吞吐换应用复杂度的巨大降低。实用智慧:别在应用代码里重新发明隔离,确切知道你数据库默认给你哪个隔离级别(常是 read committed 或 snapshot isolation,不是 serializable),并在正确性依赖于对共享数据的并发决定时伸手去拿 serializable——理想是 SSI。
ACID 里的 C 真正意味什么?应用级不变量——它是应用的责任,不真是数据库保证,不像 A、I、D。
脏读 vs 不可重复读?脏读 = 看到未提交数据(read committed 阻止)。不可重复读/读偏斜 = 跨一个事务看到不一致值(snapshot isolation 阻止)。
Snapshot isolation 防写偏斜吗?不。两个读同一组、写不同行的事务能违反不变量;你需要可串行化或显式锁。
2PL vs SSI?2PL 是悲观的(读/写阻塞、死锁、慢);SSI 是乐观的(在快照上运行、提交时检测冲突、中止失败者),低争用下通常更快。