第12章:MVCC 与隔离级别
元数据卡
维度 值 难度 (黑魔法) 前置 第11章:事务与 ACID 关键词 隔离级别、MVCC、快照隔离、PostgreSQL、MySQL InnoDB、SSI 代码语言 SQL (PostgreSQL / MySQL)
你的进度
ACID 的规矩贴出来了,但管理员发现一个问题:如果一个人正在盘点(SELECT 长查询),另一个人就不能入库——得等他查完。效率太低了。老陈的解决办法是:给每个管理员发一本个人的副本记录本。你做你的事,我做我的事,互不干扰——完工了再对账。这就是 MVCC:多版本并发控制。
你的任务
理解四种隔离级别分别禁止哪些问题,MVCC 的核心工作原理,以及 PostgreSQL 和 MySQL 在隔离级别的实现差异。最重要的是:学会选一个合适的隔离级别。
破局 · 溯源
隔离级别:从哪里开始松绑
上章你看到了五个并发问题,按严重程度排列:
| 严重程度 | 问题 | 描述 |
|---|---|---|
| 最严重 | 脏写 | 回滚时覆盖已提交的数据 |
| 严重 | 脏读 | 读到未提交的数据 |
| 普通 | 不可重复读 | 同一事务内读到不同的已提交值 |
| 普通 | 更新丢失 | 两个事务的更新相互覆盖 |
| 轻微 | 幻读 | 同一查询返回不同行集 |
SQL 标准定义了四个隔离级别,每个级别禁止特定集合的问题。
SQL 标准隔离级别
| 隔离级别 | 脏写 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|---|
| Read Uncommitted | 允许 | 允许 | 允许 | 允许 |
| Read Committed | 禁止 | 禁止 | 允许 | 允许 |
| Repeatable Read | 禁止 | 禁止 | 禁止 | 允许 |
| Serializable | 禁止 | 禁止 | 禁止 | 禁止 |
注意:SQL 标准没有明确说明"更新丢失"在哪个级别被禁止。实际上所有级别都可能出现更新丢失——但 Repeatable Read 及以上配合 MVCC 可以避免。
Read Uncommitted(读未提交)
允许脏读——一个事务能读到另一个活跃事务未提交的修改。
实际使用:几乎没有数据库真正实现这一级别(PostgreSQL 和 InnoDB 都把它等价于 Read Committed)。原因很简单——脏读带来的问题比性能提升不值得。
场景:
T1: BEGIN;
T1: UPDATE vault_items SET quantity = 0 WHERE item_name = 'flame_sword';
T2: BEGIN;
T2: SELECT quantity FROM vault_items WHERE item_name = 'flame_sword';
-- Read Uncommitted 下返回 0(脏读)
T1: ROLLBACK;T2 读到的 0 从未真实存在过。
Read Committed(读已提交)
禁止脏读,但允许不可重复读。
这是大多数数据库的默认级别(PostgreSQL、Oracle、SQL Server)。
场景:
T1: BEGIN;
T1: SELECT quantity FROM vault_items WHERE item_name = 'flame_sword'; → 100
T2: UPDATE vault_items SET quantity = 50 WHERE item_name = 'flame_sword';
T2: COMMIT;
T1: SELECT quantity FROM vault_items WHERE item_name = 'flame_sword'; → 50(不可重复读)T1 的两次查询读到了不同的结果。这在报告统计里很常见——报表拉到一半,数据变了。
Repeatable Read(可重复读)
禁止脏读和不可重复读,但允许幻读。
场景:
T1: BEGIN;
T1: SELECT * FROM vault_items WHERE value > 100; → 3 rows
T2: INSERT INTO vault_items VALUES (999 150);
T2: COMMIT;
T1: SELECT * FROM vault_items WHERE value > 100; → 3 rows (不可重复读被阻止)
→ 但 4 rows 在幻读下仍可能出现不同数据库对幻读的处理不同。PostgreSQL 的 Repeatable Read 实际上也阻止了幻读(通过快照隔离),MySQL InnoDB 默认的 Repeatable Read 也阻止了幻读(通过间隙锁)。标准定义和实际实现有差距。
Serializable(可串行化)
禁止所有问题。多个并发事务的执行效果等价于某个串行顺序。
代价:串行化失败率增加、吞吐量下降。
MVCC:多版本并发控制
MVCC 是核心思想最简单但实现最复杂的并发控制技术之一。
核心思想:每个数据项维护多个版本。读操作读取数据的某个"快照"版本,而不是锁住它。这样:
- 读不阻塞写:读操作读取旧版本,写操作创建新版本
- 写不阻塞读:写事务在创建新版本的读事务仍然可以读旧版本
想象一个 Git 仓库:你可以在 master 分支上提交新版本,其他人仍然看到旧版本的代码,直到他们 pull。
MVCC 术语
- Tuple(元组):行的一个版本
- xmin:创建该版本的事务 ID
- xmax:删除/覆盖该版本的事务 ID
- 快照:事务在某个时间点可见的版本集合
PostgreSQL MVCC 实现
PostgreSQL 的 MVCC 实现是行版本链 + 事务 ID 比较。
每个行版本在页面上存有两个隐藏系统列:
xmin:插入这个版本的事务 IDxmax:删除/更新这个版本的事务 ID(0 表示当前版本有效)
更新操作实际发生:
-- 原来的记录 ('flame_sword' 100)
BEGIN; -- 事务 ID = 1001
UPDATE vault_items SET quantity = 50 WHERE item_name = 'flame_sword';- PostgreSQL 将旧版本的
xmax设为当前事务 ID(1001)——标记为"被删除" - 插入新版本,
xmin = 1001
物理存储(简化):
xmin=99 xmax=1001 item_name='flame_sword' quantity=100
xmin=1001 xmax=0 item_name='flame_sword' quantity=50
↑ 这是当前的活跃版本读取时可见性判断:
事务执行查询时,先收到一个事务快照,包含:
xmin:快照创建时的最小活跃事务 IDxmax:快照创建时已分配的最大事务 ID + 1xip_list:快照创建时所有活跃事务 ID 的列表
可见性规则(简化):
| 条件 | 可见性 |
|---|---|
xmin 小于快照 xmin 且已提交 | 可见 |
xmin 在 xip_list 中 | 不可见(创建者还活跃) |
xmax 在 xip_list 中 | 可见(删除者还活跃,版本有效) |
xmax 小于快照 xmin 且已提交 | 不可见(已被删除) |
不同隔离级别的快照差异:
- Read Committed:每条 SQL 语句开始前获取一个新快照
- Repeatable Read / Serializable:事务开始时获取一个快照,整个事务用同一个
这也解释了为什么 Read Committed 会出现不可重复读——因为两次 SELECT 拿到了不同的快照。
MySQL InnoDB MVCC 实现
InnoDB 的 MVCC 不靠行版本链上的 xmin/xmax,而是用 undo log + read view。
undo log:记录每行被修改前的数据。当需要读取旧版本时,从最新版本通过 undo log 回溯。
当前版本:('flame_sword' 50)
undo log:
- trx_id=1001: ('flame_sword' 100) ← 回溯一步得到
- trx_id=990: ('flame_sword' 200) ← 再回溯一步得到Read View:创建时记录:
low_limit_id:当前最大的活跃事务 ID + 1up_limit_id:当前最小的活跃事务 IDcreator_trx_id:创建该 Read View 的事务 IDm_ids:当前活跃事务 ID 列表
可见性判断:
| 条件 | 可见性 |
|---|---|
trx_id < up_limit_id | 可见(事务已提交) |
trx_id == creator_trx_id | 可见(自己的修改) |
trx_id ∈ m_ids | 不可见(事务活跃) |
trx_id >= low_limit_id | 不可见(事务在 Read View 之后创建) |
| 其他 | 通过 undo log 找上一个版本,继续判断 |
核心差异:
| 维度 | PostgreSQL | MySQL InnoDB |
|---|---|---|
| 版本存储 | 行在数据页上有多版本(xmin/xmax) | 最新版本在数据页,旧版本在 undo log |
| 事务 ID | 全局递增,有回卷问题(需 freeze/VACUUM) | 全局递增,存储在 undo log 的事务头 |
| 回滚 | 直接标记 xmax = 当前事务 | 通过 undo log 回滚到前一个版本 |
| 清除 | VACUUM 清除过期版本 | purge 线程异步清理 undo log |
| 死元组膨胀 | 明显(需要定期 VACUUM) | 不明显(undo log 可回收) |
快照隔离(Snapshot Isolation)
MVCC 带来的自然产物就是快照隔离(SI)。事务看到的是数据库在某个时刻的"快照"——所有已提交事务的修改集合。
SI 的保证:
- 读不会失败(总是从快照读取)
- 写-写冲突会被检测到(第一提交者获胜)
- 不会出现脏读、不可重复读、幻读
SI 的盲区:写偏斜(Write Skew)。堡垒规定城墙上任何时候至少有一名哨兵站岗——这是业务规则,不是数据库约束。
T1: SELECT * FROM guards WHERE on_duty = TRUE; → 2 人站岗
T2: SELECT * FROM guards WHERE on_duty = TRUE; → 2 人站岗
T1: UPDATE guards SET on_duty = FALSE WHERE id = 'G1'; -- 老张:我今天休息,让小王站
T2: UPDATE guards SET on_duty = FALSE WHERE id = 'G2'; -- 小王:老张站我就歇一天
→ 结果:0 人站岗。城墙上空无一人——违反了"至少一人站岗"的堡垒规则。在 SI 下,T1 和 T2 读到了同一个快照(2 人站岗),各自修改不同行,没有写-写冲突,但共同导致约束被违反。这是可串行化快照隔离要解决的问题。
可串行化快照隔离(SSI)
PostgreSQL 在 9.1 版本引入了 Serializable Snapshot Isolation(SSI)——在 SI 的基础上检测写偏斜,实现了真正的可串行化。
核心思想:检测读-写冲突(Read-Write Conflict)。
SI 只检测写-写冲突。SSI 额外跟踪:
- SIREAD 锁(读锁,类似于"我读了这些数据"的标记)
- 当检测到两个事务的读-写图存在循环依赖时,回滚其中一个
代价:SIREAD 锁的管理开销大,CPU 和内存消耗明显增加。这也是为什么 Serializable 级别的性能远低于 Repeatable Read。
不同数据库的隔离级别实际表现
| 数据库 | Read Uncommitted | Read Committed(默认) | Repeatable Read | Serializable |
|---|---|---|---|---|
| PostgreSQL | = Read Committed | 每条语句新快照 | 事务级别快照;阻止幻读 | SSI;检测写偏斜 |
| MySQL InnoDB | 有脏读 | 每条语句新快照 | 事务级别快照 + 间隙锁;阻止幻读 | 实际 = Repeatable Read + 间隙锁 |
| Oracle | 不允许 | 快照读(无锁) | 不存在该级别 | 快照级别之上加锁 |
| SQL Server | 有脏读 | 行级锁 | 行级锁 + 不可重复读保护 | 范围锁 |
PostgreSQL 的 Remark:
- 它的 Repeatable Read 实际上阻止了幻读(通过快照隔离),比标准定义的更强
- 它的 Serializable 是通过 SSI 实现的,能检测写偏斜
MySQL InnoDB 的 Remark:
- 它的 Repeatable Read 也阻止了幻读(通过间隙锁 + next-key locking)
- 它的 Serializable 是对所有
SELECT隐式加LOCK IN SHARE MODE——性能开销很大
实践:不同隔离级别下的行为对比
看代码比读表格直接——下面用两个终端演示同一行数据在不同隔离级别下读到什么:
-- 准备数据
CREATE TABLE vault_items (id INT PRIMARY KEY stock INT);
INSERT INTO vault_items VALUES (1 100) (2 200) (3 300);
-- 终端 1
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM vault_items WHERE id = 1; -- stock = 100
-- 终端 2
BEGIN ISOLATION LEVEL READ COMMITTED;
UPDATE vault_items SET stock = 999 WHERE id = 1;
COMMIT;
-- 终端 1(保持 REPEATABLE READ)
SELECT * FROM vault_items WHERE id = 1; -- PostgreSQL: stock = 100 (快照隔离)
-- MySQL RR: stock = 100 (快照隔离)
-- 但如果用 READ COMMITTED 级别,这里会读到 999-- 幻读对比
-- 终端 1(PostgreSQL REPEATABLE READ)
BEGIN;
SELECT * FROM vault_items WHERE id > 0; -- 3 rows
-- 终端 2
INSERT INTO vault_items VALUES (4 400);
COMMIT;
-- 终端 1
SELECT * FROM vault_items WHERE id > 0; -- 仍是 3 rows (PostgreSQL RR 阻止幻读)常见陷阱
陷阱 1:认为 Read Committed 是"安全的默认值"
在宝库物品移库、库存盘点等场景下,Read Committed 不能防止不可重复读和更新丢失。如果应用依赖于"事务内两次读到相同值"的假设,必须用 Repeatable Read 或 Serializable。
陷阱 2:认为可重复读 = 可串行化
Repeatable Read 阻止了不可重复读和幻读(在 PostgreSQL 和 MySQL 中),但写偏斜(Write Skew)在 Repeatable Read 下仍然存在。只有 Serializable 能阻止写偏斜。
陷阱 3:PostgreSQL 的 VACUUM
PostgreSQL 的行版本链会积累"死元组"(dead tuples)。如果长时间不 VACUUM,表的膨胀会导致磁盘使用率飙升和查询性能下降。VACUUM 和 VACUUM FULL 的区别:
VACUUM:标记死元组空间为可重用(不释放磁盘空间给 OS)VACUUM FULL:重建表,释放空间给 OS(持排他锁)
陷阱 4:MySQL InnoDB 的间隙锁
Repeatable Read 下,InnoDB 使用间隙锁 + next-key lock 来阻止幻读。这可能导致比预期更严重的锁竞争:
-- 事务 A
SELECT * FROM vault_items WHERE id > 100 FOR UPDATE;
-- 锁住了 id > 100 的所有间隙(包括不存在的 id 范围)
-- 事务 B
INSERT INTO vault_items VALUES (150 ...); -- 阻塞通关挑战
动手试试
脏读观察:两个终端,T1 用 Read Uncommitted(如果数据库支持)BEGIN 并 UPDATE,不 COMMIT。T2 读同一行。在 PostgreSQL 和 MySQL 上分别测试。
Repeatable Read vs Read Committed:
- T1
BEGIN ISOLATION LEVEL REPEATABLE READ,读一行 - T2 UPDATE 并 COMMIT 该行
- T1 再读——RR 读到旧值,RC 读到新值
- 写偏斜场景:
- 创建一张
guards表,插入两人站岗 - 两个终端执行"把自己改为不站岗"的操作
- 在 Repeatable Read 和 Serializable 级别下分别观察结果
验收标准
- 能描述四个隔离级别分别阻止哪些并发问题
- 能解释 MVCC 的核心思想(读不阻塞写,写不阻塞读)
- 能说明 PostgreSQL 和 MySQL MVCC 实现的差异
- 能解释快照隔离(SI)和可串行化快照隔离(SSI)的区别
- 能在实际查询中选择合适的隔离级别
常见卡点
- PostgreSQL 的 xmin/xmax 理解:
xmin= 谁来创建我的,xmax= 谁来干掉我的。0 = 没人干掉我,我还活着。 - InnoDB undo log vs PostgreSQL 行版本链:InnoDB 的所有历史版本都存 undo log 里,当前行是"最新"的。PostgreSQL 的当前行和其他版本都在堆表里。
- VACUUM 定时:PostgreSQL 有 autovacuum 默认开启,但高并发更新场景下仍可能出现膨胀。
- Transaction ID 回卷:PostgreSQL 的事务 ID 是 32 位循环计数,需要定期 FREEZE 防止回卷导致的可见性错误。
现在不需要理解
- PostgreSQL VACUUM 的完整实现细节(vm 文件、可见性映射)
- InnoDB 的 Purge 机制线程调度
- PostgreSQL 的快照导出(
pg_export_snapshot())——分布式场景
旅人笔记
隔离级别是正确性与性能之间的权衡。MVCC 用多版本的思路让读不阻塞写、写不阻塞读——这是现代数据库并发控制的默认方案。快照隔离(SI)是 MVCC 的自然产物,但它不防写偏斜。可串行化快照隔离(SSI)在 SI 之上增加了读-写冲突检测,实现了真正的可串行化。
→ 下一站预告
MVCC 解决了读-写冲突。但如果写-写冲突严重(高并发写入场景),MVCC 的回滚开销会很大。下一章你将看到更细粒度的控制方法:两阶段锁、意向锁、乐观并发控制、时间戳排序——这些是数据库调度器的底层武器库。