mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
6075 字
17 分钟
事务与并发控制:ACID、隔离与 MVCC
2024-06-10

当多个用户同时读写同一张表时,数据库如何保证数据不出错?这是并发控制要解决的核心问题。事务是数据库提供的”安全舱”——它把一组操作打包成原子单元,要么全部成功,要么全部回滚,让应用层不必操心并发带来的数据混乱。

本章从 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 数据库全景:理解数据库的分层架构
  • 基本的并发编程概念:锁、竞态条件、死锁
Note

事务与并发控制是数据库最核心的机制之一。如果你只读一章,建议读这一章——理解 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已提交数据丢失
Note

一致性是 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 读取旧值,恢复为 5000

1.4 持久性的实现#

持久性依赖 WAL(Write-Ahead Logging) 机制:数据页的修改必须先写入日志,再写入磁盘。提交事务时,数据库确保 WAL 刷盘(fsync)成功后才返回提交确认。即使数据页尚未写回磁盘,崩溃恢复时也能从 WAL 重做已提交的修改。关于 WAL 的存储层细节,可参考 存储引擎 中的 WAL 与检查点章节。

1.5 事务状态机#

事务在其生命周期中经历多个状态的转换:

stateDiagram-v2 [*] --> 活跃: BEGIN 活跃 --> 部分提交: 最后一条SQL执行完毕 部分提交 --> 已提交: COMMIT / WAL刷盘成功 部分提交 --> 失败: WAL刷盘失败 活跃 --> 失败: SQL执行错误 已提交 --> [*] 失败 --> 已回滚: ROLLBACK / Undo恢复 已回滚 --> [*]

状态说明

  • 活跃(Active):事务正在执行 SQL 语句
  • 部分提交(Partially Committed):最后一条语句执行完毕,等待 WAL 刷盘
  • 已提交(Committed):WAL 刷盘成功,修改持久化
  • 失败(Failed):执行出错或刷盘失败,需要回滚
  • 已回滚(Aborted):Undo Log 恢复完成,数据库回到事务前状态
Important

部分提交 → 已提交这一步是持久性的关键——只有 WAL 成功刷盘,事务才算真正提交。如果在此期间崩溃,重启后数据库会根据 WAL 重做(Redo)已提交事务、撤销(Undo)未提交事务,这就是崩溃恢复的 ARIES 算法核心思想。

二、并发异常#

当多个事务并发执行时,即使每个事务单独看都是正确的,组合在一起却可能产生各种异常。理解这些异常是理解隔离级别的前提。

2.1 脏读(Dirty Read)#

脏读是指事务 A 读到了事务 B 未提交的修改。如果事务 B 随后回滚,事务 A 读到的就是”脏”数据——从未真正存在过的数据。

sequenceDiagram participant T1 as 事务T1 participant DB as 数据库 participant T2 as 事务T2 Note over T1,T2: 初始余额:A=5000, B=5000 T1->>DB: BEGIN T1->>DB: UPDATE accounts SET balance=4000 WHERE id=1 T2->>DB: BEGIN (READ UNCOMMITTED) T2->>DB: SELECT balance FROM accounts WHERE id=1 DB-->>T2: 4000 ← 读到未提交数据! T1->>DB: ROLLBACK ← 余额恢复为5000 Note over T2: T2 基于4000做决策,但5000才是正确值
-- 脏读复现(MySQL,隔离级别设为 READ UNCOMMITTED)
SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- 事务 T1
BEGIN;
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;
-- 事务 T1
BEGIN;
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)#

幻读是指事务内同一范围查询返回的行数发生变化——因为其他事务插入或删除了满足条件的行。与不可重复读的区别在于,幻读关注的是行的”有无”,而非已有行的值变化。

sequenceDiagram participant T1 as 事务T1 participant DB as 数据库 participant T2 as 事务T2 Note over T1,T2: accounts 表中 balance>4500 的行有2条 T1->>DB: BEGIN (REPEATABLE READ) T1->>DB: SELECT * FROM accounts WHERE balance > 4500 DB-->>T1: 2行 T2->>DB: BEGIN T2->>DB: INSERT INTO accounts VALUES(3, 'Charlie', 4800) T2->>DB: COMMIT T1->>DB: SELECT * FROM accounts WHERE balance > 4500 DB-->>T1: 3行 ← 多了一行"幻影"! T1->>DB: UPDATE accounts SET balance=balance-100 WHERE balance>4500 Note over T1: UPDATE影响了3行,与SELECT的2行不一致
-- 幻读复现(MySQL,REPEATABLE READ 下快照读不会幻读,但当前读会)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- 事务 T1
BEGIN;
SELECT * FROM accounts WHERE balance > 4500; -- 返回 2 行
-- 事务 T2
INSERT 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
Warning

写偏序是隔离级别中最容易忽视的异常。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 READREAD COMMITTED
REPEATABLE READ 是否防止幻读快照读防止,当前读通过 Gap Lock 防止不防止(PG 的 RR 基于 MVCC 快照)
SERIALIZABLE 实现方式严格两阶段锁(S2PL)SSI(Serializable Snapshot Isolation)
READ COMMITTED 实现方式MVCC(每条语句新快照)MVCC(每条语句新快照)
是否支持 READ UNCOMMITTED语法支持,实际等同于 READ COMMITTED不支持
Note

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”。

graph LR subgraph "MVCC 版本链(以 InnoDB 为例)" V3["版本3<br/>balance=3000<br/>trx_id=103<br/>roll_ptr→"] V2["版本2<br/>balance=4000<br/>trx_id=102<br/>roll_ptr→"] V1["版本1<br/>balance=5000<br/>trx_id=101"] V3 --> V2 --> V1 end subgraph "可见性判断" T100["事务T100<br/>Read View:<br/>min=101, max=103"] T102["事务T102<br/>Read View:<br/>min=101, max=103"] end T100 -.->|"trx_id=103 > max<br/>不可见,沿链查找"| V3 T100 -.->|"trx_id=102 ∈ [min,max]<br/>未提交,不可见"| V2 T100 -.->|"trx_id=101 < min<br/>已提交,可见"| V1 T102 -.->|"trx_id=103 > max<br/>不可见"| V3 T102 -.->|"trx_id=102 = 自身<br/>可见"| V2

4.3 可见性判断规则#

MVCC 的核心算法是可见性判断:给定一个 Read View(快照),判断版本链上的哪个版本对当前事务可见。

InnoDB 的 Read View 包含

  • m_ids:生成 Read View 时所有活跃(未提交)事务的 ID 列表
  • min_trx_idm_ids 中的最小值
  • max_trx_id:下一个将分配的事务 ID(即当前最大事务 ID + 1)
  • creator_trx_id:创建该 Read View 的事务 ID

判断规则(从版本链最新版本开始,沿 roll_ptr 回溯):

  1. trx_id == creator_trx_id可见(自己修改的)
  2. trx_id < min_trx_id可见(在 Read View 创建前已提交)
  3. trx_id >= max_trx_id不可见(在 Read View 创建后才开始)
  4. trx_id ∈ m_ids不可见(事务活跃未提交)
  5. 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 View
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- 第一次 SELECT:生成 Read View 1
SELECT balance FROM accounts WHERE id = 1; -- 5000
-- 另一事务提交修改后
-- 第二次 SELECT:生成 Read View 2(包含新提交的数据)
SELECT balance FROM accounts WHERE id = 1; -- 4000
COMMIT;
-- REPEATABLE READ:整个事务共用一个 Read View
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
-- 第一次 SELECT:生成 Read View,整个事务复用
SELECT balance FROM accounts WHERE id = 1; -- 5000
-- 另一事务提交修改后
-- 第二次 SELECT:复用同一个 Read View
SELECT balance FROM accounts WHERE id = 1; -- 5000(不变)
COMMIT;

五、两阶段锁(2PL)#

5.1 2PL 原理#

两阶段锁(Two-Phase Locking,2PL)是保证可串行化的经典并发控制协议。其核心规则是:每个事务的锁操作分为两个阶段,增长阶段只加锁不释放,收缩阶段只释放不新增

graph LR subgraph "2PL 两阶段" G["增长阶段<br/>只加锁,不释放"] S["收缩阶段<br/>只释放,不加锁"] G -->|"获取所有锁后"| S end subgraph "事务生命周期" BEGIN["BEGIN"] --> LOCK["加锁1<br/>加锁2<br/>加锁3"] LOCK -->|"锁定点"| UNLOCK["释放锁1<br/>释放锁2<br/>释放锁3"] UNLOCK --> COMMIT["COMMIT"] end BEGIN --> G LOCK --> S S --> COMMIT

2PL 为什么能保证可串行化? 直觉上理解:如果所有事务都遵守 2PL,那么事务的加锁顺序构成一个偏序关系,这个偏序关系就是可串行化顺序。形式化证明基于冲突可串行化理论——2PL 保证了冲突操作的串行顺序与加锁顺序一致。

5.2 锁类型#

锁类型符号兼容性用途
共享锁(S Lock)SS-S 兼容,S-X 互斥读操作(SELECT … LOCK IN SHARE MODE)
排他锁(X Lock)X与所有锁互斥写操作(INSERT/UPDATE/DELETE/FOR UPDATE)
意向共享锁(IS)ISIS-IS/IS-IX/IS-S 兼容表级锁,表示打算加行级 S 锁
意向排他锁(IX)IXIX-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 锁兼容性矩阵#

ISIXSX
IS
IX
S
X

5.4 锁升级#

当行级锁数量过多时,数据库可能将行级锁升级为表级锁以减少锁管理的内存开销。MySQL InnoDB 不会自动锁升级,但可以通过 LOCK TABLES 手动加表锁。SQL Server 会在行锁超过阈值时自动升级为表锁。

5.5 严格两阶段锁(S2PL)#

2PL 的问题是:收缩阶段释放锁后,其他事务可能读到该事务尚未提交的修改——这恰好是脏读。解决方案是严格两阶段锁(Strict 2PL, S2PL):所有锁在事务提交或回滚时才统一释放。S2PL 是大多数数据库 SERIALIZABLE 隔离级别的实现方式。

六、死锁#

6.1 死锁产生条件#

死锁需要同时满足四个条件(Coffman 条件):

  1. 互斥:资源同一时刻只能被一个事务持有
  2. 持有并等待:事务持有至少一个资源,同时等待其他资源
  3. 不可抢占:已持有的资源不能被强制剥夺
  4. 循环等待:事务之间形成环形等待链

6.2 死锁检测 — 等待图#

数据库通过等待图(Wait-For Graph) 检测死锁:如果等待图中存在环,则存在死锁。

graph LR T1["事务T1<br/>持有: 行A的X锁<br/>等待: 行B的X锁"] T2["事务T2<br/>持有: 行B的X锁<br/>等待: 行C的X锁"] T3["事务T3<br/>持有: 行C的X锁<br/>等待: 行A的X锁"] T1 -->|"等待"| T2 T2 -->|"等待"| T3 T3 -->|"等待"| T1 style T1 fill:#ffcdd2,stroke:#c62828 style T2 fill:#fff9c4,stroke:#f9a825 style T3 fill:#c8e6c9,stroke:#2e7d32

检测到环 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);
-- 事务 T1
BEGIN;
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 锁
-- 事务 T1
UPDATE items SET stock = stock - 1 WHERE id = 2; -- 等待 T2 释放 id=2 的锁
-- 事务 T2
UPDATE 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;
Tip

预防死锁的实用方法:按固定顺序访问资源。如果 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 的系统。其实现基于以下机制:

  1. SI(快照隔离):每个事务看到一致的快照,读不阻塞写
  2. rw 冲突追踪:记录事务之间的读写依赖关系
  3. 危险结构检测:检测”两个 rw 冲突形成的三事务环”
  4. 回滚决策:选择代价最小的事务回滚
-- PostgreSQL SSI 示例
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 事务 T1
BEGIN;
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; -- 返回 2
UPDATE on_call SET on_duty = false WHERE doctor = 'Bob';
-- SSI 记录:T2 写入了 T1 读取的行 → rw 冲突
-- 事务 T1
UPDATE on_call SET on_duty = false WHERE doctor = 'Alice';
-- SSI 检测到双向 rw 冲突 → 回滚 T1
-- ERROR: could not serialize access due to read/write dependencies

7.4 SSI vs S2PL 性能对比#

对比维度S2PLSSI
读操作加共享锁,阻塞写不加锁,读快照
写操作加排他锁,阻塞读写只在提交时检测冲突
冲突处理阻塞等待回滚冲突事务
只读事务受写事务阻塞完全不受影响
适用场景冲突频繁冲突稀少(大多数 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 LogVACUUM 清理死元组
更新方式就地更新 + Undo Log 记录旧值插入新行 + 标记旧行为过期
长事务影响Undo Log 膨胀死元组无法清理,表膨胀
Important

两种 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/xmax
SELECT id, name, balance, xmin, xmax FROM accounts;
id | name | balance | xmin | xmax
----+-------+---------+------+------
2 | Bob | 500 | 744 | 0
1 | Alice | 900 | 744 | 0

xmin 是插入该行版本的事务 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 | 0

PostgreSQL 的 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 committed

PostgreSQL 默认隔离级别是 READ COMMITTED——每个语句看到语句开始时已提交的数据。更高的隔离级别可以通过 SET TRANSACTION ISOLATION LEVEL 设置。

九、总结#

事务与并发控制是数据库系统最核心的机制之一。本章从 ACID 出发,逐步构建了完整的并发控制知识体系:

核心脉络

  1. ACID 是目标:原子性、一致性、隔离性、持久性定义了事务的正确性标准
  2. 隔离级别是权衡:从 READ UNCOMMITTED 到 SERIALIZABLE,一致性越强,并发度越低
  3. MVCC 是手段:多版本并发控制让读写互不阻塞,是现代数据库实现高并发的基石
  4. 2PL 是保证:两阶段锁提供了可串行化的理论基础,但代价是并发度降低
  5. SSI 是未来:Serializable Snapshot Isolation 在快照隔离的基础上检测写偏序,兼顾正确性与性能

关键对比

维度2PLMVCCSSI
读操作加锁读快照读快照
写操作加锁创建新版本创建新版本 + 冲突检测
可串行化天然保证不保证检测并回滚冲突
性能中高
适用场景冲突频繁一般 OLTP需要可串行化的 OLTP

从单机到分布式的延伸:本章讨论的事务机制限于单机场景。当数据分布在多个节点上时,事务面临新的挑战——网络分区、节点故障、时钟不同步。分布式事务协议(2PC、Saga、TCC)和共识算法(Raft、Paxos)是解决这些挑战的核心工具,将在 分布式事务 中深入讨论。

实践建议

  • 大多数 OLTP 场景使用 READ COMMITTED 或 REPEATABLE READ 即可
  • 需要防止写偏序时,使用 SERIALIZABLE 或在应用层加锁
  • 预防死锁的最佳实践是按固定顺序访问资源
  • 长事务是 MVCC 的天敌——尽量缩短事务持续时间
  • 理解你所使用的数据库的 MVCC 实现方式,才能正确诊断并发问题

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

事务与并发控制:ACID、隔离与 MVCC
https://blog.souloss.com/posts/database/transactions-and-concurrency-control/
作者
Souloss
发布于
2024-06-10
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时