数据库系列(五):MVCC 与隔离级别 — 为什么数据库不再读阻塞写

写在前面

承接事务主题,本文深入 ACID 中的 I——隔离性。MVCC(多版本并发控制)是现代数据库读写并发的核心机制,它让"读不阻塞写、写不阻塞读"成为可能。

本文要回答:

为什么 SQL 标准定义的四个隔离级别,各家数据库实现的行为却不一样?为什么 MySQL Repeatable Read 能防幻读,PostgreSQL 不能?为什么 PostgreSQL 表会膨胀?


一、并发问题:标准定义的四种异常

SQL 标准定义了四种并发异常,按严重程度递减:

1.1 脏读(Dirty Read)

1
2
3
4
5
6
7
8
9
事务 A 读到了事务 B 未提交的修改。

时间线:
  T1: 事务B:UPDATE balance SET bal = bal - 100 WHERE id=1;  (未提交)
  T2: 事务A:SELECT bal FROM balance WHERE id=1;  → 读到 -100 后的值
  T3: 事务B:ROLLBACK;
  → A 读到了"从未存在"的值

几乎所有现代数据库都默认不允许脏读。

1.2 不可重复读(Non-repeatable Read)

1
2
3
4
5
6
7
事务 A 同一行读两次,结果不同(其他事务已提交)。

时间线:
  T1: 事务A:SELECT bal FROM balance WHERE id=1;  → 1000
  T2: 事务B:UPDATE balance SET bal = 900 WHERE id=1; COMMIT;
  T3: 事务A:SELECT bal FROM balance WHERE id=1;  → 900
  → A 两次读结果不同

1.3 幻读(Phantom Read)

1
2
3
4
5
6
7
事务 A 同一查询两次,结果集行数不同(其他事务插入/删除了匹配行)。

时间线:
  T1: 事务A:SELECT * FROM users WHERE age > 18;  → 10 行
  T2: 事务B:INSERT INTO users (age) VALUES (20); COMMIT;
  T3: 事务A:SELECT * FROM users WHERE age > 18;  → 11 行
  → A 多了一行"幻影"

1.4 丢失更新(Lost Update)

1
2
3
4
5
6
7
8
9
两个事务都基于读到的旧值更新,后写的覆盖前写的。

  T1: 事务A:读 bal = 1000
  T2: 事务B:读 bal = 1000
  T3: 事务A:写 bal = 1000 + 100 = 1100
  T4: 事务B:写 bal = 1000 + 200 = 1200
  → A 的更新丢失

SQL 标准没列这个,但所有数据库都要处理。

1.5 异常矩阵

隔离级别 脏读 不可重复读 幻读 丢失更新
Read Uncommitted
Read Committed(RC)
Repeatable Read(RR)
Serializable

二、MVCC 基本原理

2.1 朴素的锁方案

1
2
3
4
5
6
7
读:加共享锁 S
写:加排他锁 X
S-X 互斥,X-X 互斥

问题:写事务持有 X 锁期间,所有读都被阻塞
  - 长事务写入 → 大量 SELECT 等待
  - 实际不可用

2.2 MVCC 思路

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
核心:每行有多个版本,每个事务看到自己"快照"

UPDATE 不修改原行,而是:
  1. 把原行标记为"旧版本"
  2. 写入新行(新版本)
  3. 不同事务根据"可见性规则"看到不同版本

  事务A(开始早)→ 看到旧版本
  事务B(开始晚)→ 看到新版本

→ 读和写不冲突!
→ 性能远高于锁方案

2.3 MVCC 的代价

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
代价 1:存储膨胀
  - 旧行版本不能立即删
  - 等到所有"可能看到旧行"的事务结束后才能清理
  - PostgreSQL 这点尤其严重(详见第 7 节)

代价 2:写放大
  - 一次 UPDATE = 写一行 + 标记旧行 + 更新索引
  - 比直接"原地改"成本高

代价 3:长事务问题
  - 长事务持有"旧版本",导致 Undo/WAL 不能清理
  - 拖累整个实例

三、四种 MVCC 实现

四家的 MVCC 实现思路类似(多版本 + 可见性判断),但具体机制不同。

3.1 Oracle:Undo Segment + SCN

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
核心机制:
  - 每行有"块头 ITL(事务槽)",记录最后修改它的事务
  - Undo Segment 存"旧版本"
  - SCN(System Change Number):全局递增的事务编号

可见性判断:
  - 事务开始时记录一个"快照 SCN"
  - 读行时:
    - 行的 ITL SCN ≤ 快照 SCN → 可见
    - 行的 ITL SCN > 快照 SCN → 不可见,去 Undo Segment 找旧版本
    - 一直往回找,直到找到 ≤ 快照 SCN 的版本
1
2
3
4
5
6
7
8
查询流程:
  SELECT balance FROM accounts WHERE id = 1;

  1. 读到当前行的 balance = 900,ITL SCN = 200
  2. 我的快照 SCN = 150
  3. 200 > 150 → 不可见
  4. 通过 ITL 找到 Undo Segment 中 SCN = 100 的旧版本 balance = 1000
  5. 100 ≤ 150 → 可见,返回 1000

3.2 MySQL InnoDB:Undo Log + ReadView

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
核心机制:
  - 每行隐藏两个列:DB_TRX_ID(最后修改事务 ID)、DB_ROLL_PTR(指向 Undo 链)
  - Undo Log 中存旧版本,通过 DB_ROLL_PTR 串成链
  - ReadView:事务开始时创建的"快照"

ReadView 内容:
  - m_ids:当前活跃(未提交)事务 ID 列表
  - min_trx_id:m_ids 最小值
  - max_trx_id:下一个要分配的事务 ID
  - creator_trx_id:创建 ReadView 的事务 ID

可见性判断:
  - 行的 trx_id < min_trx_id → 该事务已提交 → 可见
  - 行的 trx_id >= max_trx_id → 该事务在我之后开始 → 不可见
  - 行的 trx_id 在 m_ids 中 → 该事务未提交 → 不可见
  - 行的 trx_id == creator_trx_id → 自己改的 → 可见
  - 否则 → 可见
1
2
3
4
5
6
7
RC vs RR 的实现差异:
  - Read Committed:每条 SELECT 都创建新 ReadView
    → 总是看到最新已提交数据
    → 但同一事务内同一行可能看到不同值(不可重复读)
  - Repeatable Read:事务内只创建一次 ReadView(第一次读时)
    → 整个事务看到的数据快照一致
    → 不会发生不可重复读

3.3 PostgreSQL:xmin/xmax + clog

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
核心机制:
  - 每行有 xmin(创建事务 ID)和 xmax(删除/更新事务 ID)
  - 没有独立 Undo,旧行就留在 Heap 里
  - clog(commit log):记录每个事务的状态(committed/aborted/in-progress)

可见性判断:
  - 行的 xmin 已提交且 < 当前快照 → 可见
  - 行的 xmax 已提交且 ≤ 当前快照 → 已被删除,不可见
  - 复杂规则见 PostgreSQL 源码 HeapTupleSatisfiesMVCC

特点:
  - 不需要 Undo Log
  - 旧 tuple 直接留在 heap 中("表膨胀")
  - VACUUM 后台清理

3.4 SQL Server:RCSI / SI(基于 TempDB)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
核心机制:
  - 行版本存在 TempDB(不是本表)
  - 两种模式:
    RCSI(Read Committed Snapshot Isolation):
      - Read Committed 默认走行版本
      - 每条语句一个快照
    SI(Snapshot Isolation):
      - 类似 RR,整个事务一个快照
      - 需要应用显式启用

启用:
  ALTER DATABASE mydb SET READ_COMMITTED_SNAPSHOT ON;  -- RCSI
  ALTER DATABASE mydb SET ALLOW_SNAPSHOT_ISOLATION ON;  -- SI

注意:
  - SQL Server 默认 RC 是"读加 S 锁",不是 MVCC
  - 启用 RCSI 后才变成 MVCC 行为

3.5 四家对比

维度 Oracle SQL Server MySQL InnoDB PostgreSQL
旧版本存放 Undo Segment TempDB Undo Log Heap 内
事务标识 SCN TID DB_TRX_ID xmin/xmax
清理机制 SMON 自动 TempDB 自动 Purge Thread autovacuum
默认隔离级别 Read Committed Read Committed Repeatable Read Read Committed
RR 防幻读 ✅(Serializable)

四、幻读与 Next-Key Lock

4.1 MySQL InnoDB 的 RR:为什么能防幻读

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
InnoDB 在 RR 级别下,普通 SELECT 用 MVCC(快照读),不会幻读
但 UPDATE/DELETE/SELECT FOR UPDATE 用当前读,要靠锁防幻读

Next-Key Lock = Gap Lock + Record Lock

示例:
  表:id (1, 5, 10, 15, 20)
  索引:id 主键

  事务A:SELECT * FROM t WHERE id BETWEEN 5 AND 15 FOR UPDATE;
  → 锁住 id ∈ [5, 15] 的所有行(Record Lock)
  → 同时锁住 (15, 20) 之间的"间隙"(Gap Lock)
  → 防止其他事务插入 id ∈ (10, 15) 或 (15, 20)

  事务B:INSERT INTO t VALUES (12);
  → 阻塞(Gap Lock 拦截)
1
2
3
4
Gap Lock 的副作用:
  - 锁范围比"实际行"大
  - 高并发插入场景容易死锁
  - 唯一索引等值查询会"退化"(不需要 Gap Lock)

4.2 PostgreSQL 的 RR:不能防幻读

1
2
3
4
5
6
7
8
PG 的 RR 完全基于 MVCC,没有 Gap Lock:
  - 整个事务一个快照
  - 重复读不会变化(不会不可重复读)
  - 但其他事务插入的新行,事务后期能看到(幻读)

  事务A:SELECT * FROM t WHERE id > 10;  → 3 行
  事务B:INSERT INTO t VALUES (15); COMMIT;
  事务A:SELECT * FROM t WHERE id > 10;  → 4 行 ← 幻读!
1
2
3
4
PG 怎么防幻读?
  → 用 Serializable 级别(SSI 实现)
  → 通过 SIREAD 锁 + 冲突检测
  → 性能代价大

4.3 SQL Server:SI 同样不防幻读

1
2
3
4
5
6
SI(Snapshot Isolation):类似 PG 的 RR
  - 不会不可重复读
  - 但会幻读
  - 写冲突时报错(需要重试)

要防幻读 → Serializable

五、Serializable 的"真假"

5.1 真 Serializable vs SSI

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
真 Serializable:
  - 表级锁 / 范围锁
  - 性能极差,几乎不用
  - 2PL(Two-Phase Locking)的严格实现

SSI(Serializable Snapshot Isolation):
  - PostgreSQL 9.1+、SQL Server(部分)
  - 基于 MVCC + 冲突检测
  - 性能远好于传统 Serializable
  - 但仍有写冲突代价

5.2 各家 Serializable 实现

数据库 实现 性能
Oracle 表/范围锁 差(生产几乎不用)
SQL Server 范围锁
MySQL InnoDB 2PL + Next-Key Lock
PostgreSQL SSI 较好

5.3 实践建议

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
默认隔离级别选择:
  - Oracle / SQL Server / PG → Read Committed(默认)
  - MySQL InnoDB → Repeatable Read(默认)

需要"严格串行化":
  - 高一致性场景(金融)→ Serializable
  - 否则 RC/RR 已经够用

PG SSI 用法:
  SET default_transaction_isolation = 'serializable';
  -- 业务代码需要重试机制(写冲突会报错)

六、Read Committed 的细节差异

6.1 Oracle / PG 的 RC:语句级快照

1
2
3
4
5
6
每条 SELECT 创建一个新快照,看到当时最新已提交数据。

  事务A:
    SELECT * FROM t WHERE id = 1;  -- 假设看到 value = 100
    [其他事务改并提交 value = 200]
    SELECT * FROM t WHERE id = 1;  -- 看到 value = 200

6.2 MySQL InnoDB 的 RC:与 Oracle/PG 类似

1
2
3
4
InnoDB 在 RC 级别下:
  - 每条 SELECT 创建新 ReadView
  - 不能 Next-Key Lock(退化到 Record Lock)
  - 不能防不可重复读

6.3 SQL Server 默认 RC:基于锁

1
2
3
4
5
6
7
8
SQL Server 默认 RC 是"读加 S 锁":
  - SELECT 期间持 S 锁
  - 写事务被阻塞
  - 不是 MVCC

启用 RCSI 后变成 MVCC(语句级快照):
  ALTER DATABASE mydb SET READ_COMMITTED_SNAPSHOT ON;
  -- 这是 SQL Server 性能优化关键步骤

七、PostgreSQL 表膨胀与 VACUUM

7.1 为什么 PG 会膨胀

1
2
3
4
5
6
7
PG 没有 Undo Segment,旧 tuple 留在 heap:
  UPDATE → 插入新 tuple + 标记旧 tuple xmax
  旧 tuple 仍在 heap 中,占用空间

  长期高频 UPDATE:
    原表 1GB → 实际有效数据 200MB + 死 tuple 800MB
    → 性能下降(扫描慢、缓存命中率低)

7.2 autovacuum

1
2
3
4
5
6
7
PG 自动清理死 tuple 的后台进程:
  - 默认启用
  - 每 N 行变更触发一次 VACUUM
  - 关键参数:
    autovacuum_vacuum_threshold = 50(基础行数)
    autovacuum_vacuum_scale_factor = 0.2(变更比例)
    → 满足条件:50 + 表行数 × 0.2
1
2
3
4
5
6
7
8
9
-- 手动触发
VACUUM ANALYZE accounts;     -- 普通 VACUUM + 更新统计
VACUUM FULL accounts;        -- 锁表 + 重写表(回收磁盘空间,慢)
REINDEX TABLE accounts;      -- 重建索引

-- 监控
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000;

7.3 长事务的危害

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
PG 长事务的连锁问题:
  1. 旧 tuple 不能被 VACUUM 清理(事务可能还要看到)
  2. clog 不能截断
  3. 复制槽(replication slot)可能阻塞 WAL 删除
  4. 表空间持续膨胀

排查:
  SELECT pid, age(now(), xact_start) AS duration, query
  FROM pg_stat_activity
  WHERE state = 'idle in transaction';

  → 长事务杀掉:SELECT pg_terminate_backend(pid);

八、常见问题排查

8.1 死锁

1
2
3
4
5
-- MySQL 看最近一次死锁
SHOW ENGINE INNODB STATUS\G

-- PostgreSQL 看日志
-- 默认会记录:ERROR: deadlock detected

8.2 锁等待

1
2
3
4
5
6
-- MySQL 看锁等待
SELECT * FROM information_schema.innodb_trx;
SELECT * FROM performance_schema.data_locks;

-- PostgreSQL 看锁
SELECT * FROM pg_locks WHERE NOT granted;

8.3 长事务

1
2
3
4
5
6
7
8
9
-- MySQL
SELECT * FROM information_schema.innodb_trx
WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 60;

-- PostgreSQL
SELECT pid, xact_start, query
FROM pg_stat_activity
WHERE state IN ('active', 'idle in transaction')
ORDER BY xact_start;

九、小结

本文学习了 MVCC 与隔离级别:

  • 四种并发异常:脏读、不可重复读、幻读、丢失更新
  • MVCC 的核心:多版本 + 可见性判断
  • 四家 MVCC 实现:Oracle Undo、InnoDB ReadView、PG xmin/xmax、SQL Server TempDB
  • 隔离级别实现差异:MySQL RR 防幻读(Next-Key Lock),PG RR 不防
  • Serializable 的真假:传统 2PL vs SSI
  • PostgreSQL 的表膨胀与 VACUUM 机制
  • 长事务的危害
1
2
3
4
记住三句话:
  1. RC 是语句级快照,RR 是事务级快照,Serializable 是真串行
  2. PG 没有 Undo,旧 tuple 留在 heap → 必须配置 autovacuum
  3. MySQL InnoDB 默认 RR + Next-Key Lock 防幻读,但代价是死锁风险

下一篇进入锁与并发控制:从行锁到死锁检测,深入 InnoDB 的 Record/Gap/Next-Key 三档锁、SQL Server 的锁升级、PostgreSQL 的 Advisory Lock。