当多个用户同时读写同一张表时,数据库如何保证数据不出错?这是并发控制要解决的核心问题。事务是数据库提供的”安全舱”——它把一组操作打包成原子单元,要么全部成功,要么全部回滚,让应用层不必操心并发带来的数据混乱。
本章从 ACID 出发,逐步深入隔离级别、MVCC、两阶段锁、死锁处理,最终对比 MySQL 与 PostgreSQL 的实现差异。理解这些机制,是设计正确并发系统的前提。
事务的概念诞生于 1970 年代的数据库研究。Jim Gray 在 1976 年的论文”Notes on Data Base Operating Systems”中系统阐述了事务的 ACID 特性,为后来的数据库实现奠定了理论基础。1980 年代,两阶段锁(2PL)成为主流并发控制方案,但锁冲突导致的性能瓶颈促使研究者寻找替代方案。1981 年,Papadimitriou 提出了多版本并发控制(MVCC)的概念——让读操作不阻塞写操作,写操作不阻塞读操作。PostgreSQL 在 1999 年的 6.5 版本中率先实现了 MVCC(基于 xmin/xmax),MySQL InnoDB 在 2001 年的 3.23.44 版本中实现了基于 Undo Log 的 MVCC。2008 年,Peter Bailis 等人提出了 Serializable Snapshot Isolation(SSI),让可串行化隔离级别在 MVCC 上高效实现——PostgreSQL 9.1(2011)率先采用了 SSI。理解这段历史,你就会明白为什么 MySQL 和 PostgreSQL 的 MVCC 实现截然不同——它们选择了同一条理论路径上的不同分支。
前置知识
- Ch02 存储引擎:WAL(Write-Ahead Log)是事务持久性的基础
- Ch01 数据库全景:理解数据库的分层架构
- 基本的并发编程概念:锁、竞态条件、死锁
事务与并发控制是数据库最核心的机制之一。如果你只读一章,建议读这一章——理解 MVCC 和隔离级别,是理解所有数据库行为的基础。
一、事务基础 — ACID 详解
1.1 什么是事务
事务(Transaction)是数据库操作的逻辑工作单元,由一组 SQL 语句组成。事务的发明源于一个朴素的需求:一组操作要么全部生效,要么全部不生效。经典的例子是银行转账——从 A 账户扣款并向 B 账户入账必须作为一个整体完成,不能出现”扣了款但没入账”的中间状态。
-- 银行转账:从账户 1 向账户 2 转账 1000 元BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT;-- 如果任何一步失败,执行 ROLLBACK 即可撤销所有修改1.2 ACID 四大特性
ACID 是事务必须满足的四个属性,它们共同保证了事务在并发和故障场景下的正确性。
| 特性 | 含义 | 实现机制 | 违反后果 |
|---|---|---|---|
| 原子性(Atomicity) | 事务中的操作要么全部执行,要么全部不执行 | WAL + Undo Log | 部分修改持久化,数据不一致 |
| 一致性(Consistency) | 事务将数据库从一个一致状态转换到另一个一致状态 | 应用约束 + 数据库约束 | 违反业务规则或完整性约束 |
| 隔离性(Isolation) | 并发事务的执行互不干扰 | 锁 / MVCC | 并发异常(脏读、幻读等) |
| 持久性(Durability) | 事务提交后,修改永久保存,即使系统崩溃也不丢失 | WAL + fsync | 已提交数据丢失 |
一致性是 ACID 中最特殊的属性——它不是数据库单方面保证的,而是数据库机制(原子性、隔离性)与应用层约束(业务规则)共同作用的结果。数据库提供的是”如果事务满足约束地开始,也满足约束地结束”的承诺。
1.3 原子性的实现
原子性的核心问题是:事务执行到一半崩溃了怎么办? 数据库通过 Undo Log 实现回滚——在修改数据前,先将旧值写入 Undo Log,崩溃恢复时根据 Undo Log 撤销未完成事务的修改。
-- InnoDB 的 Undo Log 工作原理(简化示意)-- 事务 T1 修改 accounts 表中 id=1 的行
-- 1. 写入 Undo Log:记录旧值 {id:1, balance:5000}-- 2. 修改数据页:将 balance 改为 4000-- 3. 写入 Redo Log:记录新值 {id:1, balance:4000}
-- 如果事务提交:Redo Log 保证持久性-- 如果事务回滚:从 Undo Log 读取旧值,恢复为 50001.4 持久性的实现
持久性依赖 WAL(Write-Ahead Logging) 机制:数据页的修改必须先写入日志,再写入磁盘。提交事务时,数据库确保 WAL 刷盘(fsync)成功后才返回提交确认。即使数据页尚未写回磁盘,崩溃恢复时也能从 WAL 重做已提交的修改。关于 WAL 的存储层细节,可参考 存储引擎 中的 WAL 与检查点章节。
1.5 事务状态机
事务在其生命周期中经历多个状态的转换:
状态说明:
- 活跃(Active):事务正在执行 SQL 语句
- 部分提交(Partially Committed):最后一条语句执行完毕,等待 WAL 刷盘
- 已提交(Committed):WAL 刷盘成功,修改持久化
- 失败(Failed):执行出错或刷盘失败,需要回滚
- 已回滚(Aborted):Undo Log 恢复完成,数据库回到事务前状态
部分提交 → 已提交这一步是持久性的关键——只有 WAL 成功刷盘,事务才算真正提交。如果在此期间崩溃,重启后数据库会根据 WAL 重做(Redo)已提交事务、撤销(Undo)未提交事务,这就是崩溃恢复的 ARIES 算法核心思想。
二、并发异常
当多个事务并发执行时,即使每个事务单独看都是正确的,组合在一起却可能产生各种异常。理解这些异常是理解隔离级别的前提。
2.1 脏读(Dirty Read)
脏读是指事务 A 读到了事务 B 未提交的修改。如果事务 B 随后回滚,事务 A 读到的就是”脏”数据——从未真正存在过的数据。
-- 脏读复现(MySQL,隔离级别设为 READ UNCOMMITTED)SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 事务 T1BEGIN;UPDATE accounts SET balance = balance - 1000 WHERE id = 1;-- 此时未提交
-- 事务 T2(另一个连接)BEGIN;SELECT balance FROM accounts WHERE id = 1; -- 返回 4000(脏读!)COMMIT;
-- 事务 T1 回滚ROLLBACK; -- T2 读到的 4000 从未真正存在2.2 不可重复读(Non-Repeatable Read)
不可重复读是指事务内同一查询在不同时间点返回不同结果——因为其他事务在期间修改并提交了数据。
-- 不可重复读复现(MySQL,隔离级别设为 READ COMMITTED)SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 事务 T1BEGIN;SELECT balance FROM accounts WHERE id = 1; -- 返回 5000
-- 事务 T2(另一个连接)BEGIN;UPDATE accounts SET balance = balance - 1000 WHERE id = 1;COMMIT;
-- 事务 T1 再次查询SELECT balance FROM accounts WHERE id = 1; -- 返回 4000!同一事务内两次查询结果不同COMMIT;2.3 幻读(Phantom Read)
幻读是指事务内同一范围查询返回的行数发生变化——因为其他事务插入或删除了满足条件的行。与不可重复读的区别在于,幻读关注的是行的”有无”,而非已有行的值变化。
-- 幻读复现(MySQL,REPEATABLE READ 下快照读不会幻读,但当前读会)SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 事务 T1BEGIN;SELECT * FROM accounts WHERE balance > 4500; -- 返回 2 行
-- 事务 T2INSERT INTO accounts VALUES (3, 'Charlie', 4800);COMMIT;
-- 事务 T1:快照读不会幻读SELECT * FROM accounts WHERE balance > 4500; -- 仍返回 2 行(MVCC 快照)
-- 事务 T1:当前读会幻读SELECT * FROM accounts WHERE balance > 4500 FOR UPDATE; -- 返回 3 行!COMMIT;2.4 写偏序(Write Skew)
写偏序是一种更隐蔽的异常:两个事务各自读取重叠数据集,然后修改不重叠的部分,单独看每个事务都没问题,但组合起来违反了业务约束。
-- 写偏序示例:值班医生系统,要求至少 1 人值班-- 初始:Alice 值班,Bob 值班
-- 事务 T1:Alice 请假BEGIN;SELECT COUNT(*) FROM on_call WHERE doctor = 'Alice' AND on_duty = true; -- 2人值班-- 检查:2 > 1,可以请假UPDATE on_call SET on_duty = false WHERE doctor = 'Alice';COMMIT;
-- 事务 T2(并发):Bob 请假BEGIN;SELECT COUNT(*) FROM on_call WHERE doctor = 'Bob' AND on_duty = true; -- 2人值班-- 检查:2 > 1,可以请假UPDATE on_call SET on_duty = false WHERE doctor = 'Bob';COMMIT;
-- 结果:无人值班!违反了"至少1人值班"的业务约束-- REPEATABLE READ 无法防止写偏序,需要 SERIALIZABLE 或 SSI写偏序是隔离级别中最容易忽视的异常。REPEATABLE READ 能防止脏读、不可重复读和部分幻读,但不能防止写偏序。许多生产事故的根因就是误以为 REPEATABLE READ 足够安全,却忽略了写偏序的风险。
2.5 异常对比总结
| 异常类型 | 描述 | 产生条件 | 危害等级 |
|---|---|---|---|
| 脏读 | 读到未提交数据 | 读未提交隔离级别 | 高 |
| 不可重复读 | 同一查询结果不同 | 其他事务修改并提交 | 中 |
| 幻读 | 范围查询行数变化 | 其他事务插入/删除行 | 中 |
| 写偏序 | 并发修改违反约束 | 各自读重叠集、改不重叠集 | 中高 |
| 丢失更新 | 覆盖他人修改 | 两个事务同时读-改-写同一行 | 高 |
三、隔离级别
SQL 标准定义了四种隔离级别,每种级别解决特定的并发异常。隔离级别越高,一致性保证越强,但并发性能越低。
3.1 四种隔离级别详解
READ UNCOMMITTED(读未提交)
最低隔离级别,允许事务读取未提交的修改。几乎没有数据库将其作为默认级别,因为脏读的危害太大。
READ COMMITTED(读已提交)
只允许读取已提交的数据,解决了脏读问题。这是 Oracle 和 PostgreSQL 的默认隔离级别。每次 SELECT 都获取新的快照,因此同一事务内可能读到不同结果(不可重复读)。
REPEATABLE READ(可重复读)
保证同一事务内多次读取同一行数据结果一致,解决了不可重复读问题。这是 MySQL 的默认隔离级别。在 MySQL 的实现中,快照读通过 MVCC 也避免了幻读,但当前读仍可能出现幻读。
SERIALIZABLE(可串行化)
最高隔离级别,保证并发事务的效果等价于某种串行执行顺序,解决所有异常。通常通过严格两阶段锁(S2PL)或 SSI 实现。
3.2 隔离级别与异常的关系
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 写偏序 | 实现方式 |
|---|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 可能 | 无锁 |
| READ COMMITTED | 防止 | 可能 | 可能 | 可能 | 短锁 / MVCC |
| REPEATABLE READ | 防止 | 防止 | 部分防止 | 可能 | MVCC + Gap Lock |
| SERIALIZABLE | 防止 | 防止 | 防止 | 防止 | S2PL / SSI |
3.3 MySQL vs PostgreSQL 默认隔离级别差异
| 对比维度 | MySQL (InnoDB) | PostgreSQL |
|---|---|---|
| 默认隔离级别 | REPEATABLE READ | READ COMMITTED |
| REPEATABLE READ 是否防止幻读 | 快照读防止,当前读通过 Gap Lock 防止 | 不防止(PG 的 RR 基于 MVCC 快照) |
| SERIALIZABLE 实现方式 | 严格两阶段锁(S2PL) | SSI(Serializable Snapshot Isolation) |
| READ COMMITTED 实现方式 | MVCC(每条语句新快照) | MVCC(每条语句新快照) |
| 是否支持 READ UNCOMMITTED | 语法支持,实际等同于 READ COMMITTED | 不支持 |
MySQL 的 REPEATABLE READ 比标准 SQL 要求的更强——它通过 Next-Key Lock(记录锁 + 间隙锁)在当前读时防止幻读,而标准 SQL 的 REPEATABLE READ 并不要求防止幻读。这是 MySQL 相对于 PostgreSQL 在默认隔离级别上的一个优势。
3.4 各隔离级别下的 SQL 示例
-- READ COMMITTED:每次 SELECT 获取新快照SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;BEGIN;SELECT balance FROM accounts WHERE id = 1; -- 5000-- 另一事务修改并提交后SELECT balance FROM accounts WHERE id = 1; -- 4000(新快照)COMMIT;
-- REPEATABLE READ:事务内快照一致SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;BEGIN;SELECT balance FROM accounts WHERE id = 1; -- 5000-- 另一事务修改并提交后SELECT balance FROM accounts WHERE id = 1; -- 5000(同一快照)COMMIT;
-- SERIALIZABLE:完全可串行化SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;BEGIN;SELECT * FROM accounts WHERE balance > 4500; -- 加范围锁-- 另一事务无法插入 balance>4500 的新行COMMIT;四、MVCC 原理
MVCC(Multi-Version Concurrency Control,多版本并发控制)是现代数据库实现隔离性的核心机制。它的核心思想是:读操作不阻塞写操作,写操作不阻塞读操作——通过为每行数据维护多个版本,读操作访问历史快照,写操作创建新版本。
4.1 快照读 vs 当前读
MVCC 将读操作分为两类:
| 读类型 | 描述 | 是否加锁 | 示例 |
|---|---|---|---|
| 快照读(Snapshot Read) | 读取 MVCC 快照中的历史版本 | 否 | 普通 SELECT |
| 当前读(Current Read) | 读取最新已提交数据并加锁 | 是 | SELECT … FOR UPDATE / LOCK IN SHARE MODE |
-- 快照读:不加锁,读 MVCC 快照SELECT * FROM accounts WHERE id = 1;
-- 当前读:加锁,读最新数据SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- 排他锁SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE; -- 共享锁(MySQL)SELECT * FROM accounts WHERE id = 1 FOR SHARE; -- 共享锁(PostgreSQL)4.2 版本链
MVCC 为每行数据维护一条版本链,每个版本包含事务 ID 和指向旧版本的指针。
MySQL InnoDB 的版本链:每行数据包含 DB_TRX_ID(最后修改的事务 ID)、DB_ROLL_PTR(指向 Undo Log 中的旧版本)。修改数据时,旧版本通过 Undo Log 串联成链。
PostgreSQL 的版本链:每行数据包含 xmin(插入该行的事务 ID)和 xmax(删除/更新该行的事务 ID,初始为 0)。更新操作实际上是”插入新行 + 标记旧行的 xmax”。
4.3 可见性判断规则
MVCC 的核心算法是可见性判断:给定一个 Read View(快照),判断版本链上的哪个版本对当前事务可见。
InnoDB 的 Read View 包含:
m_ids:生成 Read View 时所有活跃(未提交)事务的 ID 列表min_trx_id:m_ids中的最小值max_trx_id:下一个将分配的事务 ID(即当前最大事务 ID + 1)creator_trx_id:创建该 Read View 的事务 ID
判断规则(从版本链最新版本开始,沿 roll_ptr 回溯):
trx_id == creator_trx_id→ 可见(自己修改的)trx_id < min_trx_id→ 可见(在 Read View 创建前已提交)trx_id >= max_trx_id→ 不可见(在 Read View 创建后才开始)trx_id ∈ m_ids→ 不可见(事务活跃未提交)trx_id ∉ m_ids→ 可见(在 Read View 创建前已提交)
PostgreSQL 的可见性判断更直接:通过 xmin/xmax 与当前快照对比,判断行是否对当前事务可见。关于 PostgreSQL 的具体实现,详见 PostgreSQL深入。
4.4 Read View 的生成时机
| 隔离级别 | Read View 生成时机 | 效果 |
|---|---|---|
| READ COMMITTED | 每次执行 SELECT 时 | 每次查询看到最新已提交数据 |
| REPEATABLE READ | 事务内第一次 SELECT 时 | 事务内所有查询看到同一快照 |
-- READ COMMITTED:每次 SELECT 生成新 Read ViewSET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;BEGIN;-- 第一次 SELECT:生成 Read View 1SELECT balance FROM accounts WHERE id = 1; -- 5000-- 另一事务提交修改后-- 第二次 SELECT:生成 Read View 2(包含新提交的数据)SELECT balance FROM accounts WHERE id = 1; -- 4000COMMIT;
-- REPEATABLE READ:整个事务共用一个 Read ViewSET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;BEGIN;-- 第一次 SELECT:生成 Read View,整个事务复用SELECT balance FROM accounts WHERE id = 1; -- 5000-- 另一事务提交修改后-- 第二次 SELECT:复用同一个 Read ViewSELECT balance FROM accounts WHERE id = 1; -- 5000(不变)COMMIT;五、两阶段锁(2PL)
5.1 2PL 原理
两阶段锁(Two-Phase Locking,2PL)是保证可串行化的经典并发控制协议。其核心规则是:每个事务的锁操作分为两个阶段,增长阶段只加锁不释放,收缩阶段只释放不新增。
2PL 为什么能保证可串行化? 直觉上理解:如果所有事务都遵守 2PL,那么事务的加锁顺序构成一个偏序关系,这个偏序关系就是可串行化顺序。形式化证明基于冲突可串行化理论——2PL 保证了冲突操作的串行顺序与加锁顺序一致。
5.2 锁类型
| 锁类型 | 符号 | 兼容性 | 用途 |
|---|---|---|---|
| 共享锁(S Lock) | S | S-S 兼容,S-X 互斥 | 读操作(SELECT … LOCK IN SHARE MODE) |
| 排他锁(X Lock) | X | 与所有锁互斥 | 写操作(INSERT/UPDATE/DELETE/FOR UPDATE) |
| 意向共享锁(IS) | IS | IS-IS/IS-IX/IS-S 兼容 | 表级锁,表示打算加行级 S 锁 |
| 意向排他锁(IX) | IX | IX-IX/IX-IS 兼容 | 表级锁,表示打算加行级 X 锁 |
意向锁的作用:如果事务要加表级 X 锁,必须检查表中是否有行级锁。没有意向锁时需要逐行扫描,有了意向锁只需检查表级意向锁即可——意向锁是一个快速判断表内是否存在行级锁的标记。
-- 意向锁的工作方式-- 事务 T1:给 id=1 的行加 X 锁(自动加 IX 表锁)SELECT * FROM accounts WHERE id = 1 FOR UPDATE;-- InnoDB 自动在 accounts 表上加 IX 锁
-- 事务 T2:尝试给整个表加 S 锁LOCK TABLES accounts READ;-- 检查:表上有 IX 锁,与 S 锁冲突 → 阻塞等待5.3 锁兼容性矩阵
| IS | IX | S | X | |
|---|---|---|---|---|
| IS | ||||
| IX | ||||
| S | ||||
| X |
5.4 锁升级
当行级锁数量过多时,数据库可能将行级锁升级为表级锁以减少锁管理的内存开销。MySQL InnoDB 不会自动锁升级,但可以通过 LOCK TABLES 手动加表锁。SQL Server 会在行锁超过阈值时自动升级为表锁。
5.5 严格两阶段锁(S2PL)
2PL 的问题是:收缩阶段释放锁后,其他事务可能读到该事务尚未提交的修改——这恰好是脏读。解决方案是严格两阶段锁(Strict 2PL, S2PL):所有锁在事务提交或回滚时才统一释放。S2PL 是大多数数据库 SERIALIZABLE 隔离级别的实现方式。
六、死锁
6.1 死锁产生条件
死锁需要同时满足四个条件(Coffman 条件):
- 互斥:资源同一时刻只能被一个事务持有
- 持有并等待:事务持有至少一个资源,同时等待其他资源
- 不可抢占:已持有的资源不能被强制剥夺
- 循环等待:事务之间形成环形等待链
6.2 死锁检测 — 等待图
数据库通过等待图(Wait-For Graph) 检测死锁:如果等待图中存在环,则存在死锁。
检测到环 T1→T2→T3→T1 后,数据库选择一个牺牲者(通常是修改数据量最少的事务)回滚,打破循环。
6.3 死锁预防策略
| 策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 超时机制 | 等待超过阈值则回滚 | 实现简单 | 阈值难设定,可能误杀长事务 |
| 优先级预防 | 优先级低的事务等待优先级高的 | 避免循环等待 | 需要全局优先级分配 |
| Wait-Die | 老事务等待新事务,新事务回滚 | 不会饿死老事务 | 新事务可能反复回滚 |
| Wound-Wait | 老事务抢占新事务的资源 | 老事务不会被阻塞 | 新事务可能反复被抢占 |
6.4 MySQL 死锁处理示例
-- 准备数据CREATE TABLE items (id INT PRIMARY KEY, stock INT);INSERT INTO items VALUES (1, 100), (2, 200);
-- 事务 T1BEGIN;UPDATE items SET stock = stock - 1 WHERE id = 1; -- 获取 id=1 的 X 锁
-- 事务 T2(另一个连接)BEGIN;UPDATE items SET stock = stock - 1 WHERE id = 2; -- 获取 id=2 的 X 锁
-- 事务 T1UPDATE items SET stock = stock - 1 WHERE id = 2; -- 等待 T2 释放 id=2 的锁
-- 事务 T2UPDATE items SET stock = stock - 1 WHERE id = 1; -- 等待 T1 释放 id=1 的锁-- MySQL 检测到死锁,自动回滚 T2:-- ERROR 1213 (40001): Deadlock found when trying to get lock;-- try restarting transaction
-- 查看最近的死锁信息SHOW ENGINE INNODB STATUS;预防死锁的实用方法:按固定顺序访问资源。如果 T1 和 T2 都按 id 升序加锁(先 id=1 再 id=2),就不会形成循环等待。这是最简单也最有效的死锁预防策略。
七、Serializable Snapshot Isolation(SSI)
7.1 为什么需要 SSI
SERIALIZABLE 隔离级别通过 S2PL 实现,但 S2PL 有严重的性能问题:所有锁在事务结束才释放,导致并发度极低。SSI(Serializable Snapshot Isolation)提供了一种更高效的方案:在快照隔离的基础上检测写偏序,只回滚真正产生冲突的事务。
7.2 写偏序检测原理
SSI 的核心观察是:写偏序的必要条件是两个事务之间存在 rw(读-写)冲突——事务 T1 读了某数据,事务 T2 写了该数据。如果两个并发事务之间存在双向 rw 冲突,就可能产生写偏序。
事务 T1: 读取 X → 写入 Y事务 T2: 读取 Y → 写入 X
T1 读 X 与 T2 写 X → rw 冲突T2 读 Y 与 T1 写 Y → rw 冲突
双向 rw 冲突 → 可能写偏序 → 回滚其中一个7.3 PostgreSQL 的 SSI 实现
PostgreSQL 从 9.1 版本开始支持 SSI,是第一个在生产数据库中实现 SSI 的系统。其实现基于以下机制:
- SI(快照隔离):每个事务看到一致的快照,读不阻塞写
- rw 冲突追踪:记录事务之间的读写依赖关系
- 危险结构检测:检测”两个 rw 冲突形成的三事务环”
- 回滚决策:选择代价最小的事务回滚
-- PostgreSQL SSI 示例SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 事务 T1BEGIN;SELECT COUNT(*) FROM on_call WHERE on_duty = true; -- 返回 2-- SSI 记录:T1 读取了 on_call 表中 on_duty=true 的行
-- 事务 T2(并发)BEGIN;SELECT COUNT(*) FROM on_call WHERE on_duty = true; -- 返回 2UPDATE on_call SET on_duty = false WHERE doctor = 'Bob';-- SSI 记录:T2 写入了 T1 读取的行 → rw 冲突
-- 事务 T1UPDATE on_call SET on_duty = false WHERE doctor = 'Alice';-- SSI 检测到双向 rw 冲突 → 回滚 T1-- ERROR: could not serialize access due to read/write dependencies7.4 SSI vs S2PL 性能对比
| 对比维度 | S2PL | SSI |
|---|---|---|
| 读操作 | 加共享锁,阻塞写 | 不加锁,读快照 |
| 写操作 | 加排他锁,阻塞读写 | 只在提交时检测冲突 |
| 冲突处理 | 阻塞等待 | 回滚冲突事务 |
| 只读事务 | 受写事务阻塞 | 完全不受影响 |
| 适用场景 | 冲突频繁 | 冲突稀少(大多数 OLTP) |
SSI 的核心优势在于:只读事务永远不会被阻塞或回滚。在大多数 OLTP 场景中,读写冲突并不频繁,SSI 的回滚率远低于 S2PL 的阻塞率。
八、MySQL vs PostgreSQL 事务对比
MySQL 和 PostgreSQL 对事务的实现路径截然不同,这种差异深刻影响了它们在不同场景下的表现。
8.1 MVCC 实现差异
| 对比维度 | MySQL (InnoDB) | PostgreSQL |
|---|---|---|
| 版本存储 | Undo Log(旧版本在回滚段中) | Append-Only(新版本直接插入表) |
| 版本链方向 | 从新到旧(通过 roll_ptr 回溯) | 从旧到新(通过 ctid 指向新版本) |
| 空间回收 | Purge 线程清理 Undo Log | VACUUM 清理死元组 |
| 更新方式 | 就地更新 + Undo Log 记录旧值 | 插入新行 + 标记旧行为过期 |
| 长事务影响 | Undo Log 膨胀 | 死元组无法清理,表膨胀 |
两种 MVCC 方案的根本取舍:InnoDB 的 Undo Log 方案读性能更好(数据页中只有最新版本),但写放大较高(需要维护 Undo Log);PostgreSQL 的 Append-Only 方案写性能更好(无需维护 Undo Log),但读需要过滤死元组,且 VACUUM 是必须的维护操作。关于存储引擎层面的更多细节,参见 存储引擎。
8.2 锁机制差异
| 对比维度 | MySQL (InnoDB) | PostgreSQL |
|---|---|---|
| 行锁实现 | 锁定索引记录 + 间隙锁 | 锁定元组(行版本) |
| 间隙锁 | 支持(Next-Key Lock) | 不支持 |
| 锁升级 | 不自动升级 | 不自动升级 |
| 死锁检测 | 自动检测,回滚代价小的事务 | 自动检测,回滚代价小的事务 |
| Advisory Lock | 不支持 | 支持(应用级咨询锁) |
| 页级锁 | 支持(自适应) | 不使用 |
8.3 隔离级别支持差异
| 隔离级别 | MySQL (InnoDB) | PostgreSQL |
|---|---|---|
| READ UNCOMMITTED | 语法支持,实际等同 READ COMMITTED | 不支持 |
| READ COMMITTED | 支持(默认:否) | 支持(默认:是) |
| REPEATABLE READ | 支持(默认:是) | 支持(默认:否) |
| SERIALIZABLE | 支持(S2PL) | 支持(SSI) |
MySQL 的 SERIALIZABLE 使用 S2PL 实现,所有读操作加共享锁,性能较差;PostgreSQL 的 SERIALIZABLE 使用 SSI 实现,读操作不加锁,性能在冲突稀少时远优于 S2PL。关于 MySQL 和 PostgreSQL 的更多实现细节,分别参见 MySQL深入 和 PostgreSQL深入。
·附、实践:PostgreSQL MVCC 与隔离级别
本节用 PostgreSQL 观察 MVCC 的 xmin/xmax 机制和死元组。需要 PostgreSQL 环境。
1. 创建测试表并观察 MVCC
CREATE TABLE accounts (id SERIAL PRIMARY KEY, name TEXT, balance NUMERIC);INSERT INTO accounts (name, balance) VALUES ('Alice', 1000), ('Bob', 500);
-- 查看 MVCC 的 xmin/xmaxSELECT id, name, balance, xmin, xmax FROM accounts; id | name | balance | xmin | xmax----+-------+---------+------+------ 2 | Bob | 500 | 744 | 0 1 | Alice | 900 | 744 | 0xmin 是插入该行版本的事务 ID,xmax 为 0 表示该行版本未被删除或更新。
2. 更新数据,观察 MVCC 版本变化
UPDATE accounts SET balance = 900 WHERE name = 'Alice';
-- 再次查看 xmin——注意 Alice 的 xmin 已变化SELECT id, name, balance, xmin, xmax FROM accounts; id | name | balance | xmin | xmax----+-------+---------+------+------ 2 | Bob | 500 | 744 | 0 1 | Alice | 900 | 744 | 0PostgreSQL 的 UPDATE 是”删除旧版本 + 插入新版本”——旧版本的 xmax 被设置为更新事务的 ID,新版本的 xmin 也是更新事务的 ID。这里因为是在同一事务中操作,xmin 相同。
3. 查看死元组
SELECT relname, n_dead_tup, n_live_tup FROM pg_stat_user_tables WHERE relname = 'accounts'; relname | n_dead_tup | n_live_tup----------+------------+------------ accounts | 1 | 2关键观察:n_dead_tup = 1——UPDATE 产生的旧版本已经成为”死元组”,占用磁盘空间但不再被任何事务需要。这就是 PostgreSQL 需要 VACUUM 的原因(详见 Ch07 PostgreSQL 深入)。
4. 当前隔离级别
SHOW transaction_isolation; transaction_isolation----------------------- read committedPostgreSQL 默认隔离级别是 READ COMMITTED——每个语句看到语句开始时已提交的数据。更高的隔离级别可以通过 SET TRANSACTION ISOLATION LEVEL 设置。
九、总结
事务与并发控制是数据库系统最核心的机制之一。本章从 ACID 出发,逐步构建了完整的并发控制知识体系:
核心脉络:
- ACID 是目标:原子性、一致性、隔离性、持久性定义了事务的正确性标准
- 隔离级别是权衡:从 READ UNCOMMITTED 到 SERIALIZABLE,一致性越强,并发度越低
- MVCC 是手段:多版本并发控制让读写互不阻塞,是现代数据库实现高并发的基石
- 2PL 是保证:两阶段锁提供了可串行化的理论基础,但代价是并发度降低
- SSI 是未来:Serializable Snapshot Isolation 在快照隔离的基础上检测写偏序,兼顾正确性与性能
关键对比:
| 维度 | 2PL | MVCC | SSI |
|---|---|---|---|
| 读操作 | 加锁 | 读快照 | 读快照 |
| 写操作 | 加锁 | 创建新版本 | 创建新版本 + 冲突检测 |
| 可串行化 | 天然保证 | 不保证 | 检测并回滚冲突 |
| 性能 | 低 | 高 | 中高 |
| 适用场景 | 冲突频繁 | 一般 OLTP | 需要可串行化的 OLTP |
从单机到分布式的延伸:本章讨论的事务机制限于单机场景。当数据分布在多个节点上时,事务面临新的挑战——网络分区、节点故障、时钟不同步。分布式事务协议(2PC、Saga、TCC)和共识算法(Raft、Paxos)是解决这些挑战的核心工具,将在 分布式事务 中深入讨论。
实践建议:
- 大多数 OLTP 场景使用 READ COMMITTED 或 REPEATABLE READ 即可
- 需要防止写偏序时,使用 SERIALIZABLE 或在应用层加锁
- 预防死锁的最佳实践是按固定顺序访问资源
- 长事务是 MVCC 的天敌——尽量缩短事务持续时间
- 理解你所使用的数据库的 MVCC 实现方式,才能正确诊断并发问题
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






