一、案例背景
假设有一张 account 表:
sql
CREATE TABLE account (
id INT PRIMARY KEY,
name VARCHAR(50),
balance INT
);
INSERT INTO account VALUES (1, 'Alice', 100);
InnoDB 底层,这行数据除了用户可见的字段外,还隐藏着两个关键系统字段:
|---------------|--------|---------------------------------|
| 隐藏字段 | 值(初始) | 含义 |
| DB_TRX_ID | 50 | 最后修改这行数据的事务 ID(假设是初始化事务 50) |
| DB_ROLL_PTR | NULL | 回滚指针,指向 Undo Log 中的旧版本 |
二、时间线与版本链演变
下面按照图中的 T1 ~ T6 时间线,还原完整过程。
T1 时刻:事务 100 开始
-- 事务100
START TRANSACTION; -- 事务ID = 100
SELECT * FROM account WHERE id = 1;
此时生成 ReadView1:
ReadView1:
├─ m_ids = {100} ← 当前活跃事务只有100自己
├─ min_trx_id = 100 ← m_ids中最小值
├─ max_trx_id = 101 ← 系统下一个待分配的事务ID
└─ creator_trx_id = 100 ← 创建者就是事务100自己
可见性判断:
- 读取当前行:
trx_id = 50 50 < min_trx_id(100)→ 在 ReadView1 生成前已提交 → 可见- 返回
balance = 100
T2 时刻:事务 200 开始并修改数据
-- 事务200
START TRANSACTION; -- 事务ID = 200
UPDATE account SET balance = 200 WHERE id = 1;
-- 暂不提交
InnoDB 内部发生了什么?
- 将旧数据
(trx_id=50, balance=100)拷贝到 Undo Log - 修改当前行:
balance = 200,DB_TRX_ID = 200 - 当前行的
DB_ROLL_PTR指向 Undo Log 中的旧版本
此时版本链:
当前行(数据页) Undo Log
├─ trx_id = 200 ←───┐
├─ balance = 200 │
├─ roll_ptr ───────────┘
└─ trx_id = 50
balance = 100
roll_ptr = NULL
注意 :事务 200 未提交,但修改已经写入数据页。其他事务能否看到这个 200?取决于 ReadView 的判断。
T3 时刻:事务 100 再次 SELECT(RR 级别,复用 ReadView1)
-- 事务100(仍在事务中)
SELECT * FROM account WHERE id = 1; -- 第二次查询
RR 级别规则 :复用 T1 时刻生成的 ReadView1。
Step 1:读取当前行 → trx_id = 200
判断流程(对照图中 [T3] 部分):
|-------------------------------------|--------------------|------------|
| 判断条件 | 计算 | 结果 |
| trx_id == creator_trx_id | 200 == 100 | ❌ 否 |
| trx_id < min_trx_id | 200 < 100 | ❌ 否 |
| trx_id >= max_trx_id | 200 >= 101 | ❌ 否 |
| min_trx_id <= trx_id < max_trx_id | 100 <= 200 < 201 | ⚠️ 是,进入下一步 |
| trx_id 是否在 m_ids 中 | 200 ∈ {100} | ❌ 不在 |
关键分析:
200不在m_ids中,说明 T1 时刻事务 200 还未开始- 但当前行
trx_id=200已经存在,说明这是 T1 之后创建的新版本 - 由于 ReadView1 是在 T1 生成的,事务 100 只能看到 T1 之前已提交的数据
- 结论: ❌ 不可见!
Step 2:沿 DB_ROLL_PTR找到 Undo Log 1 → trx_id = 100
|----------------------------|--------------|----------|
| 判断条件 | 计算 | 结果 |
| trx_id == creator_trx_id | 100 == 100 | ✅ 是! |
结论: ✅ 可见!返回 balance = 100
事务 100 第二次查询,仍然看到 100,虽然事务 200 已经把数据改成了 200。 这就是 MVCC 实现的可重复读!
T4 时刻:事务 200 提交
-- 事务200
COMMIT;
事务 200 提交后,它的修改成为数据库的"最新值"。但 Undo Log 不会立即删除(因为可能还有其他事务需要读取历史版本)。
T5 时刻:事务 300 开始并 SELECT
-- 事务300
START TRANSACTION; -- 事务ID = 300
SELECT * FROM account WHERE id = 1;
此时生成 ReadView2:
ReadView2:
├─ m_ids = {300}
├─ min_trx_id = 300
├─ max_trx_id = 301
└─ creator_trx_id = 300
Step 1:读取当前行 → trx_id = 200
|----------------------------|--------------|----------|
| 判断条件 | 计算 | 结果 |
| trx_id == creator_trx_id | 200 == 300 | ❌ 否 |
| trx_id < min_trx_id | 200 < 300 | ✅ 是! |
结论: ✅ 可见!返回 balance = 200
200 < 300意味着:事务 200 在 ReadView2 生成之前就已经提交了。 所以事务 300 能看到最新值。
T6 时刻:事务 100 第三次 SELECT(仍在事务中)
-- 事务100(还没提交!)
SELECT * FROM account WHERE id = 1; -- 第三次查询
RR 级别 :仍然复用 ReadView1(T1 生成的那个)。
判断过程与 T3 完全一致:
|----------------------------|---------------|-----|
| 判断条件 | 计算 | 结果 |
| trx_id == creator_trx_id | 200 == 100 | ❌ 否 |
| trx_id < min_trx_id | 200 < 100 | ❌ 否 |
| trx_id >= max_trx_id | 200 >= 101 | ❌ 否 |
| trx_id 在 m_ids{100} 中 | 200 ∈ {100} | ❌ 否 |
结论: ❌ 不可见!沿版本链继续找 → Undo Log 1 ( trx_id=100) → ✅ 可见!
返回 balance = 100。
即使事务 200 已经提交,事务 100 仍然看到 100。 这就是 RR(可重复读) 的精髓 ------ 事务内看到的数据快照始终一致。
三、可见性判断规则总结(对照图)
┌────────────────────────────────────────────────────────────────────────┐
│ 可见性判断算法(核心) │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ① trx_id == creator_trx_id ? │
│ → YES: 自己修改的,可见 ✅ │
│ │
│ ② trx_id < min_trx_id ? │
│ → YES: 在 ReadView 生成前已提交,可见 ✅ │
│ │
│ ③ trx_id >= max_trx_id ? │
│ → YES: 在 ReadView 生成后才创建,不可见 ❌(找旧版本) │
│ │
│ ④ min_trx_id <= trx_id < max_trx_id ? │
│ → 检查 trx_id 是否在 m_ids 中: │
│ • IN m_ids → 生成 ReadView 时仍活跃(未提交),不可见 ❌ │
│ • NOT IN m_ids → 生成 ReadView 前已提交,可见 ✅ │
│ │
└────────────────────────────────────────────────────────────────────────┘
四、RC vs RR 的关键区别(案例对比)
|---------------------|------------------------------------------------|----------------------------|
| 场景 | RC(读已提交) | RR(可重复读) |
| T3 事务100 SELECT | 生成新 ReadView,看到 200(脏读?不,200未提交)→ 实际看到 100 | 复用 ReadView1,看到 100 |
| T5 事务300 SELECT | 生成新 ReadView,看到 200 | 生成新 ReadView,看到 200 |
| T6 事务100 SELECT | 生成新 ReadView ,200 < min_trx_id,看到 200 | 复用 ReadView1 ,看到 100 |
核心差异:
RC: 每次 SELECT 都生成新 ReadView
→ 能读到其他事务最新已提交的数据
→ 同一事务内两次读取结果可能不同(不可重复读)
RR: 事务内第一次 SELECT 生成 ReadView,之后复用
→ 整个事务期间数据快照一致
→ 同一事务内多次读取结果相同(可重复读)
五、总结
"MVCC 的可见性判断,我用一个实际案例来说明:
假设表里有 balance=100,事务 100 先开始并查询,生成 ReadView1(m_ids={100}, min=100, max=101)。此时事务 200 把 balance 改成 200 但未提交。事务 100 再次查询时,读取当前行 trx_id=200,发现 200 不在 m_ids 中且 200 >= max_trx_id 不成立,说明这是 ReadView1 生成之后才出现的版本,不可见 。于是沿着 DB_ROLL_PTR 回滚指针找到 Undo Log 中的旧版本 trx_id=100,发现就是自己修改的,可见 ,返回 balance=100。
之后事务 200 提交,事务 300 开始查询,生成 ReadView2(min=300),读取当前行 trx_id=200,200 < 300,说明在 ReadView2 生成前已提交,可见 ,返回 balance=200。
而事务 100 第三次查询,RR 级别下仍然复用 ReadView1,判断逻辑和第二次完全一致,仍然返回 balance=100。这就实现了可重复读。
判断规则的本质就是四步比较:creator_trx_id → min_trx_id → max_trx_id → m_ids,确保事务只能看到在它 ReadView 生成之前已经提交的数据。"
下载案例图解:
