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 t | MVCC + 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 表空间膨胀,影响性能和空间。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。