深入理解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核心技术。

相关推荐
小陈工1 小时前
Python Web开发入门(十七):Vue.js与Python后端集成——让前后端真正“握手言和“
开发语言·前端·javascript·数据库·vue.js·人工智能·python
0xDevNull5 小时前
MySQL数据冷热分离详解
后端·mysql
科技小花5 小时前
数据治理平台架构演进观察:AI原生设计如何重构企业数据管理范式
数据库·重构·架构·数据治理·ai-native·ai原生
一江寒逸5 小时前
零基础从入门到精通MySQL(中篇):进阶篇——吃透多表查询、事务核心与高级特性,搞定复杂业务SQL
数据库·sql·mysql
D4c-lovetrain5 小时前
linux个人心得22 (mysql)
数据库·mysql
阿里小阿希6 小时前
CentOS7 PostgreSQL 9.2 升级到 15 完整教程
数据库·postgresql
荒川之神6 小时前
Oracle 数据仓库雪花模型设计(完整实战方案)
数据库·数据仓库·oracle
做个文艺程序员6 小时前
MySQL安全加固十大硬核操作
数据库·mysql·安全
不吃香菜学java6 小时前
Redis简单应用
数据库·spring boot·tomcat·maven
一个天蝎座 白勺 程序猿7 小时前
Apache IoTDB(15):IoTDB查询写回(INTO子句)深度解析——从语法到实战的ETL全链路指南
数据库·apache·etl·iotdb