MySQL MVCC实现原理详解

一、MVCC基本概念

MVCC(Multi-Version Concurrency Control)是多版本并发控制技术,它是MySQL InnoDB引擎实现事务隔离性的核心机制。

核心特点:

  1. 读不加锁,读写不冲突
  2. 通过数据版本链实现并发控制
  3. 不同事务看到的数据版本可能不同

二、MVCC核心组件

2.1 隐藏字段

InnoDB每行记录包含3个隐藏字段:

  1. DB_TRX_ID:最近修改的事务ID
  2. DB_ROLL_PTR:回滚指针,指向undo日志
  3. DB_ROW_ID:行ID(当没有主键时自动生成)

2.2 undo log

  • 存储数据修改前的状态
  • 用于事务回滚和MVCC版本链
  • 分为insert undo log和update undo log

undo log版本链示例:

让我们通过一个具体的例子来说明undo log如何记录数据的变更历史:

  1. 初始数据
idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
3030A301null1
  1. 事务2修改age=25

当前记录:

idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
3025A3020x0000011

undo log (0x000001):

idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
3030A301null1
  1. 事务3修改name=A30_new

当前记录:

idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
3025A30_new30x0000021

undo log (0x000002):

idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
3025A3020x0000011

版本链结构(undo log):

🔵 最新版本 (TRX_ID=3)
id=30, age=25, name=A30_new
DB_ROLL_PTR=0x000002
📝 历史版本1 (TRX_ID=2)
id=30, age=25, name=A30
DB_ROLL_PTR=0x000001
📜 历史版本2 (TRX_ID=1)
id=30, age=30, name=A30
DB_ROLL_PTR=null

说明:

  • 粉色框:当前最新记录
  • 蓝色框:第一个历史版本(undo log 1)
  • 绿色框:最早的历史版本(undo log 2)
  • 箭头:表示DB_ROLL_PTR的指向,通过回滚指针串联成版本链
  • 每个版本都包含完整的行记录信息,包括所有隐藏字段

不同事务或相同事务对同一条记录进行修改,会导致该记录的undolog生成一条记录版本链表,链表的头部是最新的旧记录,链表尾部是最早的旧记录。

通过这个版本链,不同事务根据其可见性规则可以看到不同的历史版本:

  • 事务4在RR隔离级别下,如果在事务2和事务3执行期间开始,将看到最早的版本(age=30, name=A30)
  • 事务4在RC隔离级别下,如果在事务3提交后查询,将看到最新的版本(age=25, name=A30_new)

2.3 ReadView

ReadView是事务在快照读时产生的读视图,包含:

  1. trx_ids:当前活跃事务ID列表
  2. up_limit_id:最小活跃事务ID
  3. low_limit_id:预分配的下一个事务ID
  4. creator_trx_id:创建该ReadView的事务ID

三、MVCC工作流程

3.1 版本链访问规则

flowchart LR
    A[最新记录] -->|DB_ROLL_PTR| B[版本1]
    B -->|DB_ROLL_PTR| C[版本2]
    C -->|DB_ROLL_PTR| D[...]
  1. 从最新记录开始,通过DB_ROLL_PTR访问历史版本
  2. 比较事务ID与ReadView中的限制条件
  3. 找到对当前事务可见的版本

具体示例:

假设有一张用户表,初始数据:

idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
3030A301null0x000001

现在执行以下事务操作:

-- 事务2:修改age为3
BEGIN;
UPDATE users SET age = 3 WHERE id = 30;
COMMIT;

-- 事务3:修改name为'A30_v2'
BEGIN;
UPDATE users SET name = 'A30_v2' WHERE id = 30;
COMMIT;

执行后的版本链如下:

最新数据记录

idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
303A30_v230x0000020x000001

undo log版本1(事务3之前):

idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
303A3020x0000030x000001

undo log版本2(事务2之前):

idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
3030A301null0x000001

当不同事务读取该记录时,会根据ReadView规则沿着版本链查找可见版本。

3.2 可见性判断算法

判断条件与结果:

条件判断结果说明
DB_TRX_ID == creator_trx_id可见当前事务的修改
DB_TRX_ID < up_limit_id可见已提交的历史事务
DB_TRX_ID >= low_limit_id不可见未开始的新事务
up_limit_id <= DB_TRX_ID < low_limit_id-需进一步判断

活跃事务判断:
当 up_limit_id <= DB_TRX_ID < low_limit_id 时:

  • trx_ids列表中:事务未提交,不可见
  • 不在trx_ids列表中:事务已提交,可见

具体示例:

假设当前有三个事务,状态如下:

事务ID状态
1已提交
2已提交
3活跃中
4未开始

此时,事务5开始并创建ReadView:

ReadView信息:
- trx_ids = [3](当前活跃事务列表)
- up_limit_id = 3(最小活跃事务ID)
- low_limit_id = 6(下一个将被分配的事务ID)
- creator_trx_id = 5(创建ReadView的事务ID)

对于前面例子中的版本链,事务5的可见性判断过程:

  1. 检查最新记录(DB_TRX_ID=3):

    • 3 == 5?否,不满足条件1
    • 3 < 3?否,不满足条件2
    • 3 >= 6?否,不满足条件3
    • 3 <= 3 < 6 且 3在trx_ids中?是,满足条件4第一项,不可见
    • 继续沿版本链查找
  2. 检查undo log版本1(DB_TRX_ID=2):

    • 2 == 5?否,不满足条件1
    • 2 < 3?是,满足条件2,可见
    • 找到可见版本,停止查找

因此,事务5在RR隔离级别下看到的数据是:

idagenameDB_TRX_IDDB_ROLL_PTRDB_ROW_ID
303A3020x0000030x000001

这就是MVCC如何通过版本链和ReadView实现事务隔离性的具体过程。

四、不同隔离级别的实现

4.1 读已提交(RC)

  • 每次读取都生成新的ReadView
  • 能看到其他事务已提交的修改

RC隔离级别示例:

-- 事务A:RC隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;

-- T1时刻:第一次查询
SELECT * FROM users WHERE id = 30; -- 生成ReadView1
-- 此时看到:id=30, age=3, name=A30

-- 此时事务B修改并提交:
-- UPDATE users SET name = 'A30_v3' WHERE id = 30;
-- COMMIT;

-- T2时刻:第二次查询
SELECT * FROM users WHERE id = 30; -- 生成ReadView2
-- 此时看到:id=30, age=3, name=A30_v3(能看到事务B的修改)

COMMIT;

在RC隔离级别下,每次SELECT都会生成新的ReadView,因此能够看到其他已提交事务的修改。

4.2 可重复读(RR)

  • 只在第一次读取时生成ReadView
  • 后续读取复用同一个ReadView
  • 解决不可重复读问题

RR隔离级别示例:

-- 事务A:RR隔离级别(MySQL默认)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;

-- T1时刻:第一次查询
SELECT * FROM users WHERE id = 30; -- 生成ReadView1
-- 此时看到:id=30, age=3, name=A30

-- 此时事务B修改并提交:
-- UPDATE users SET name = 'A30_v3' WHERE id = 30;
-- COMMIT;

-- T2时刻:第二次查询
SELECT * FROM users WHERE id = 30; -- 复用ReadView1
-- 此时看到:id=30, age=3, name=A30(看不到事务B的修改)

COMMIT;

在RR隔离级别下,事务内的所有查询都复用第一次生成的ReadView,因此无法看到其他事务已提交的修改,实现了可重复读。

五、MVCC与锁的协同

5.1 当前读与快照读

  • 快照读:普通SELECT,使用MVCC
  • 当前读:加锁的SELECT/UPDATE/DELETE,使用记录锁

快照读与当前读对比:

特性快照读当前读
语句示例SELECT * FROM tableSELECT * FROM table FOR UPDATE
锁机制无锁记录锁/间隙锁
读取内容历史版本(MVCC)最新提交版本
并发性能

示例:

-- 事务A:使用快照读
BEGIN;
SELECT * FROM users WHERE id = 30; -- 使用MVCC机制,读取符合ReadView规则的版本

-- 事务B:使用当前读
BEGIN;
SELECT * FROM users WHERE id = 30 FOR UPDATE; -- 读取最新版本并加锁
UPDATE users SET age = 35 WHERE id = 30; -- 修改数据
COMMIT;

5.2 解决幻读问题

  • RR级别下,MVCC+间隙锁防止幻读
  • 通过Next-Key Lock锁定记录和间隙

幻读问题示例:

-- 事务A:第一次查询
BEGIN;
SELECT * FROM users WHERE age BETWEEN 20 AND 40; -- 返回1条记录

-- 事务B:插入新记录
-- BEGIN;
-- INSERT INTO users(id, age, name) VALUES(31, 25, 'A31');
-- COMMIT;

-- 事务A:第二次查询(快照读,使用MVCC)
SELECT * FROM users WHERE age BETWEEN 20 AND 40; -- 仍然返回1条记录(MVCC机制)

-- 事务A:使用当前读
SELECT * FROM users WHERE age BETWEEN 20 AND 40 FOR UPDATE; -- 返回2条记录(当前读)
-- 此时发现幻读问题

COMMIT;

解决方案:

在RR隔离级别下,InnoDB通过Next-Key Lock(记录锁+间隙锁)解决当前读的幻读问题:

-- 事务A:使用当前读并加锁
BEGIN;
SELECT * FROM users WHERE age BETWEEN 20 AND 40 FOR UPDATE; -- 加Next-Key Lock

-- 事务B:尝试插入(会被阻塞)
-- BEGIN;
-- INSERT INTO users(id, age, name) VALUES(31, 25, 'A31'); -- 被间隙锁阻塞
-- COMMIT;

-- 事务A:再次查询
SELECT * FROM users WHERE age BETWEEN 20 AND 40 FOR UPDATE; -- 结果一致,无幻读
COMMIT;

这样,MVCC处理快照读,Next-Key Lock处理当前读,共同保证了事务的隔离性。

六、总结

小结

1.MVCC允许多个事务同时读取同一行数据,而不会彼此阻塞,每个事务看到的数据版本是该事务开始时的数据版本。这意味着,如果其他事务在此期间修改了数据,正在运行的事务仍然看到的是它开始时的数据状态,从而实现了非阻塞读操作。
2.对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同,大家可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。
3.「读提交」隔离级别是在「每个select语句执行前」都会重新生成一个 Read View;
「可重复读」隔离级别是执行第一条select时,生成一个 Read View,然后整个事务期间都在用这个 Read View

关键知识点

  1. MVCC核心组件:隐藏字段、undo日志、ReadView
  2. 快照的读取依据:通过事务ID与ReadView比较确定可见版本
  3. 隔离级别实现:RC每次生成新ReadView,RR复用ReadView
  4. 与锁的关系:MVCC处理快照读,锁处理当前读
  5. 读已提交(RC):每次执行SELECT语句时都会重新生成一个Read View,因此能看到其他事务已提交的最新修改
  6. 可重复读(RR):只在事务中第一次执行SELECT时生成Read View,后续查询都复用同一个Read View

深入解析

  1. 版本链构建:通过DB_ROLL_PTR指针连接undo日志形成版本链
  2. ReadView生成时机:RC在每次查询时生成,RR在事务首次查询时生成
  3. 幻读解决方案:RR级别下MVCC+间隙锁共同作用
  4. 性能优化:MVCC避免了读锁,大幅提升并发性能
  5. RC级别的Read View生成
    • 每次SELECT都会获取最新的活跃事务列表
    • 能看到其他事务已提交的修改
    • 可能导致不可重复读问题
  6. RR级别的Read View复用
    • 事务开始时生成Read View后固定不变
    • 确保事务内多次读取数据一致性
    • 解决了不可重复读问题