MySQL的MVCC时光机器大揭秘 ⏰

37 阅读13分钟

一、开篇故事:图书馆的借阅困境 📚

想象你在一个魔法图书馆工作:

场景1:没有MVCC的图书馆

小明:"我要看《MySQL指南》。"
管理员:"给你!" (把书锁在小明桌上🔒)

小红:"我也要看《MySQL指南》。"
管理员:"等着吧,小明还没看完..." 😴

小刚:"我只是想瞄一眼目录而已!"
管理员:"不行,被锁了,你也得等!" 💢

结果:大家排队等一本书,效率极低!

场景2:有MVCC的魔法图书馆

小明:"我要看《MySQL指南》。"
管理员:"给你!" (复制一个快照给小明📖)

小红:"我也要看《MySQL指南》。"
管理员:"给你!" (复制另一个快照给小红📖)

小刚:"我想改书的内容。"
管理员:"你改新版本,不影响别人看旧版本!" ✨

结果:大家都能同时看书,互不影响!✅

这就是**MVCC(Multi-Version Concurrency Control,多版本并发控制)**的魔力!


二、什么是MVCC?🤔

2.1 定义

MVCC是一种并发控制机制,通过保存数据的多个版本,让读操作不加锁,写操作也不阻塞读操作,从而实现高并发。

2.2 核心思想

传统方式:读写互斥
  读操作 ←→ 写操作(互相阻塞)
  
MVCC方式:读写并发
  读操作 ← 读旧版本
  写操作 → 写新版本
  (互不影响!)

2.3 生活类比

传统锁机制: 像一个单间厕所🚽

  • 有人在里面,其他人必须等待
  • 即使只是洗手也得等

MVCC机制: 像多个平行宇宙🌌

  • 每个人都有自己的宇宙版本
  • 你看到的是你进入时的宇宙快照
  • 别人修改不影响你看到的版本

三、MVCC的核心组件 🎯

3.1 三大核心

MVCC = 隐藏字段 + Undo Log + Read View
         ↓          ↓          ↓
       身份证    历史记录    时光眼镜

3.2 组件详解

组件作用生活类比
隐藏字段记录事务ID和回滚指针身份证(记录你的ID)
Undo Log存储历史版本历史记录本(记录所有修改)
Read View判断版本可见性时光眼镜(决定你能看到哪个版本)

四、隐藏字段:数据行的身份证 🪪

4.1 三个隐藏列

每行数据除了你定义的字段,还有3个隐藏字段:

CREATE TABLE users (
    id INT,
    name VARCHAR(50),
    age INT,
    -- 下面是隐藏字段,你看不到但它们存在!
    -- DB_TRX_ID (6字节):最后修改这行数据的事务ID
    -- DB_ROLL_PTR (7字节):回滚指针,指向Undo Log
    -- DB_ROW_ID (6字节):隐藏主键(如果没有主键才有)
);

4.2 字段说明

1. DB_TRX_ID(事务ID)

记录最后一次修改此行的事务ID
就像商品上的"生产日期"

例如:
  张三的年龄 = 25,DB_TRX_ID = 100(事务100修改的)

2. DB_ROLL_PTR(回滚指针)

指向这条记录的上一个版本(在Undo Log中)
就像"历史版本链接"

例如:
  当前版本 → 上一个版本 → 上上个版本 → ...

3. DB_ROW_ID(隐藏主键)

如果表没有主键和唯一索引,InnoDB自动生成
一般表都有主键,所以这个字段通常用不到

4.3 图解

假设有一行数据:id=1, name='张三', age=25

磁盘上实际存储:
┌────┬──────┬─────┬─────────────┬──────────────┬────────────┐
│ id │ name │ age │ DB_TRX_ID   │ DB_ROLL_PTR  │ DB_ROW_ID  │
├────┼──────┼─────┼─────────────┼──────────────┼────────────┤
│ 1  │ 张三  │ 25  │ 100         │ 0x7F8B3C...  │ NULL       │
└────┴──────┴─────┴─────────────┴──────────────┴────────────┘
         ↑                ↑             ↑
      用户字段        事务ID        回滚指针

五、Undo Log:历史版本仓库 📜

5.1 什么是Undo Log?

Undo Log是InnoDB存储的历史版本数据,用于:

  1. 事务回滚:撤销未提交的修改
  2. MVCC读取:读取历史版本

5.2 版本链

每次更新都会在Undo Log中创建一个历史版本,形成版本链。

示例:张三的年龄被多次修改

时刻1:事务100插入数据
  id=1, name='张三', age=25, DB_TRX_ID=100

时刻2:事务200修改年龄为26
  当前版本:age=26, DB_TRX_ID=200
     ↓ (回滚指针)
  历史版本:age=25, DB_TRX_ID=100

时刻3:事务300修改年龄为27
  当前版本:age=27, DB_TRX_ID=300
     ↓
  历史版本:age=26, DB_TRX_ID=200
     ↓
  历史版本:age=25, DB_TRX_ID=100

5.3 图解版本链

最新版本(磁盘)
┌─────────────────────┐
│ age=27              │
│ DB_TRX_ID=300       │──────┐
│ DB_ROLL_PTR ────────┼──┐   │
└─────────────────────┘  │   │
                         ↓   │
Undo Log                 │   │
┌─────────────────────┐  │   │
│ age=26              │ ←┘   │
│ DB_TRX_ID=200       │──┐   │ 版本链
│ DB_ROLL_PTR ────────┼──┼───┼→ 通过回滚指针
└─────────────────────┘  │   │  连接起来
                         ↓   │
┌─────────────────────┐  │   │
│ age=25              │ ←┘   │
│ DB_TRX_ID=100       │      │
│ DB_ROLL_PTR = NULL  │ ←────┘
└─────────────────────┘
     ↑
  最早版本

5.4 生活类比

版本链就像Git历史:

git log
  commit 300 (HEAD) age=27  ← 最新版本
  commit 200        age=26  ← 中间版本
  commit 100        age=25  ← 最早版本

你可以checkout到任何历史版本!

六、Read View:时光眼镜 👓

6.1 什么是Read View?

**Read View(读视图)**是事务开始时创建的快照,决定了当前事务能看到哪些版本的数据。

6.2 Read View的四个重要属性

class ReadView {
    long m_low_limit_id;      // 当前系统中最大事务ID + 1
    long m_up_limit_id;       // 当前系统中最小活跃事务ID
    long[] m_ids;             // 创建ReadView时的活跃事务ID列表
    long m_creator_trx_id;    // 创建ReadView的事务ID
}

通俗解释:

属性含义生活类比
m_low_limit_id未来的事务ID明天出生的人,你看不到
m_up_limit_id最早的活跃事务ID最早的还活着的人
m_ids所有活跃事务ID正在工作的人列表
m_creator_trx_id自己的事务ID你自己

6.3 可见性判断规则

核心问题: 数据的DB_TRX_ID(版本号)是否对当前事务可见?

boolean isVisible(long trx_id) {
    // 规则1:如果是自己修改的,当然可见
    if (trx_id == m_creator_trx_id) {
        return true;
    }
    
    // 规则2:如果版本在ReadView创建前就提交了,可见
    if (trx_id < m_up_limit_id) {
        return true;
    }
    
    // 规则3:如果版本在ReadView创建后才开始,不可见
    if (trx_id >= m_low_limit_id) {
        return false;
    }
    
    // 规则4:如果版本在活跃事务列表中,不可见(未提交)
    if (m_ids.contains(trx_id)) {
        return false;
    }
    
    // 其他情况:可见
    return true;
}

6.4 图解可见性

时间线:
  ← 过去                           未来 →
─────────────────────────────────────────→
  100   200   |  300  400  |   500   600
              ↑            ↑
         ReadView创建    当前查询

m_up_limit_id = 300(最小活跃事务)
m_low_limit_id = 501(最大事务ID + 1m_ids = [300, 400](活跃事务列表)

判断:
  trx_id=100:< m_up_limit_id → ✅可见(已提交)
  trx_id=200:< m_up_limit_id → ✅可见(已提交)
  trx_id=300:在m_ids中 → ❌不可见(未提交)
  trx_id=400:在m_ids中 → ❌不可见(未提交)
  trx_id=500:< m_low_limit_id 且不在m_ids → ✅可见(已提交)
  trx_id=600:>= m_low_limit_id → ❌不可见(未来的事务)

七、MVCC读取流程:完整版 🎬

7.1 场景设置

-- 初始数据
INSERT INTO users (id, name, age) VALUES (1, '张三', 25);
-- DB_TRX_ID = 100

-- 事务200开始,修改年龄
BEGIN; -- trx_id = 200
UPDATE users SET age = 26 WHERE id = 1;
-- 还未提交!

-- 事务300开始,查询数据
BEGIN; -- trx_id = 300
SELECT * FROM users WHERE id = 1; -- 能看到什么?

7.2 Read View创建

事务300执行SELECT时创建Read View:
  m_creator_trx_id = 300(自己)
  m_up_limit_id = 200(最小活跃事务)
  m_low_limit_id = 301300 + 1m_ids = [200, 300](活跃事务列表)

7.3 查询流程

1. 从磁盘读取最新版本
   ┌─────────────────────┐
   │ age=26              │
   │ DB_TRX_ID=200       │ ← 事务200修改的
   └─────────────────────┘

2. 判断是否可见
   trx_id=200 在 m_ids 中 → ❌不可见(事务200未提交)

3. 通过回滚指针找上一个版本
   ┌─────────────────────┐
   │ age=25              │
   │ DB_TRX_ID=100       │ ← 事务100修改的
   └─────────────────────┘

4. 判断是否可见
   trx_id=100 < m_up_limit_id=200 → ✅可见(已提交)

5. 返回结果
   age = 25

7.4 流程图

开始SELECT
    ↓
创建Read View
    ↓
读取最新版本
    ↓
┌─────────────────┐
│ 版本可见?      │
└─────┬───────┬───┘
      ↓       ↓
    是       否
      ↓       ↓
  返回数据   通过回滚指针找上一版本
              ↓
           (回到判断)

八、RC与RR的Read View差异 🔍

8.1 READ COMMITTED(RC)

每次查询都创建新的Read View

-- 事务A
BEGIN; -- trx_id = 100
UPDATE users SET age = 26 WHERE id = 1;
COMMIT;

-- 事务B(RC隔离级别)
BEGIN; -- trx_id = 200

-- 第1次查询,创建ReadView1
SELECT age FROM users WHERE id = 1; 
-- 结果:25(事务A未提交)

-- 事务A提交

-- 第2次查询,创建ReadView2(新的!)
SELECT age FROM users WHERE id = 1;
-- 结果:26(事务A已提交,新ReadView能看到)

COMMIT;

特点: 能读到别人已提交的修改(不可重复读)

8.2 REPEATABLE READ(RR)

事务开始时创建Read View,之后一直复用

-- 事务A
BEGIN; -- trx_id = 100
UPDATE users SET age = 26 WHERE id = 1;
COMMIT;

-- 事务B(RR隔离级别)
BEGIN; -- trx_id = 200

-- 第1次查询,创建ReadView
SELECT age FROM users WHERE id = 1;
-- 结果:25

-- 事务A提交

-- 第2次查询,复用之前的ReadView
SELECT age FROM users WHERE id = 1;
-- 结果:25(还是25!因为ReadView没变)

COMMIT;

特点: 读取的是事务开始时的快照(可重复读)

8.3 对比表

隔离级别Read View创建时机能否看到别人提交的修改特点
RC每次查询创建✅ 能不可重复读
RR事务开始创建一次❌ 不能可重复读

九、MVCC解决了什么问题?💡

9.1 解决的问题

1. 脏读(Dirty Read)

✅ MVCC解决方案:
  未提交的修改在m_ids中,不可见

2. 不可重复读(Non-Repeatable Read)

✅ RR隔离级别下解决:
  ReadView不变,看到的始终是快照
  
❌ RC隔离级别下无法解决:
  每次查询创建新ReadView

3. 幻读(Phantom Read)

⚠️ 部分解决:
  快照读(普通SELECT):通过MVCC解决
  当前读(SELECT FOR UPDATE):通过Next-Key Lock解决

9.2 性能优势

传统锁机制:
  读操作需要加锁 → 阻塞写操作
  写操作需要加锁 → 阻塞读操作
  并发度:低 ❌

MVCC机制:
  读操作不加锁 → 不阻塞写操作
  写操作不阻塞读 → 只阻塞其他写
  并发度:高 ✅

性能提升:

  • 读操作:0锁开销
  • 并发度:大幅提升
  • 响应时间:显著降低

十、快照读 vs 当前读 📸

10.1 快照读(Snapshot Read)

定义: 读取的是历史快照版本(通过MVCC)

SQL语句:

SELECT * FROM users WHERE id = 1;

特点:

  • ✅ 不加锁
  • ✅ 读取Read View能看到的版本
  • ✅ 高并发

10.2 当前读(Current Read)

定义: 读取的是最新版本(不走MVCC)

SQL语句:

-- 加共享锁
SELECT * FROM users WHERE id = 1 LOCK IN SHARE MODE;

-- 加排他锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;

-- DML语句都是当前读
INSERT / UPDATE / DELETE

特点:

  • ⚠️ 会加锁
  • ⚠️ 读取最新版本
  • ⚠️ 阻塞其他写操作

10.3 对比

类型是否加锁读取版本并发性使用场景
快照读❌ 否历史快照普通查询
当前读✅ 是最新版本需要最新数据或修改

十一、实战案例:转账场景 💰

案例1:快照读(可能不一致)

-- 账户表
CREATE TABLE accounts (
    id INT PRIMARY KEY,
    balance DECIMAL(10,2)
);

INSERT INTO accounts VALUES (1, 1000), (2, 1000);

-- 事务A:转账
BEGIN; -- trx_id = 100
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

-- 事务B:查询总额(RR隔离级别)
BEGIN; -- trx_id = 200

-- 第1次查询
SELECT SUM(balance) FROM accounts;
-- 结果:2000(事务A未提交,看到的是快照)

-- 事务A提交

-- 第2次查询
SELECT SUM(balance) FROM accounts;
-- 结果:2000(还是快照,虽然事务A已提交)

COMMIT;

问题: 快照读可能看不到最新数据

案例2:当前读(保证一致)

-- 事务C:查询总额(使用当前读)
BEGIN; -- trx_id = 300

SELECT SUM(balance) FROM accounts FOR UPDATE;
-- 结果:2000(当前最新值)

COMMIT;

十二、MVCC的局限性 ⚠️

1. 无法完全解决幻读

-- 事务A
BEGIN;
SELECT * FROM users WHERE age > 20; -- 查到3条

-- 事务B
INSERT INTO users VALUES (4, '李四', 25);
COMMIT;

-- 事务A
SELECT * FROM users WHERE age > 20; -- 快照读,还是3条 ✅

-- 但是!
UPDATE users SET name = '修改' WHERE age > 20; -- 当前读,4条都被修改 ⚠️
SELECT * FROM users WHERE age > 20; -- 现在查到4条了!(幻读)

2. Undo Log占用空间

大量长事务 → Undo Log堆积 → 磁盘空间占用 → 性能下降

解决:

  • 及时提交事务
  • 避免大事务
  • 定期清理

3. 回滚链过长

频繁修改 → 版本链很长 → 查询需要遍历很多版本 → 性能下降

解决:

  • 批量更新
  • 减少修改频率

十三、面试高频问题 🎤

Q1: 什么是MVCC?

答: MVCC是Multi-Version Concurrency Control(多版本并发控制),通过保存数据的多个历史版本,让读操作不加锁,实现读写并发。

核心组件:

  1. 隐藏字段(DB_TRX_ID、DB_ROLL_PTR)
  2. Undo Log(版本链)
  3. Read View(可见性判断)

Q2: MVCC如何实现可重复读?

答: 在RR隔离级别下,事务开始时创建Read View,之后的查询都复用这个Read View,所以看到的始终是事务开始时的快照,实现可重复读。

Q3: RC和RR在MVCC上的区别?

答:

  • RC:每次查询创建新的Read View,能看到其他事务已提交的修改
  • RR:事务开始时创建Read View并一直复用,看不到之后其他事务的修改

Q4: 快照读和当前读的区别?

答:

  • 快照读:普通SELECT,不加锁,读历史版本(MVCC)
  • 当前读:SELECT FOR UPDATE、DML语句,加锁,读最新版本

Q5: MVCC能完全解决幻读吗?

答: 不能完全解决。快照读能解决幻读,但当前读(如UPDATE、DELETE)还是会有幻读问题,需要结合Next-Key Lock解决。


十四、最佳实践 💡

1. 避免长事务

-- ❌ 不好:长事务
BEGIN;
SELECT * FROM users; -- 创建ReadView
sleep(3600); -- 睡1小时
SELECT * FROM users;
COMMIT;
-- Undo Log堆积,影响性能

-- ✅ 好:及时提交
BEGIN;
SELECT * FROM users;
COMMIT;

2. 合理选择隔离级别

读多写少,对一致性要求高 → RR
读少写多,对实时性要求高 → RC

3. 避免频繁修改同一行

-- ❌ 不好:循环更新
FOR i IN 1..1000 LOOP
    UPDATE users SET score = score + 1 WHERE id = 1;
END LOOP;
-- 版本链过长

-- ✅ 好:批量更新
UPDATE users SET score = score + 1000 WHERE id = 1;

4. 当需要最新数据时使用当前读

-- ❌ 不好:快照读可能看到旧数据
SELECT balance FROM accounts WHERE id = 1;

-- ✅ 好:当前读保证最新
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;

十五、总结口诀 📝

MVCC多版本,
读写不阻塞。
隐藏字段记事务,
Undo Log存历史。

Read View判可见,
快照读不加锁。
RC每次新视图,
RR复用保一致。

当前读要加锁,
最新版本拿到手。
长事务要避免,
Undo Log会堆积。

理解MVCC原理,
高并发不用愁!

十六、知识拓展 🎓

Undo Log清理机制

Purge线程职责:
1. 清理不再需要的Undo Log
2. 条件:没有Read View需要这个版本
3. 长事务会阻止清理 → 空间占用

MVCC与锁的配合

MVCC(乐观) + 锁(悲观) = 完整并发控制

快照读:MVCC(不加锁)
当前读:锁(行锁、间隙锁、Next-Key Lock)

参考资料 📚


下期预告: 137-MySQL的索引数据结构为什么选择B+树而不是B树或Hash?🌳


编写时间:2025年
作者:技术文档小助手 ✍️
版本:v1.0

愿你的数据库永远高并发! ⚡🚀