摘要:从一次"明明刚插入的数据却查不到"的诡异现象出发,深度剖析MySQL MVCC多版本并发控制的核心原理。通过隐藏字段、undo log版本链、Read View可见性判断的完整图解,揭秘为什么同一时刻不同事务看到的数据不一样、RR隔离级别如何防止不可重复读、以及快照读与当前读的本质区别。手写100行代码模拟MVCC机制,配合时序图展示版本链构建过程。
💥 翻车现场
周四下午,测试同学报了个诡异的bug。
测试同学:@哈吉米 订单数据有问题!
哈吉米:啥问题?
测试同学:我刚创建了订单,立马查询,显示"订单不存在"。过了2秒再查,订单又出现了!
测试步骤:
-- 终端1(测试同学)
START TRANSACTION;
SELECT * FROM `order` WHERE order_id = 100001;
-- 结果:Empty set(订单不存在)
-- 终端2(哈吉米)
START TRANSACTION;
INSERT INTO `order` (order_id, user_id, amount) VALUES (100001, 10086, 100);
COMMIT; -- 提交了
-- 终端1(测试同学,同一个事务内)
SELECT * FROM `order` WHERE order_id = 100001;
-- 结果:Empty set(还是查不到!)
-- 终端1(测试同学提交事务后)
COMMIT;
SELECT * FROM `order` WHERE order_id = 100001;
-- 结果:能查到了!
测试同学:"明明哈吉米已经提交了,为什么我查不到?"
哈吉米:"这……我也不知道为什么……"
南北绿豆和阿西噶阿西来了。
南北绿豆:"这不是bug,这是MVCC的特性!"
哈吉米:"MVCC?"
阿西噶阿西:"Multi-Version Concurrency Control,多版本并发控制。来,我给你讲讲。"
🤔 MVCC是什么?为什么需要它?
没有MVCC的世界
南北绿豆:"先看看没有MVCC会怎样。"
-- 场景:A和B同时查询同一行数据
-- 时间T1:事务A查询余额
SELECT balance FROM account WHERE user_id = 10086;
-- 结果:1000
-- 时间T2:事务B修改余额
UPDATE account SET balance = 900 WHERE user_id = 10086;
-- 时间T3:事务A再次查询余额
SELECT balance FROM account WHERE user_id = 10086;
-- 问题:读到900(不可重复读)还是1000?
传统解决方案:加锁
- 事务A读取时,加共享锁(S锁)
- 事务B修改时,加排他锁(X锁)
- 事务A持有S锁,事务B等待
- 事务A提交,释放锁,事务B才能修改
缺点:
- 读写互斥(读的时候不能写)
- 并发性能差
MVCC的解决方案
MVCC的核心思想:
不加锁,通过多版本实现并发控制
机制:
1. 每次修改数据,保留旧版本(undo log)
2. 不同事务读取不同版本的数据
3. 读写不互斥(读旧版本,写新版本)
示例:
- 事务A读取:看到version-1(balance=1000)
- 事务B修改:创建version-2(balance=900)
- 事务A再读取:仍然看到version-1(balance=1000)← 可重复读
好处:
- ✅ 读写不阻塞
- ✅ 并发性能好
- ✅ 实现了可重复读
阿西噶阿西:"MVCC的本质是:用空间换时间,用多版本换取高并发。"
🎯 MVCC的3大核心组件
组件1:隐藏字段
南北绿豆:"InnoDB给每一行数据都加了3个隐藏字段。"
-- 用户定义的表
CREATE TABLE account (
user_id BIGINT PRIMARY KEY,
balance DECIMAL(10, 2)
);
-- InnoDB实际存储的结构
CREATE TABLE account (
user_id BIGINT PRIMARY KEY,
balance DECIMAL(10, 2),
-- 以下是隐藏字段(用户看不到,InnoDB内部使用)
DB_TRX_ID BIGINT, -- 最后修改这行的事务ID
DB_ROLL_PTR POINTER, -- 回滚指针,指向undo log的上一个版本
DB_ROW_ID BIGINT -- 隐藏主键(如果表没有主键才有)
);
字段说明:
| 字段 | 含义 | 示例 |
|---|---|---|
| DB_TRX_ID | 最后修改这行的事务ID | 100 |
| DB_ROLL_PTR | 回滚指针,指向历史版本 | 指向undo log地址 |
| DB_ROW_ID | 隐藏主键(可选) | 1 |
组件2:undo log版本链
阿西噶阿西:"每次修改数据,旧版本会保存到undo log,形成版本链。"
示例:
初始数据:
user_id=10086, balance=1000, trx_id=80
事务100:UPDATE account SET balance=900 WHERE user_id=10086;
事务200:UPDATE account SET balance=800 WHERE user_id=10086;
版本链结构:
最新版本(在数据页中)
┌─────────────────────────────────┐
│ user_id: 10086 │
│ balance: 800 │
│ DB_TRX_ID: 200 │ ← 事务200修改的
│ DB_ROLL_PTR: ──────────────┐ │
└─────────────────────────────│───┘
↓
undo log版本2
┌─────────────────────────────────┐
│ user_id: 10086 │
│ balance: 900 │
│ DB_TRX_ID: 100 │ ← 事务100修改的
│ DB_ROLL_PTR: ──────────────┐ │
└─────────────────────────────│───┘
↓
undo log版本1
┌─────────────────────────────────┐
│ user_id: 10086 │
│ balance: 1000 │
│ DB_TRX_ID: 80 │ ← 最初的版本
│ DB_ROLL_PTR: NULL │
└─────────────────────────────────┘
版本链的构建流程:
sequenceDiagram
participant Txn80 as 事务80(初始)
participant Txn100 as 事务100
participant Txn200 as 事务200
participant DataPage as 数据页
participant UndoLog as undo log
Txn80->>DataPage: 1. INSERT balance=1000
Note over DataPage: 版本1: balance=1000, trx_id=80
Txn100->>DataPage: 2. UPDATE balance=900
DataPage->>UndoLog: 3. 保存旧版本(balance=1000, trx_id=80)
DataPage->>DataPage: 4. 更新:balance=900, trx_id=100
Note over DataPage: roll_ptr指向undo log版本1
Txn200->>DataPage: 5. UPDATE balance=800
DataPage->>UndoLog: 6. 保存旧版本(balance=900, trx_id=100)
DataPage->>DataPage: 7. 更新:balance=800, trx_id=200
Note over DataPage: roll_ptr指向undo log版本2
Note over DataPage,UndoLog: 版本链:800(200) → 900(100) → 1000(80)
哈吉米:"所以每次UPDATE,旧版本都保存下来了,形成一个链表?"
南北绿豆:"对!这就是版本链,MVCC的基础。"
组件3:Read View(读视图)
阿西噶阿西:"有了版本链,如何判断哪个版本可见?靠Read View。"
Read View的结构:
// Read View(简化)
class ReadView {
long creator_trx_id; // 创建这个Read View的事务ID
List<Long> m_ids; // 生成Read View时,活跃的事务ID列表
long min_trx_id; // m_ids中的最小值
long max_trx_id; // 生成Read View时,下一个将要分配的事务ID
}
示例:
时刻T1:
- 事务80:已提交
- 事务100:运行中
- 事务200:运行中
- 事务250:刚启动(执行第一个SELECT)
事务250的Read View:
{
creator_trx_id: 250,
m_ids: [100, 200, 250],
min_trx_id: 100,
max_trx_id: 251
}
可见性判断算法
南北绿豆:"有了Read View,判断某个版本是否可见,有4条规则。"
读取某个版本(trx_id=X)时,判断是否可见:
规则1:X == creator_trx_id
→ 可见(自己修改的数据)
规则2:X < min_trx_id
→ 可见(在Read View生成前就提交的事务)
规则3:X >= max_trx_id
→ 不可见(在Read View生成后才启动的事务)
规则4:min_trx_id <= X < max_trx_id
→ 判断X是否在m_ids中
- 在 → 不可见(事务还在运行,未提交)
- 不在 → 可见(事务已提交)
判断流程图:
graph TD
A[读取某个版本 trx_id=X] --> B{X == creator_trx_id?}
B -->|是| C[可见 自己改的]
B -->|否| D{X < min_trx_id?}
D -->|是| E[可见 早就提交了]
D -->|否| F{X >= max_trx_id?}
F -->|是| G[不可见 还没创建]
F -->|否| H{X in m_ids?}
H -->|是| I[不可见 未提交]
H -->|否| J[可见 已提交]
style C fill:#90EE90
style E fill:#90EE90
style J fill:#90EE90
style G fill:#FFB6C1
style I fill:#FFB6C1
完整的查询流程
场景:事务250要读取 user_id=10086 的数据
1. 读取最新版本:balance=800, trx_id=200
↓
2. 判断可见性:trx_id=200
- 200 == 250? 否
- 200 < 100? 否
- 200 >= 251? 否
- 200 in [100, 200, 250]? 是!
↓
3. 不可见(事务200未提交)
↓
4. 沿着roll_ptr找上一个版本
↓
5. 读取版本2:balance=900, trx_id=100
↓
6. 判断可见性:trx_id=100
- 100 in [100, 200, 250]? 是!
↓
7. 不可见(事务100未提交)
↓
8. 继续找上一个版本
↓
9. 读取版本1:balance=1000, trx_id=80
↓
10. 判断可见性:trx_id=80
- 80 < 100? 是!
↓
11. 可见!返回 balance=1000 ✅
哈吉米:"所以MVCC就是沿着版本链,找到第一个可见的版本?"
南北绿豆:"对!这就是多版本并发控制的核心逻辑。"
🎯 RC vs RR:Read View的生成时机
READ COMMITTED(读已提交)
特点:每次SELECT都生成新的Read View
-- 事务B(RC隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 10086;
-- 结果:1000
-- 生成Read View 1:m_ids = [100, 200]
-- 事务A(同时进行)
START TRANSACTION; -- 事务ID=200
UPDATE account SET balance = 900 WHERE user_id = 10086;
COMMIT; -- 提交
-- 事务B再次查询(同一个事务内)
SELECT balance FROM account WHERE user_id = 10086;
-- 结果:900(变了!)
-- 生成Read View 2:m_ids = [100](事务200已提交,不在活跃列表)
-- 问题:同一个事务内,两次读取结果不同(不可重复读)
REPEATABLE READ(可重复读)
特点:整个事务只生成一次Read View(第一次SELECT时)
-- 事务B(RR隔离级别)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT balance FROM account WHERE user_id = 10086;
-- 结果:1000
-- 生成Read View:m_ids = [100, 200]
-- 事务A(同时进行)
START TRANSACTION; -- 事务ID=200
UPDATE account SET balance = 900 WHERE user_id = 10086;
COMMIT; -- 提交
-- 事务B再次查询(同一个事务内)
SELECT balance FROM account WHERE user_id = 10086;
-- 结果:仍然是1000(没变!)
-- 复用之前的Read View:m_ids = [100, 200]
-- trx_id=200 仍在m_ids中,所以看不到事务200的修改
-- 结果:可重复读 ✅
RC vs RR对比
时序对比图:
READ COMMITTED:
T1: 事务B启动
T2: 事务B第1次SELECT → 生成Read View 1
T3: 事务A修改并提交
T4: 事务B第2次SELECT → 生成Read View 2(新的)
↓
Read View 2能看到事务A的修改
↓
不可重复读 ❌
REPEATABLE READ:
T1: 事务B启动
T2: 事务B第1次SELECT → 生成Read View
T3: 事务A修改并提交
T4: 事务B第2次SELECT → 复用之前的Read View
↓
Read View不变,看不到事务A的修改
↓
可重复读 ✅
对比表:
| 隔离级别 | Read View生成时机 | 可重复读 | 性能 |
|---|---|---|---|
| RC | 每次SELECT生成 | ❌ | ⭐⭐⭐⭐ |
| RR | 第一次SELECT生成,之后复用 | ✅ | ⭐⭐⭐ |
哈吉米:"原来RC和RR的区别就是Read View的生成时机!"
🎯 快照读 vs 当前读
快照读(Snapshot Read)
定义:读取快照版本(历史版本),走MVCC,不加锁。
-- 这些都是快照读
SELECT * FROM account WHERE user_id = 10086;
SELECT COUNT(*) FROM account;
特点:
- ✅ 不加锁
- ✅ 读取历史版本
- ✅ 并发性能好
当前读(Current Read)
定义:读取最新版本,加锁。
-- 这些都是当前读
SELECT * FROM account WHERE user_id = 10086 FOR UPDATE; -- 加排他锁
SELECT * FROM account WHERE user_id = 10086 LOCK IN SHARE MODE; -- 加共享锁
INSERT INTO account VALUES (...); -- 加排他锁
UPDATE account SET balance = 900 WHERE user_id = 10086; -- 加排他锁
DELETE FROM account WHERE user_id = 10086; -- 加排他锁
特点:
- ❌ 加锁
- ✅ 读取最新数据
- ⚠️ 并发性能差
为什么需要当前读?
场景:转账业务
START TRANSACTION;
-- 如果用快照读
SELECT balance FROM account WHERE user_id = 10086;
-- 读到快照:balance=1000
-- 同时另一个事务扣了200
-- 实际余额已经是800了
-- 但你基于1000判断
IF balance >= 500 THEN
UPDATE account SET balance = balance - 500 WHERE user_id = 10086;
-- 实际变成了300,但应该余额不足!
END IF;
COMMIT;
正确写法:用当前读
START TRANSACTION;
-- 用当前读(加锁)
SELECT balance FROM account WHERE user_id = 10086 FOR UPDATE;
-- 锁住这行,读到最新数据:balance=800
IF balance >= 500 THEN
UPDATE account SET balance = balance - 500 WHERE user_id = 10086;
ELSE
-- 余额不足 ✅
END IF;
COMMIT;
对比:
| 读取方式 | 是否加锁 | 读取数据 | 适用场景 |
|---|---|---|---|
| 快照读 | ❌ | 历史版本 | 查询(不修改数据) |
| 当前读 | ✅ | 最新数据 | 修改前的查询 |
南北绿豆:"记住:查询用快照读,修改前用当前读。"
🎯 手写MVCC模拟器
哈吉米:"能不能自己实现一个MVCC的版本链和Read View?"
阿西噶阿西:"来,100行代码模拟核心逻辑!"
/**
* MVCC模拟器
*/
public class MVCCSimulator {
// 数据版本
static class DataVersion {
String value; // 数据值(如balance=1000)
long trxId; // 修改这个版本的事务ID
DataVersion prev; // 上一个版本(模拟roll_ptr)
public DataVersion(String value, long trxId, DataVersion prev) {
this.value = value;
this.trxId = trxId;
this.prev = prev;
}
}
// Read View
static class ReadView {
long creatorTrxId; // 当前事务ID
List<Long> activeIds; // 活跃事务列表(m_ids)
long minTrxId; // 最小活跃事务ID
long maxTrxId; // 下一个将要分配的事务ID
// 判断某个版本是否可见
public boolean isVisible(long trxId) {
// 规则1:自己修改的可见
if (trxId == creatorTrxId) {
return true;
}
// 规则2:在Read View生成前就提交的可见
if (trxId < minTrxId) {
return true;
}
// 规则3:在Read View生成后才启动的不可见
if (trxId >= maxTrxId) {
return false;
}
// 规则4:在活跃列表中的不可见
return !activeIds.contains(trxId);
}
}
/**
* 查找可见版本
*/
public static DataVersion findVisibleVersion(DataVersion latest, ReadView readView) {
DataVersion current = latest;
System.out.println("===== 查找可见版本 =====");
System.out.println("Read View: " + readView.activeIds);
while (current != null) {
System.out.println("检查版本: " + current.value + ", trx_id=" + current.trxId);
if (readView.isVisible(current.trxId)) {
System.out.println("→ 可见 ✅");
return current;
} else {
System.out.println("→ 不可见,继续找上一个版本");
}
current = current.prev;
}
System.out.println("没有可见版本");
return null;
}
/**
* 测试
*/
public static void main(String[] args) {
// 构造版本链:800(200) → 900(100) → 1000(80)
DataVersion v1 = new DataVersion("balance=1000", 80, null);
DataVersion v2 = new DataVersion("balance=900", 100, v1);
DataVersion v3 = new DataVersion("balance=800", 200, v2);
// 模拟事务250的Read View
ReadView readView = new ReadView();
readView.creatorTrxId = 250;
readView.activeIds = Arrays.asList(100L, 200L, 250L);
readView.minTrxId = 100;
readView.maxTrxId = 251;
// 查找可见版本
DataVersion visible = findVisibleVersion(v3, readView);
if (visible != null) {
System.out.println("\n最终可见版本:" + visible.value);
}
}
}
运行结果:
===== 查找可见版本 =====
Read View: [100, 200, 250]
检查版本: balance=800, trx_id=200
→ 不可见,继续找上一个版本
检查版本: balance=900, trx_id=100
→ 不可见,继续找上一个版本
检查版本: balance=1000, trx_id=80
→ 可见 ✅
最终可见版本:balance=1000
哈吉米:"原来MVCC就是:沿着版本链找,直到找到可见的版本!"
🎯 回到翻车现场:为什么查不到数据?
阿西噶阿西:"现在可以解释开头的现象了。"
-- 终端1(事务B,事务ID=100)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM `order` WHERE order_id = 100001;
-- 结果:Empty set
-- 生成Read View:m_ids = [100, 200]
-- 终端2(事务A,事务ID=200)
START TRANSACTION;
INSERT INTO `order` (order_id, user_id, amount) VALUES (100001, 10086, 100);
-- 数据版本:order_id=100001, trx_id=200
COMMIT;
-- 终端1(事务B,同一个事务内)
SELECT * FROM `order` WHERE order_id = 100001;
-- 查询到数据:order_id=100001, trx_id=200
-- 判断可见性:trx_id=200 in m_ids? 是
-- 不可见!
-- 结果:Empty set(查不到)
-- 原因:
// 1. RR隔离级别,Read View只生成一次
// 2. m_ids = [100, 200](事务200在活跃列表中)
// 3. 虽然事务200已提交,但Read View认为它"未提交"
// 4. 所以看不到
南北绿豆:"这就是RR隔离级别的可重复读特性:在事务B看来,事务200的数据一直不可见!"
🎓 面试标准答案
题目:MVCC是什么?如何实现的?
答案:
MVCC(Multi-Version Concurrency Control):多版本并发控制
作用:
- 解决并发读写问题
- 读写不互斥(读旧版本,写新版本)
- 实现RC和RR隔离级别
核心组件:
1. 隐藏字段
- DB_TRX_ID:事务ID
- DB_ROLL_PTR:回滚指针
2. undo log版本链
- 每次修改保留旧版本
- 形成链表:新版本 → 旧版本 → 更旧版本
3. Read View
- creator_trx_id:当前事务ID
- m_ids:活跃事务列表
- min_trx_id:最小活跃事务ID
- max_trx_id:下一个事务ID
可见性判断:
- 自己修改的 → 可见
- 早就提交的 → 可见
- 还没创建的 → 不可见
- 在活跃列表中 → 不可见
RC vs RR:
- RC:每次SELECT生成Read View
- RR:第一次SELECT生成,之后复用
题目:快照读和当前读的区别?
答案:
| 特性 | 快照读 | 当前读 |
|---|---|---|
| SQL | SELECT | SELECT FOR UPDATE、INSERT、UPDATE、DELETE |
| 是否加锁 | ❌ | ✅ |
| 读取数据 | 历史版本(快照) | 最新版本 |
| 实现机制 | MVCC | 加锁 |
| 适用场景 | 查询 | 修改前的查询 |
核心:
- 快照读:走MVCC,不加锁,读历史版本
- 当前读:加锁,读最新数据
建议:
- 普通查询用快照读
- 修改前的查询用当前读(SELECT FOR UPDATE)
🎉 结束语
晚上11点,哈吉米终于彻底理解了MVCC。
哈吉米:"原来MVCC就是:隐藏字段 + 版本链 + Read View,三者配合实现多版本并发控制!"
南北绿豆:"对,每次读取数据,都沿着版本链找到可见的版本。"
阿西噶阿西:"记住:RC每次生成Read View,RR只生成一次,这是可重复读的关键。"
哈吉米:"还有快照读和当前读的区别,快照读走MVCC不加锁,当前读加锁读最新数据。"
南北绿豆:"对,理解了MVCC,就理解了MySQL如何在高并发下保证数据一致性!"
记忆口诀:
MVCC多版本控制,读写不互斥并发高
隐藏字段记事务,版本链条连历史
Read View判可见,四条规则要记牢
RC每次生成新,RR复用一个View
快照读取走MVCC,当前读取要加锁