05. MVCC 多版本并发控制

深入理解 InnoDB MVCC 机制:Undo Log 版本链、ReadView 可见性判断算法、不同隔离级别下的行为差异。

1. MVCC 核心思想

MVCC(Multi-Version Concurrency Control) 通过保存数据的多个历史版本,实现 读不阻塞写、写不阻塞读,避免加锁带来的并发性能损失。

无 MVCC(锁模型):
  事务 A 读 Row X ──→ 加读锁
                         │
  事务 B 写 Row X ──→ 阻塞等待读锁释放

有 MVCC(版本链):
  事务 A 读 Row X ──→ 获取历史版本 V1(无需加锁)
                         │
  事务 B 写 Row X ──→ 创建新版本 V2(事务 B 的 trx_id)
                         ↓
                         V1 保留在 Undo Log 中,供 A 继续读取

2. 版本链与 Undo Log

2.1 行结构

InnoDB 每行存储的隐藏列:

┌───────────┬───────────┬───────────┬────────┐
│ DB_TRX_ID │ DB_ROLL_PTR│ ...数据列... │ 主键   │
│  (6B)     │   (7B)    │           │        │
└───────────┴───────────┴───────────┴────────┘

DB_TRX_ID:最后修改该行的事务 ID
DB_ROLL_PTR:指向 Undo Log 的指针

2.2 版本链形成

初始状态:
  Row: [trx_id=50, roll_ptr=NULL, value='Alice']

事务 100 更新:
  UPDATE users SET name = 'Bob' WHERE id = 1;
  
  → 创建 Undo Log 记录旧值:
    Undo Log: [trx_id=50, value='Alice', prev_ptr=NULL]
  
  → 更新行:
    Row: [trx_id=100, roll_ptr→Undo Log, value='Bob']

事务 101 再更新:
  UPDATE users SET name = 'Cathy' WHERE id = 1;
  
  → 创建新 Undo Log 记录旧值:
    Undo Log 2: [trx_id=100, value='Bob', prev_ptr→Undo Log 1]
  
  → 更新行:
    Row: [trx_id=101, roll_ptr→Undo Log 2, value='Cathy']

版本链:
  Row → Undo Log 2 (trx=100, val='Bob') → Undo Log 1 (trx=50, val='Alice') → NULL

3. ReadView 与可见性判断

3.1 ReadView 结构

ReadView(快照):
┌──────────────┬──────────────┬──────────────┐
│  creator_trx_id │  min_trx_id   │  max_trx_id  │  m_ids  │
│  创建者事务ID   │  最小活跃事务  │  最大待分配ID │ 活跃列表 │
└──────────────┴──────────────┴──────────────┘

m_ids:创建 ReadView 时所有未提交的事务 ID 列表

3.2 可见性判断规则

对于某行的 trx_id(最后修改它的事务 ID):

1. trx_id == creator_trx_id → 可见(自己修改的)

2. trx_id < min_trx_id → 可见(事务已提交)

3. trx_id >= max_trx_id → 不可见(事务在 ReadView 创建后启动)

4. min_trx_id <= trx_id < max_trx_id:
   - trx_id 在 m_ids 中 → 不可见(事务活跃,未提交)
   - trx_id 不在 m_ids 中 → 可见(事务已提交)

5. 如果不可见 → 沿 roll_ptr 回溯 Undo Log,找上一个版本
   → 重复判断直到找到可见版本或链结束

3.3 RC vs RR 隔离级别

隔离级别ReadView 生成时机效果
READ COMMITTED每条 SELECT 语句每次读都看到最新已提交数据(不可重复读)
REPEATABLE READ事务首次 SELECT整个事务看到同一快照(可重复读)
RC 场景(ReadView 每次重新生成):
  T1 时刻:ReadView 看到 value='Alice'
  T2 时刻:另一事务提交 value='Bob'
  T3 时刻:新 ReadView 看到 value='Bob'  ← 不可重复读

RR 场景(ReadView 只生成一次):
  T1 时刻:ReadView 看到 value='Alice'
  T2 时刻:另一事务提交 value='Bob'
  T3 时刻:同一 ReadView 仍看到 value='Alice'  ← 可重复读

4. MVCC 与幻读

MVCC 解决的是快照读(Snapshot Read / Consistent Read)的幻读问题。对于当前读(Current Read),InnoDB 使用**间隙锁(Gap Lock)**解决幻读。

-- 快照读(SELECT):通过 MVCC + ReadView,RR 下无幻读
SELECT * FROM orders WHERE user_id = 100;

-- 当前读(FOR UPDATE / LOCK IN SHARE MODE):通过 Next-Key Lock
SELECT * FROM orders WHERE user_id = 100 FOR UPDATE;
  → 在 user_id=100 的记录上加 Record Lock
  → 在相邻的空隙上加 Gap Lock(阻止新记录插入 user_id=100 的位置)

5. 一致性非锁定读 vs 当前读

MVCC 在读请求中有两种截然不同的行为,开发中必须明确区分:

读类型语句示例实现机制是否阻塞写是否看到最新已提交数据
一致性非锁定读 (快照读)SELECT ... FROM tMVCC + ReadView否否(RR下看到快照)
当前读 (锁定读)SELECT ... FOR UPDATE行锁 + 间隙锁是是
当前读 (共享锁)SELECT ... LOCK IN SHARE MODE行锁(S锁)是是
INSERT / UPDATE / DELETE自动隐式当前读行锁 + 间隙锁是是

关键区别:

  • 快照读:读取数据的历史版本(通过 Undo Log 回溯),不加锁,不阻塞任何 DML
  • 当前读:读取数据的最新版本,并对读取到的记录加锁(X 或 S 锁),阻塞其他事务的并发写

为什么 UPDATE 需要先执行当前读?

-- 事务 A
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 步骤 1: 当前读 id=1 的最新 balance(加 X 锁)
-- 步骤 2: 计算新值(balance - 100)
-- 步骤 3: 写入新值,生成 Undo Log

-- 如果步骤 1 是快照读,可能读到事务开始时的旧值,
-- 导致 "lost update"(丢失更新)

6. Undo Log 清理:Purge 线程

MVCC 保留了大量历史版本,如何安全清理不再需要的 Undo Log?

Purge 工作原理

Undo Log 空间回收流程:

1. 事务提交 → Undo Log 标记为 "待清理"
2. Purge 后台线程定期检查:
   - 找到当前系统中最小的活跃事务 ID(min_trx_id)
   - 所有 trx_id < min_trx_id 的 Undo Log 记录
     → 不再有事务需要读取 → 可以物理删除
3. 如果存在长事务(未提交):
   - 该事务的 ReadView 阻止对应 Undo Log 的清理
   - Undo 表空间持续增长

Undo 表空间参数

-- 查看 Undo 表空间使用情况
SELECT 
    tablespace_name,
    ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb
FROM information_schema.tables
WHERE tablespace_name LIKE '%undo%'
GROUP BY tablespace_name;

-- MySQL 8.0: Undo Log 独立表空间
SHOW VARIABLES LIKE 'innodb_undo_tablespaces';   -- 默认 2
SHOW VARIABLES LIKE 'innodb_max_undo_log_size';    -- 默认 1GB
SHOW VARIABLES LIKE 'innodb_purge_rseg_truncate_frequency'; -- 清理频率

7. 长事务:MVCC 的头号敌人

长事务是 MVCC 场景下最常见的性能杀手:

危害链式反应:

长事务未提交
    ↓
ReadView 一直不释放
    ↓
Undo Log 无法清理( trx_id >= min_trx_id )
    ↓
Undo 表空间膨胀
    ↓
    ├── 磁盘空间耗尽
    ├── 新事务读取需回溯更长的版本链 → 查询变慢
    └──  SSD 写入放大,寿命缩短

监控与告警:

-- 找出运行超过 60 秒的事务
SELECT 
    trx_id,
    trx_state,
    trx_started,
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_seconds,
    trx_mysql_thread_id,
    trx_tables_locked,
    trx_rows_locked,
    LEFT(trx_query, 100) AS query_preview
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY trx_started;

最佳实践:

  • 业务代码避免在事务中调用外部 HTTP/RPC 接口
  • 大报表查询使用只读副本,不占用主库的 ReadView
  • 连接池配置合理的 maxLifetime(如 30 分钟),防止连接泄漏导致事务悬挂
  • 设置 innodb_lock_wait_timeout = 50 秒,超时自动回滚

5. 总结

MVCC 核心机制:
  写 → 创建新版本 + Undo Log(旧版本保留)
  读 → ReadView 判断可见版本(可能回溯 Undo Log)

带来的好处:
  ✅ 读写不互斥(读不阻塞写,写不阻塞读)
  ✅ 无需读锁,并发性能高
  ✅ RR 级别天然防快照读幻读

代价:
  ❌ Undo Log 膨胀(长事务导致清理不了旧版本)
  ❌ 查询需回溯版本链(极端情况下性能下降)
  ❌ 不能替代写锁(当前读仍需加锁)

生产注意:
  避免长事务!长事务持有的 ReadView 阻止 Undo Log 清理,
  导致 Undo 表空间膨胀,影响性能和空间。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. PostgreSQL 锁与阻塞分析
  2. COPY 与批量数据加载优化
  3. pgvector 向量检索与混合查询