医院值班系统中,两位医生同时尝试放弃自己的值班班次。每个医生在操作前都会检查:是否至少还有另一位医生在值班?检查结果都显示"是",于是两人都放心地提交了更新。最终结果:当晚没有任何医生值班。

这不是代码 bug,而是数据库事务隔离级别设计中的一个经典陷阱——写倾斜(Write Skew)。更令人困惑的是,这个异常在 PostgreSQL 的"可重复读"隔离级别下会发生,但在 MySQL 的同名隔离级别下却不会。两个数据库都声称遵循 ANSI SQL 标准,为什么表现截然不同?

答案藏在数据库并发控制的核心设计中。事务隔离级别的标准定义存在根本性缺陷,而不同数据库的 MVCC 实现策略更是差异巨大。理解这些差异,是从"会用数据库"到"理解数据库"的关键一步。

三个读现象与不完整的标准

1992 年发布的 ANSI SQL-92 标准定义了四种事务隔离级别,通过禁止三种"读现象"来区分:

隔离级别 脏读 不可重复读 幻读
读未提交 允许 允许 允许
读已提交 禁止 允许 允许
可重复读 禁止 禁止 允许
可串行化 禁止 禁止 禁止

三种现象的含义如下:

脏读(Dirty Read):事务 A 读取了事务 B 未提交的数据。如果 B 回滚,A 读到的就是"不存在"的数据。

不可重复读(Non-repeatable Read):事务 A 两次读取同一行数据,得到不同的结果,因为事务 B 在两次读取之间修改并提交了该行。

幻读(Phantom Read):事务 A 执行相同的范围查询两次,得到不同的行数,因为事务 B 在两次查询之间插入或删除了符合条件的数据。

这个定义看起来清晰完整,但实际上存在严重问题。

Berenson 论文:标准的致命缺陷

1995 年,几位来自微软研究院和 DEC 的研究人员发表了一篇题为《A Critique of ANSI SQL Isolation Levels》的论文[1]。这篇论文揭示了一个惊人的事实:ANSI SQL 的隔离级别定义无法正确区分当时主流数据库实际提供的隔离级别

问题出在哪里?ANSI 标准用"禁止某些现象"来定义隔离级别,但禁止了这些现象并不等于获得了正确的隔离语义。

论文指出,除了三种已知现象外,还存在其他异常:

丢失更新(Lost Update):事务 A 和 B 同时读取同一行,各自修改后提交。B 的更新会覆盖 A 的更新,A 的修改"丢失"了。

写倾斜(Write Skew):两个事务读取相同的数据集,各自修改其中不同的部分,最终结果违反业务约束。开篇的医生值班案例就是典型例子。

论文还引入了一个重要概念:快照隔离(Snapshot Isolation)。这是一种在 ANSI 标准发布后才被学术界形式化的隔离级别,它禁止脏读、不可重复读和幻读,但允许写倾斜。

这导致了一个尴尬的局面:ANSI 标准说"可重复读允许幻读",但快照隔离在理论上禁止了幻读,却允许写倾斜。两者在标准定义下无法被清晰区分。

更复杂的是,快照隔离和可重复读是不可比较的——快照隔离禁止了幻读,但允许写倾斜;传统的可重复读(基于锁实现)禁止了写倾斜,但可能允许幻读。两者在隔离强度上"各有胜负"。

MVCC:读写不阻塞的艺术

现代数据库几乎都采用多版本并发控制(MVCC)来实现事务隔离。MVCC 的核心思想是:写操作不覆盖旧数据,而是创建新版本;读操作读取合适的历史版本

这带来了一个关键优势:读者不阻塞写者,写者不阻塞读者。传统基于锁的方案中,读操作需要获取共享锁,写操作需要获取排他锁,读写冲突会导致阻塞。MVCC 通过版本化消除了这种冲突。

但 MVCC 的实现方式,PostgreSQL 和 MySQL InnoDB 走了两条截然不同的道路。

PostgreSQL:元组版本化

PostgreSQL 采用**元组版本化(Tuple Versioning)**策略。当一行数据被更新时,PostgreSQL 不会修改原行,而是在表中插入一个全新的行版本:

+------------------+------------------+------------------+
|  旧版本 (dead)    |  新版本 (live)   |                  |
|  xmin=100        |  xmin=105        |                  |
|  xmax=105        |  xmax=0          |                  |
|  t_ctid=(5,2)    |  t_ctid=(5,2)    |                  |
+------------------+------------------+------------------+

每个元组头部包含三个关键字段:

  • xmin:创建该版本的事务 ID
  • xmax:删除/更新该版本的事务 ID(0 表示当前版本)
  • t_ctid:指向更新后的版本

事务通过比较自己的快照与 xmin/xmax 来判断哪个版本对自己可见。这种设计的优点是实现简单、回滚快速(只需标记事务状态),缺点是表膨胀(Table Bloat)——死元组占用空间,需要 VACUUM 进程定期清理。

MySQL InnoDB:Undo Log

InnoDB 采用Undo Log策略。更新操作会直接修改主表中的行(称为聚簇索引),同时将旧版本写入独立的 Undo Log:

主表(聚簇索引):
+----+--------+------------+--------------+
| id | name   | DB_TRX_ID  | DB_ROLL_PTR  |
+----+--------+------------+--------------+
| 1  | Alice  | 105        | → Undo Log   |
+----+--------+------------+--------------+

Undo Log:
+----+--------+------------+--------------+
| id | name   | DB_TRX_ID  | DB_ROLL_PTR  |
+----+--------+------------+--------------+
| 1  | Alice  | 100        | → 更早版本   |
+----+--------+------------+--------------+

每行包含两个隐藏列:

  • DB_TRX_ID:最后修改该行的事务 ID
  • DB_ROLL_PTR:指向 Undo Log 中该行上一版本的指针

当事务需要读取旧版本时,通过 DB_ROLL_PTR 在 Undo Log 中构建历史版本。这种设计的优点是主表紧凑,缺点是 Undo Log 可能因长事务而无限增长。

关键差异:索引更新的代价

两种实现方式最关键的差异在于索引更新:

PostgreSQL:由于每次更新都会创建一个物理位置完全不同的新元组,所有索引都必须更新,指向新的元组位置(Tuple ID)。即使更新的是非索引列,所有索引也需要添加新的索引项。唯一的优化是 HOT(Heap-Only Tuple)更新——当更新不涉及索引列且同一页面有空闲空间时,可以在页面内形成版本链,避免索引更新。

MySQL InnoDB:聚簇索引按主键组织,更新非主键列时,主键索引本身不需要更新(行的物理位置由主键决定)。只有涉及索引列的更新才需要修改对应的二级索引。

这意味着:在索引较多的表上,PostgreSQL 的更新开销显著高于 MySQL

幻读的两种解决思路

幻读问题涉及范围查询——如何在事务执行期间防止其他事务插入符合条件的新行?这是 ANSI 标准定义中最模糊的部分。

MySQL:Next-Key Lock

InnoDB 在可重复读隔离级别下使用 Next-Key Lock 来防止幻读。Next-Key Lock 是记录锁(Record Lock)和间隙锁(Gap Lock)的组合:

假设索引包含值 10、11、13、20,Next-Key Lock 会锁定以下区间:

(负无穷, 10], (10, 11], (11, 13], (13, 20], (20, 正无穷)

当事务执行 SELECT * FROM t WHERE id > 15 FOR UPDATE 时,不仅锁定现有的 id=20,还会锁定 (13, 20] 和 (20, 正无穷) 两个区间,防止其他事务在这个范围内插入新行。

间隙锁是一种"纯抑制性"锁——它的唯一目的是阻止插入,不同事务可以在同一个间隙上持有冲突的间隙锁。这避免了不必要的阻塞,但可能导致死锁。

PostgreSQL:快照隔离天然解决

PostgreSQL 的可重复读隔离级别基于快照隔离实现。事务开始时获取一个数据库快照,整个事务期间都从这个快照读取数据。其他事务插入的新行对当前事务不可见——幻读天然被禁止

但这里有一个关键区别:PostgreSQL 的"可重复读"实际上提供了比 ANSI 标准更强的保证。ANSI 标准说可重复读"允许幻读",但 PostgreSQL 的实现禁止了幻读。这是合规的——标准规定的是"必须禁止的现象",额外禁止更多现象是允许的。

写倾斜:快照隔离的阿喀琉斯之踵

快照隔离禁止了幻读,但无法阻止写倾斜。回到医生值班的例子:

初始状态:Alice 和 Bob 都在值班

事务 T1 (Bob):                    事务 T2 (Alice):
BEGIN;                            BEGIN;
SELECT count(*) FROM doctors      SELECT count(*) FROM doctors
WHERE on_call = true;             WHERE on_call = true;
-- 返回 2                          -- 返回 2
                                  
UPDATE doctors SET on_call=false  UPDATE doctors SET on_call=false
WHERE name='Bob';                 WHERE name='Alice';
                                  
COMMIT;                           COMMIT;

最终状态:无人值班!

问题在于:两个事务的写集合(修改的行)不重叠,快照隔离允许它们并发提交。但它们的决策依赖于对方的读取结果——这就是 rw-conflict(读写冲突)。

串行化图分析

学术界用串行化图来分析事务依赖关系:

  • wr-dependency:T1 写入,T2 读取(T1 → T2)
  • ww-dependency:T1 写入,T2 覆盖写入(T1 → T2)
  • rw-conflict:T1 读取,T2 写入并覆盖(T1 → T2,但顺序相反)

写倾斜的串行化图如下:

T1 --rw--> T2 --rw--> T1
(形成环)

关键定理:在快照隔离下,如果串行化图存在环,则环中必有两条连续的 rw-conflict 边[2]。

这个定理是 PostgreSQL 实现可串行化的理论基础。

可串行化的代价:SSI vs S2PL

ANSI 标准将可串行化定义为最高隔离级别——事务执行结果等价于某种串行执行。实现可串行化有两种主流方式:

Strict Two-Phase Locking (S2PL)

传统方案:读操作获取共享锁,写操作获取排他锁,所有锁在事务提交时释放。

优点:实现简单,保证可串行化。 缺点:读写互相阻塞,高并发下性能差;容易死锁。

Serializable Snapshot Isolation (SSI)

PostgreSQL 从 9.1 版本开始实现的方案:运行快照隔离,同时监控 rw-conflict,当检测到"危险结构"(两条连续的 rw-conflict 边)时,中止其中一个事务。

危险结构检测:

T1 --rw--> T2 --rw--> T3
         ↑
    检测到两条连续 rw 边
    中止 T2 或 T3

SSI 的优点是读写不阻塞,性能接近快照隔离。缺点是有"误杀"——某些本可成功的事务被中止,需要应用层重试。

实践中的选择

理解隔离级别的真实含义后,如何在实践中做出选择?

读已提交(PostgreSQL 默认):适合大多数 Web 应用。每次语句执行时获取新快照,能读到最新提交的数据。但要注意丢失更新问题——两个事务同时读取-修改-写入同一行会互相覆盖。解决方案:使用 SELECT ... FOR UPDATE 或乐观锁(版本号检查)。

可重复读(MySQL 默认):适合需要事务内一致性读的场景。MySQL 通过 Next-Key Lock 防止幻读,但这是"悲观"方案——范围查询会锁定间隙,可能影响并发插入性能。PostgreSQL 的可重复读基于快照,不阻塞任何操作,但存在写倾斜风险。

可串行化:适合对数据一致性要求极高的场景(金融、医疗)。PostgreSQL 的 SSI 实现代价相对较低,但需要应用层处理序列化失败错误(SQLSTATE 40001)并重试。MySQL 的可串行化通过锁实现,对性能影响更大。

一个容易被忽视的细节:在 PostgreSQL 可重复读隔离级别下,更新操作遇到并发修改会直接报错回滚,而不是像读已提交那样等待并重新评估。这是因为快照隔离要求事务看到一致的数据视图,不能中途"更新"快照。

结语

事务隔离级别的复杂性源于一个根本矛盾:我们希望并发执行获得性能,同时希望每个事务"看起来"像在独占数据库。这个矛盾无法完美调和,只能在一致性和性能之间权衡。

ANSI SQL 标准试图用"禁止某些现象"来定义隔离级别,但这个定义是不完整的——它遗漏了写倾斜等异常,也无法区分快照隔离和可重复读。不同数据库厂商根据自身架构做出了不同选择:MySQL 用锁来阻止幻读,PostgreSQL 用快照来避免读取阻塞,各自都有合理之处。

真正理解隔离级别,需要超越标准定义,深入 MVCC 的实现细节。PostgreSQL 的元组版本化导致表膨胀但回滚快速,InnoDB 的 Undo Log 保持主表紧凑但依赖后台清理。这些实现差异直接影响性能特性和运维要求。

当你下次看到"可重复读"四个字时,请记住:这在不同数据库中意味着完全不同的行为。标准只是一个模糊的框架,真正的语义由具体实现决定。


参考文献

[1] Berenson, H., Bernstein, P., Gray, J., et al. “A Critique of ANSI SQL Isolation Levels.” SIGMOD 1995.

[2] Fekete, A., Liarokapis, D., O’Neil, E., O’Neil, P., & Shasha, D. “Making Snapshot Isolation Serializable.” ACM TODS 2005.

[3] Ports, D. R. K., & Grittner, K. “Serializable Snapshot Isolation in PostgreSQL.” VLDB 2012.

[4] PostgreSQL Documentation: Chapter 13. Concurrency Control.

[5] MySQL Reference Manual: Chapter 17. InnoDB Storage Engine.

[6] Cahill, A., Röhm, U., & Fekete, A. “Serializable Isolation for Snapshot Databases.” SIGMOD 2008.

[7] Pavlo, A. “CMU 15-445/645 Database Systems: Lecture Notes - Multi-Version Concurrency Control.” Carnegie Mellon University.