MVCC(Multi-Version Concurrency Control,多版本并发控制)是 InnoDB 实现高并发读写不阻塞的核心机制。通过为每行数据维护多个版本,SELECT 读取不需加锁即可获取一致性快照,大幅提高并发性能。本文深入解析 MVCC 的底层实现。
1. MVCC 核心思想
传统数据库使用锁机制控制并发,读请求可能被写请求阻塞。MVCC 的思想是:
为每个事务提供数据库在某一时刻的快照(Snapshot),读写互不阻塞。
传统锁模型:
T1(写) ──► [X Lock] 数据
│
T2(读) ────────► 阻塞等待!
MVCC 模型:
T1(写) ──► 数据 v1 ──► 修改 ──► 数据 v2(新版本)
│ │
T2(读) ────────► 读取 v1 版本(不阻塞!)
2. 隐藏字段:每行数据的额外信息
InnoDB 在每行数据后自动添加三个隐藏字段:
| 隐藏字段 | 大小 | 含义 |
|---|---|---|
DB_TRX_ID | 6 bytes | 最后修改该记录的事务 ID |
DB_ROLL_PTR | 7 bytes | 回滚指针,指向 Undo Log 中的历史版本 |
DB_ROW_ID | 6 bytes | 隐藏主键(当表没有显式主键时使用) |
2.1 数据结构可视化
┌─────────────────────────────────────────────────────────────┐
│ 聚簇索引记录格式 │
├─────────────────────────────────────────────────────────────┤
│ 用户定义的列(id, name, balance...) │
├─────────────────────────────────────────────────────────────┤
│ DB_TRX_ID (6B) │ 修改此记录的事务号 │
├─────────────────────────────────────────────────────────────┤
│ DB_ROLL_PTR (7B) │ Undo Log 地址: │
│ │ Roll Segment(1B) + Page Offset + Slot │
├─────────────────────────────────────────────────────────────┤
│ DB_ROW_ID (6B) │ 隐藏主键(无主键表时使用) │
└─────────────────────────────────────────────────────────────┘
2.2 事务 ID 生成
-- 查看当前服务器最旧和最新的事务 ID
SELECT MIN(TRX_ID) min_trx, MAX(TRX_ID) max_trx
FROM information_schema.INNODB_TRX;
-- InnoDB 内部使用 trx_id_t(64位整数),重启后自增不重置
3. Undo Log 与版本链
3.1 版本链结构
每次 UPDATE 或 DELETE 时,InnoDB 不直接修改原有记录,而是通过 Undo Log 保留旧版本:
当前值:balance = 800, TX=10
│ DB_ROLL_PTR 指向
▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 版本2 (TX=10) │───►│ 版本1 (TX=5) │───►│ 版本0 (TX=1) │───► NULL
│ balance = 800 │PTR │ balance = 500 │PTR │ balance = 1000 │
└─────────────────┘ └─────────────────┘ └─────────────────┘
当前值的 DB_TRX_ID = 10(事务 ID),DB_ROLL_PTR 指向事务 5 的版本。
事务 5 的版本 DB_ROLL_PTR 再指向事务 1 的版本,依此类推形成版本链。
3.2 INSERT 与 DELETE 的特殊处理
| 操作 | Undo Log 类型 | 版本链特征 |
|---|---|---|
| INSERT | TRX_UNDO_INSERT | 新插入行的 DB_TRX_ID = 当前事务 ID,之前无版本 |
| UPDATE | TRX_UNDO_UPD_EXIST | 旧值写入 Undo,当前行 DB_TRX_ID 更新 |
| DELETE | TRX_UNDO_DEL_MARK | 标记删除,DB_TRX_ID 记录删除事务 ID |
4. Read View:可见性判定的快照
Read View 是 MVCC 实现**一致性读(Consistent Read)**的核心数据结构,决定在事务执行期间,能看到哪些版本的数据。
4.1 Read View 结构
struct read_view_t {
trx_id_t low_limit_id; // 高水位:当前活跃事务中最大的事务 ID + 1
trx_id_t up_limit_id; // 低水位:当前活跃事务中最小的事务 ID
trx_id_t creator_trx_id; // 创建此 Read View 的事务 ID
ids_t trx_ids; // 创建 Read View 时所有未提交事务的 ID 列表
};
4.2 可见性判断算法
对于一条数据记录的 DB_TRX_ID:
def is_visible(record.trx_id, read_view):
if record.trx_id == read_view.creator_trx_id:
return True # 当前事务自己的修改总是可见
if record.trx_id < read_view.up_limit_id:
return True # 在 Read View 创建前已提交
if record.trx_id >= read_view.low_limit_id:
return False # 在 Read View 创建后启动的事务,不可见
if record.trx_id in read_view.trx_ids:
return False # 创建 Read View 时仍处于活跃(未提交)
return True # 活跃列表中没有,说明已提交
4.3 不可见时的回溯流程
def read_record(view, row):
while True:
if is_visible(row.trx_id, view):
return row # 找到可见版本,返回
if row.roll_ptr is None:
return None # 版本链到头,记录已不存在
row = fetch_undo_log(row.roll_ptr) # 沿着 Undo 链回溯上一版本
5. 不同隔离级别下的 Read View
5.1 READ COMMITTED(每次 SELECT 创建新 Read View)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN; -- T1, TRX_ID = 100
SELECT * FROM accounts WHERE id = 1; -- 创建 RV1,看到已提交的数据
-- T2 修改并 COMMIT
SELECT * FROM accounts WHERE id = 1; -- 创建 RV2,看到 T2 的修改!
COMMIT;
每次 SELECT 重新获取当前活跃事务列表。因此可以读到其他事务新提交的数据 → 不可重复读。
5.2 REPEATABLE READ(事务开始时创建 Read View)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN; -- T1, TRX_ID = 100
SELECT * FROM accounts WHERE id = 1; -- 创建 RV(仅一次)
-- T2 修改并 COMMIT
SELECT * FROM accounts WHERE id = 1; -- 复用同一个 RV,仍看到旧数据 ✅
COMMIT;
整个事务期间使用同一个 Read View,确保可重复读。
5.3 对比可视化
READ COMMITTED(每次查询刷新视图):
时间点: t1 t2(T2提交) t3
T1 Select RV1[A=100] ──► 是,A 仍为 100
▲
T2 Update A=200
T1 Select2 ──► RV2[A=200] ──► 不可重复读!A=200
========================================
REPEATABLE READ(事务开始后视图固定):
时间点: t1 t2(T2提交) t3
T1 Select RV[A=100] ──► 是,A 仍为 100
▲
T2 Update A=200
T1 Select2 ──► 复用 RV[A=100] ──► 可重复读 ✅
6. 快照读 vs 当前读
6.1 快照读(Snapshot Read / Consistent Read)
不加锁的 SELECT,读取创建 Read View 时可见的快照数据:
SELECT * FROM accounts WHERE id = 1; -- 快照读
SELECT * FROM accounts WHERE balance > 0; -- 快照读
特点:
- 不加锁,不阻塞其他事务的读写
- 可能读到旧版本数据(根据隔离级别)
- InnoDB 默认行为
6.2 当前读(Current Read)
读取最新已提交数据,并加锁:
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- X 锁 + 当前读
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE; -- S 锁
UPDATE accounts SET balance = 100 WHERE id = 1; -- 当前读 + 更新
DELETE FROM accounts WHERE id = 1; -- 当前读 + 删除
INSERT INTO accounts VALUES (...); -- 当前读范式
当前读的语义:必须看到最新的数据并对其进行锁定。因此在 REPEATABLE READ 下也会产生幻读(需要通过 Gap Lock 解决)。
6.3 两者对比
| 维度 | 快照读 | 当前读 |
|---|---|---|
| SQL 形式 | 纯 SELECT | SELECT … FOR UPDATE / UPDATE / DELETE / INSERT |
| 读取版本 | 历史版本(Read View) | 当前最新版本 |
| 是否加锁 | 不加锁 | 加锁(X/S/Next-Key Lock) |
| 是否阻塞写 | 不阻塞 | 阻塞(X 锁) |
| 性能 | 高 | 存在锁竞争 |
| 出现幻读 | REPEATABLE READ 下不幻读 | REPEATABLE READ 下可能幻读 |
7. MVCC 与幻读的关系
7.1 为什么 MVCC 不能解决当前读的幻读?
-- T1, REPEATABLE READ
BEGIN;
SELECT * FROM accounts WHERE balance > 500; -- 快照读,看到 [A(600), B(700)]
-- T2
INSERT INTO accounts (name, balance) VALUES ('C', 600);
COMMIT;
-- T1 使用当前读
SELECT * FROM accounts WHERE balance > 500 FOR UPDATE;
-- 看到 [A(600), B(700), C(600)] ← 幻读!
原因:MVCC 的快照读可防止幻读,但当前读读取最新版本(不经过 Read View),因此可能看到新插入的行。
7.2 Gap Lock 解决当前读幻读
InnoDB 的 FOR UPDATE / LOCK IN SHARE MODE 使用 Next-Key Lock(记录锁 + 间隙锁):
-- balance > 500 的范围会被加上 Gap Lock
-- 不仅锁住现有记录,还锁住区间 (500, +∞) 中的"间隙"
-- T2 的 INSERT 被阻塞,直到 T1 COMMIT
8. Purge:清理过期版本
MVCC 产生大量历史版本,不能无限堆积。InnoDB 后台的 Purge 线程负责清理不再需要的 Undo Log:
-- Purge 关键参数
SHOW VARIABLES LIKE 'innodb_purge%';
-- innodb_purge_threads = 4 -- Purge 线程数
-- innodb_purge_batch_size = 300 -- 每批清理页数
-- 检查 Purge 滞后
SHOW ENGINE INNODB STATUS;
-- Purge done for trx's n:o < 123456 undo n:o < 0
当长时间运行的大事务不提交时,它会阻塞其 TRX_ID 之后的 Purge 进度,导致:
- Undo 表空间无限膨胀(ibdata 文件增大)
- Buffer Pool 被旧版本数据污染
监控脚本:
-- 发现长事务和风险
SELECT
trx_id,
trx_mysql_thread_id,
trx_state,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) as trx_seconds,
LEFT(trx_query, 100) as query_preview
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
9. 总结
MVCC 是 InnoDB 并发控制的核心设计:
事务开始
│
▼
Read View(可见性规则快照)
│
├── 快照读(Consistent Read)
│ └── 读取历史的可见版本(无锁)
│
└── 当前读(Current Read)
└── 读取最新版本 + 加锁
└── Next-Key Lock 防止幻读
| 隔离级别 | 读类型 | 幻读可能性 | 实现手段 |
|---|---|---|---|
| READ COMMITTED | 快照读 | ✓(每次刷新视图范围可能变) | MVCC, 每次 SELECT 新 Read View |
| REPEATABLE READ | 快照读 | ✗ | MVCC, 事务开始固定 Read View |
| REPEATABLE READ | 当前读 | 若无 Gap Lock 则 ✓ | MVCC + Next-Key Lock |
深入理解 MVCC 与 Read View 的机制,是分析复杂并发问题、设计高并发读写策略的基础。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。