深入理解MySQL InnoDB MVCC:原理、实验与实践

在MySQL数据库的并发控制中,InnoDB存储引擎的多版本并发控制(MVCC) 是保障读写性能的核心机制。它解决了传统锁机制下"读写互斥"的痛点,实现了"读不加锁、读写不冲突",极大提升了数据库的并发处理能力。本文将从实验现象出发,逐步拆解MVCC的底层原理,带你彻底搞懂这一关键技术。

一、从实验切入:MVCC的直观现象

要理解MVCC,我们先从一个真实的数据库实验开始。通过观察不同事务对同一数据的访问结果,感受MVCC的作用。

1.1 实验环境准备

首先创建测试表t1并插入初始数据(使用InnoDB引擎,避免MyISAM无事务支持的问题):

sql 复制代码
-- 切换数据库(若不存在需先创建)
use martin;
-- 删除旧表(避免干扰)
drop table if exists t1;
-- 创建测试表
CREATE TABLE `t1` (
  `id` int NOT NULL AUTO_INCREMENT,
  `a` int NOT NULL,
  `b` int NOT NULL,
  PRIMARY KEY (`id`),  -- 主键索引
  KEY `idx_a` (`a`)    -- 普通索引
) ENGINE=InnoDB CHARSET=utf8mb4;
-- 插入初始数据
insert into t1(a,b) values (1,1);

1.2 实验步骤与现象

我们开启两个事务(session1和session2),均使用读已提交(READ-COMMITTED) 隔离级别,执行如下操作:

步骤 session1(事务1) session2(事务2)
1 set session transaction_isolation='READ-COMMITTED'; set session transaction_isolation='READ-COMMITTED';
2 select * from t1; → 结果:(1,1,1) -
3 begin;(开启事务) -
4 update t1 set b=3 where a=1;(修改未提交) -
5 - begin;(开启事务)
6 select * from t1 where a=1; → 结果:(1,1,3) select * from t1 where a=1; → 结果:(1,1,1)
7 commit;(提交事务) -
8 - select * from t1 where a=1; → 结果:(1,1,3)
9 - commit;(提交事务)

1.3 实验现象分析

这个实验暴露了一个关键问题:

  • 步骤6中,session1修改数据后未提交,自身能看到修改后的b=3,但session2看到的仍是原始值b=1
  • 步骤8中,session1提交后,session2才能看到b=3

为什么同一时间对同一数据的访问会出现"版本差异"?答案就是MVCC------InnoDB通过保存数据的"历史版本快照",让不同事务看到对应时间点的一致数据。

二、MVCC的核心组成:三大基石

MVCC的实现依赖三个关键组件:隐藏列Undo Log(撤销日志)Read View(读取视图)。这三者共同构成了MVCC的底层逻辑。

2.1 隐藏列:数据的"身份档案"

InnoDB存储引擎会为表中的每一行记录自动添加3个隐藏列,用于追踪数据的版本和事务信息:

隐藏列名 作用说明
DB_ROW_ID 隐藏自增ID。若表未定义主键,InnoDB会用该列作为聚集索引的键值
DB_TRX_ID 事务ID。记录最后一次修改该记录的事务ID(包括INSERT/UPDATE/DELETE)
DB_ROLL_PTR 回滚指针。指向该记录的"历史版本"在Undo Log中的存储地址,形成版本链

以实验中的初始数据(1,1,1)为例,其隐藏列初始状态如下:

id a b DB_TRX_ID DB_ROLL_PTR
1 1 1 1(插入事务ID) NULL(无历史版本)

2.2 Undo Log:历史版本的"仓库"

Undo Log(撤销日志)的核心作用有两个:

  1. 事务回滚:若事务执行失败,通过Undo Log恢复数据到修改前的状态;
  2. MVCC版本存储:当数据被修改时,InnoDB会将修改前的"历史版本"存入Undo Log,供其他事务读取。
版本链的形成过程

当同一行数据被多次修改时,Undo Log会形成一条版本链 ,通过DB_ROLL_PTR串联:

  1. 初始状态:只有当前版本(b=1),DB_ROLL_PTR=NULL;

  2. 第一次修改(session1的update b=3):

    • 原记录(b=1)被存入Undo Log,DB_TRX_ID=1
    • 新记录(b=3)的DB_TRX_ID=2(session1的事务ID),DB_ROLL_PTR指向Undo Log中的原记录;
  3. 若再次修改(如update b=4):

    • 上一次的新记录(b=3)存入Undo Log,DB_TRX_ID=2
    • 最新记录(b=4)的DB_TRX_ID=3DB_ROLL_PTR指向Undo Log中的b=3版本。

版本链的结构如下:

plain 复制代码
当前版本(b=4, DB_TRX_ID=3) 
    ↓(DB_ROLL_PTR指向)
Undo Log中的版本(b=3, DB_TRX_ID=2)
    ↓(DB_ROLL_PTR指向)
Undo Log中的版本(b=1, DB_TRX_ID=1)

2.3 Read View:版本选择的"裁判"

当事务需要读取数据时,面对版本链中的多个历史版本,如何确定"哪个版本可见"?这就需要Read View(读取视图) 来判断。

Read View的核心属性

Read View包含4个关键信息,用于判断版本可见性:

属性名 含义
trx_ids 生成Read View时,数据库中当前活跃的事务ID集合
low_limit_id 生成Read View时,系统"下一个要分配的事务ID"(即当前最大事务ID+1)
up_limit_id 生成Read View时,活跃事务中的最小事务ID
creator_trx_id 生成该Read View的"当前事务ID"
Read View的可见性判断规则

对于版本链中的某一历史版本(DB_TRX_ID表示最后一次修改这行记录的事务ID ,其DB_TRX_IDtrx_id),Read View按以下规则判断是否可见:

  1. trx_id == creator_trx_id:当前事务修改的数据,可见;
  2. trx_id < up_limit_id:修改该版本的事务已提交(早于所有活跃事务),可见;
  3. trx_id >= low_limit_id:修改该版本的事务未开启(晚于当前Read View生成),不可见,需沿版本链找前一个版本;
  4. up_limit_id <= trx_id < low_limit_id
    • trx_idtrx_ids中:事务未提交,不可见,找前一个版本;
    • trx_id不在trx_ids中:事务已提交,可见。
满足的条件 查询哪个版本
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 并且 DB_TRX_ID 在 trx_ids 中 说明 DB_TRX_ID 还未提交,那么这一行记录对该事物是不可见的,则再确定这一行记录的前一个版本对该事务是否可见
up_limit_id <= DB_TRX_ID < low_limit_id 并且 DB_TRX_ID 不在 trx_ids 中 说明事务 DB_TRX_ID 已经提交了,那么这一行记录对该事物是可见的
ReadView判断哪个版本可用的举例

我们通过模拟多事务操作,结合ReadView的核心参数(trx_idslow_limit_idup_limit_idcreator_trx_id),分析不同场景下版本可见性的判断逻辑。

实验背景

初始化测试表:

bash 复制代码
truncate table t1;
set session transaction_isolation='READ-COMMITTED';
步骤 session1(DB_TRX_ID=1) session2(DB_TRX_ID=2) session3(DB_TRX_ID=3) session4(DB_TRX_ID=4) session5 a=1这行记录的DB_TRX_ID 满足条件
1 begin insert into t1(a,b) values (1,1); select * from t1 where a=1; commit;(此时的Read View trx_ids:1 low_limit_id:2 up_limit_id:1 creator_trx_id:1 1 DB_TRX_ID = creator_trx_id
2 begin; update t1 set b=2 where a=1; 2
3 select * from t1 where a=1;(此时的Read View trx_ids:2 low_limit_id:3 up_limit_id:2 creator_trx_id:0 2 up_limit_id <= DB_TRX_ID < low_limit_id 并且DB_TRX_ID也在trx_ids中
4 begin; insert into t1(a,b) values (2,2); 2
5 commit; 2
6 begin; update t1 set b=3 where a=1; commit; 4
7 select * from t1 where a=1;(此时的Read View trx_ids:3 low_limit_id:5 up_limit_id:5 creator_trx_id:0 4 up_limit_id <= DB_TRX_ID < low_limit_id 并且DB_TRX_ID不在trx_ids中
8 commit;
场景分析
(1)步骤1:session1的查询
  • ReadView参数
    • trx_ids = {1}(生成Read View时,仅当前事务活跃);
    • low_limit_id = 2(下一个待分配的事务ID);
    • up_limit_id = 1(活跃事务中最小的事务ID);
    • creator_trx_id = 1(创建Read View的事务ID)。
  • 匹配规则DB_TRX_ID = creator_trx_id
  • 结论 :当前事务是该行数据的创建者,记录对该事务可见 ,查询结果为a=1, b=1
(2)步骤3:session3的查询
  • ReadView参数
    • trx_ids = {2}(生成Read View时,session2的事务活跃);
    • low_limit_id = 3(下一个待分配的事务ID);
    • up_limit_id = 2(活跃事务中最小的事务ID);
    • creator_trx_id = 0(只读事务默认ID)。
  • 匹配规则up_limit_id <= DB_TRX_ID < low_limit_id 并且 DB_TRX_ID 在 trx_ids 中
  • 结论 :事务DB_TRX_ID=2未提交,记录对当前事务不可见 ,需通过Undo Log找前一版本(DB_TRX_ID=1的版本),查询结果为a=1, b=1
(3)步骤7:session5的查询
  • ReadView参数
    • trx_ids = {3}(生成Read View时,session3的事务活跃);
    • low_limit_id = 5(因session4已用事务ID4,下一个事务ID为5);
    • up_limit_id = 3(活跃事务中最小的事务ID);
    • creator_trx_id = 0(只读事务默认ID)。
  • 匹配规则up_limit_id <= DB_TRX_ID < low_limit_id 并且 DB_TRX_ID 不在 trx_ids 中
  • 结论 :事务DB_TRX_ID=4已提交,记录对当前事务可见 ,查询结果为a=1, b=3

通过以上实验和分析,可以清晰看到ReadView如何通过事务ID的规则判断,确定数据版本的可见性。

三、关键差异:RC与RR隔离级别的Read View时机

MVCC仅在读已提交(RC)可重复读(RR) 两个隔离级别下生效,但两者的核心差异在于:Read View的生成时机不同。这直接导致了"可重复读"和"不可重复读"的现象。

3.1 实验对比:RC与RR的Read View差异

我们将之前的实验改为可重复读(REPEATABLE-READ) 隔离级别,观察步骤8的结果:

步骤 session1(事务1) session2(事务2)
1 set session transaction_isolation='REPEATABLE-READ'; set session transaction_isolation='REPEATABLE-READ';
2 select * from t1;(1,1,1) -
3 begin;update t1 set b=3 where a=1; -
5 - begin;select * from t1 where a=1;(1,1,1)
7 commit;(提交事务) -
8 - select * from t1 where a=1;仍为 (1,1,1)
9 - commit;
10 - select * from t1;(1,1,3)

3.2 差异原因:Read View生成时机

  • 读已提交(RC) :同一事务中,每次执行查询都会重新生成Read View。因此步骤8中,session1提交后,session2的新Read View能看到提交后的版本;
  • 可重复读(RR) :同一事务中,仅在第一次查询时生成Read View,后续查询复用该Read View。因此步骤8中,session2仍用初始Read View,看不到session1提交的新版本,实现了"可重复读"。

四、MVCC的完整工作流程

结合上述组件,我们梳理MVCC的完整执行流程:

  1. 事务开启,执行select查询;
  2. InnoDB生成当前事务的Read View,记录活跃事务ID、最小/最大事务ID等;
  3. 读取目标记录的最新版本,检查其DB_TRX_ID是否符合Read View的可见性规则;
  4. 若符合规则,直接返回该版本;若不符合,通过DB_ROLL_PTR沿Undo Log版本链向前查找,直到找到可见版本;
  5. 返回可见版本给事务。

获取事务ID 获取Read View 查询数据 比较事务ID 符合规则的数据 返回数据

五、MVCC的核心价值与适用场景

5.1 MVCC的核心优势

  1. 读写不冲突:读操作无需加锁,写操作仅加行级排他锁,极大提升并发性能;
  2. 保障隔离性:通过版本链和Read View,实现RC和RR级别的事务隔离性,满足ACID中的"I";
  3. 避免幻读(RR级别):RR级别下复用Read View,同一事务多次查询看到一致数据,避免幻读(注:InnoDB通过"间隙锁"彻底解决幻读,MVCC是辅助)。

5.2 MVCC的适用范围

  • 仅支持 InnoDB:MyISAM 等其他存储引擎不支持 MVCC;
  • 仅支持 RC 和 RR 隔离级别
    • 读未提交(RU):直接读最新版本,无需 MVCC;
    • 串行化(SERIALIZABLE):通过锁互斥实现隔离,不依赖 MVCC;
  • 仅支持 "快照读":普通 SELECT 是快照读(读历史版本),而 UPDATE/DELETE/SELECT ... FOR UPDATE 是 "当前读"(读最新版本,需加锁)。

六、总结

MVCC是InnoDB并发控制的灵魂,其本质是"通过保存数据的历史版本,结合Read View的可见性判断,实现读写分离"。核心要点可归纳为:

  1. 三大组件:隐藏列(身份)、Undo Log(版本仓库)、Read View(裁判);
  2. 关键差异:RC每次查询生成Read View,RR仅第一次生成;
  3. 核心价值:读不加锁、读写不冲突,平衡并发与数据一致性。

建议大家动手复现文中的实验,通过实际操作感受MVCC的现象,再结合原理拆解,就能真正掌握这一MySQL核心技术。

相关推荐
Macbethad2 小时前
数据库架构技术总结:MySQL主从/读写分离与PostgreSQL高可用
mysql·postgresql·数据库架构
无心水2 小时前
爆款实战!Vue3+Spring Boot+MySQL实现电商商品自动分类系统(含三级类目管理+规则兜底)
spring boot·mysql·分类·vue3商品分类·spring boot电商系统·三级类目管理·商品自动分类
IvorySQL2 小时前
版本发布| IvorySQL 5.1 发布
数据库·人工智能·postgresql·开源
yuniko-n2 小时前
【MySQL】通俗易懂的 MVCC 与事务
数据库·后端·sql·mysql
啦啦啦~~~7542 小时前
【最新版】Edge浏览器安装!绿色增强版+禁止Edge更新的软件+彻底卸载Edge软件
数据库·阿里云·电脑·.net·edge浏览器
程序边界2 小时前
金仓数据库助力Oracle迁移:一场国产数据库的逆袭之旅
数据库·oracle
为什么不问问神奇的海螺呢丶2 小时前
oracle RAC开关机步骤
数据库·oracle
后端小张2 小时前
【Java 进阶】深入理解Redis:从基础应用到进阶实践全解析
java·开发语言·数据库·spring boot·redis·spring·缓存
TDengine (老段)2 小时前
TDengine IDMP 1.0.9.0 上线:数据建模、分析运行与可视化能力更新一览
大数据·数据库·物联网·ai·时序数据库·tdengine·涛思数据