Skip to content

第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:插入这个版本的事务 ID
  • xmax:删除/更新这个版本的事务 ID(0 表示当前版本有效)

更新操作实际发生

sql
-- 原来的记录 ('flame_sword' 100)
BEGIN; -- 事务 ID = 1001
UPDATE vault_items SET quantity = 50 WHERE item_name = 'flame_sword';
  1. PostgreSQL 将旧版本的 xmax 设为当前事务 ID(1001)——标记为"被删除"
  2. 插入新版本,xmin = 1001
物理存储(简化):
 xmin=99 xmax=1001 item_name='flame_sword' quantity=100
 xmin=1001 xmax=0 item_name='flame_sword' quantity=50
 ↑ 这是当前的活跃版本

读取时可见性判断

事务执行查询时,先收到一个事务快照,包含:

  • xmin:快照创建时的最小活跃事务 ID
  • xmax:快照创建时已分配的最大事务 ID + 1
  • xip_list:快照创建时所有活跃事务 ID 的列表

可见性规则(简化):

条件可见性
xmin 小于快照 xmin 且已提交可见
xminxip_list不可见(创建者还活跃)
xmaxxip_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 + 1
  • up_limit_id:当前最小的活跃事务 ID
  • creator_trx_id:创建该 Read View 的事务 ID
  • m_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 找上一个版本,继续判断

核心差异

维度PostgreSQLMySQL 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 的保证

  1. 读不会失败(总是从快照读取)
  2. 写-写冲突会被检测到(第一提交者获胜)
  3. 不会出现脏读、不可重复读、幻读

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 UncommittedRead Committed(默认)Repeatable ReadSerializable
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——性能开销很大

实践:不同隔离级别下的行为对比

看代码比读表格直接——下面用两个终端演示同一行数据在不同隔离级别下读到什么:

sql
-- 准备数据
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
sql
-- 幻读对比
-- 终端 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,表的膨胀会导致磁盘使用率飙升和查询性能下降。VACUUMVACUUM FULL 的区别:

  • VACUUM:标记死元组空间为可重用(不释放磁盘空间给 OS)
  • VACUUM FULL:重建表,释放空间给 OS(持排他锁)

陷阱 4:MySQL InnoDB 的间隙锁

Repeatable Read 下,InnoDB 使用间隙锁 + next-key lock 来阻止幻读。这可能导致比预期更严重的锁竞争:

sql
-- 事务 A
SELECT * FROM vault_items WHERE id > 100 FOR UPDATE;
-- 锁住了 id > 100 的所有间隙(包括不存在的 id 范围)

-- 事务 B
INSERT INTO vault_items VALUES (150 ...); -- 阻塞

通关挑战

动手试试

  1. 脏读观察:两个终端,T1 用 Read Uncommitted(如果数据库支持)BEGIN 并 UPDATE,不 COMMIT。T2 读同一行。在 PostgreSQL 和 MySQL 上分别测试。

  2. Repeatable Read vs Read Committed

  • T1 BEGIN ISOLATION LEVEL REPEATABLE READ,读一行
  • T2 UPDATE 并 COMMIT 该行
  • T1 再读——RR 读到旧值,RC 读到新值
  1. 写偏斜场景
  • 创建一张 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 的回滚开销会很大。下一章你将看到更细粒度的控制方法:两阶段锁、意向锁、乐观并发控制、时间戳排序——这些是数据库调度器的底层武器库。

Built with VitePress | Software Systems Atlas