在数据库的世界里,并发控制 是一个永恒的话题。如何在多用户同时操作数据时,既保证数据的一致性 ,又能实现高性能 ?InnoDB 存储引擎给出了一个优雅的答案:MVCC(Multi-Version Concurrency Control,多版本并发控制)。
如果你曾对数据库的并发机制感到困惑,或者对 SELECT 语句为何不阻塞 UPDATE 感到好奇,那么这篇文章正是为你准备的。我们将从最基础的概念出发,一步步揭开 MVCC 的神秘面纱,直抵其底层实现原理,并结合实际案例和 SQL 语句,让你彻底掌握 MVCC 的精髓。
一、并发控制的痛点:为什么我们需要 MVCC?
想象一下,在一个电商平台,多个用户同时浏览商品、下单支付。如果没有合理的并发控制机制,就会出现各种问题:
- 脏读(Dirty Read) :一个事务读到了另一个未提交事务修改的数据。如果那个未提交事务回滚了,读到的就是"假"数据。
- 不可重复读(Non-Repeatable Read) :在同一个事务中,两次读取同一条记录,发现数据不一致,因为其他事务在这两次读取之间提交了修改。
- 幻读(Phantom Read) :在同一个事务中,两次执行相同的查询,发现行数不同,因为其他事务在这两次查询之间插入或删除了符合查询条件的记录。
传统的并发控制,通常通过锁(Locking) 来解决这些问题。例如,当一个事务在修改数据时,就对数据加排他锁,其他事务想读或写就必须等待。虽然保证了数据一致性,但极大地牺牲了并发性能。
MVCC 正是为了解决读写冲突的痛点而生: 它通过保存数据的多个历史版本,让读操作可以读取数据的旧版本(快照),而无需等待写操作释放锁。
二、MVCC 的核心基石:隐藏列与 Undo Log 版本链
MVCC 并非魔法,它的背后是一套精巧的设计。理解 MVCC,首先要了解 InnoDB 行格式中的几个隐藏列 和 Undo Log 版本链。
1. 隐藏列:行数据的"身份证"与"时光机"
InnoDB 为每行记录额外增加了几个隐藏列,它们是 MVCC 得以运行的"基础设施":
-
DB_TRX_ID(Transaction ID):- 占用 6 字节。
- 记录了最近一次 修改该行数据的事务 ID 。每当一个事务修改(
INSERT/UPDATE/DELETE)了一行数据,它就会将自己的事务 ID 写入这行的DB_TRX_ID列。 - 重要提示:
DB_TRX_ID的更新时机是在数据修改语句执行后立即更新 ,而非事务提交后。事务提交只是改变了该事务 ID 的状态(从活跃变为已提交),而不会改变行上的DB_TRX_ID值。
-
DB_ROLL_PTR(Roll Pointer):- 占用 7 字节。
- 这是一个回滚指针 ,指向当前行的上一个版本在 Undo Log 段中的位置。
- 每当一行数据被更新时,它的旧版本数据会被写入 Undo Log,并生成一条新的 Undo Log 记录。
DB_ROLL_PTR就指向这条新的 Undo Log 记录的地址。
-
DB_ROW_ID(Row ID):- 占用 6 字节。
- 这是一个隐藏的行 ID 。如果表没有定义主键,也没有定义任何非空唯一键,那么 InnoDB 会自动为每行数据生成一个
DB_ROW_ID作为隐藏的聚集索引。有了主键或唯一键,这个列通常不直接参与 MVCC 逻辑。

2. Undo Log 版本链:数据的"历史记录"
正是通过 DB_TRX_ID 和 DB_ROLL_PTR 这两个隐藏列,以及 Undo Log ,InnoDB 构建了行的版本链。
当一行数据被修改时,InnoDB 的操作流程是:
- 将当前行记录的旧版本数据 复制一份到 Undo Log 中。
- 在 Undo Log 中,这条记录会包含上一个版本的
DB_ROLL_PTR值,从而将自身与更早的版本连接起来。 - 在数据行本身,更新
DB_TRX_ID为当前事务的 ID,并更新DB_ROLL_PTR指向新生成的 Undo Log 记录的地址。 - 数据行本身的最新内容被修改。
这样,一行数据的每一次修改,都会在 Undo Log 中留下一个"足迹",并且这些足迹通过 DB_ROLL_PTR 逆向连接,形成一条指向过去的链条,这就是Undo Log 版本链。
示例:一个数据的版本链演变
假设 products 表中有一条记录 id=1, name='Laptop', price=80 (初始事务 T0 插入)。
UPDATE 1:事务 A (T100) 将 price 改为 90
sql
-- 事务 A (ID: T100)
START TRANSACTION;
UPDATE products SET price = 90 WHERE id = 1;
COMMIT;
此时,数据行和 Undo Log 的逻辑状态(U1 和 U2 代表的是 Undo Log 记录的逻辑地址或标识符。):
- 当前数据行:
id=1, name='Laptop', price=90, DB_TRX_ID=T100, DB_ROLL_PTR -> U1 - Undo Log (U1):
id=1, name='Laptop', price=80, DB_TRX_ID=T0, DB_ROLL_PTR=NULL

UPDATE 2:事务 B (T200) 将 price 改为 95
sql
-- 事务 B (ID: T200)
START TRANSACTION;
UPDATE products SET price = 95 WHERE id = 1;
COMMIT;
此时,数据行和 Undo Log 的逻辑状态:
- 当前数据行:
id=1, name='Laptop', price=95, DB_TRX_ID=T200, DB_ROLL_PTR -> U2 - Undo Log (U2):
id=1, name='Laptop', price=90, DB_TRX_ID=T100, DB_ROLL_PTR -> U1 - Undo Log (U1):
id=1, name='Laptop', price=80, DB_TRX_ID=T0, DB_ROLL_PTR=NULL
这就形成了一个完整的版本链:price=95 (T200) -> price=90 (T100) -> price=80 (T0)。

三、读视图 (Read View):MVCC 的"时间旅行"机制
光有版本链还不够,数据库怎么知道哪个事务应该看到哪个版本的数据呢?这就需要读视图 (Read View)。
当一个事务执行快照读 (即普通的 SELECT 语句,不带 FOR UPDATE 或 LOCK IN SHARE MODE)时,InnoDB 会为它创建一个读视图 。这个读视图就相当于给当前事务拍了一张"照片",决定了它在整个事务生命周期内(针对 REPEATABLE READ)或每次快照读时(针对 READ COMMITTED)能看到哪些数据。
一个读视图主要包含以下信息:
m_ids: 当前系统中所有活跃的(即还未提交的)事务 ID 列表。min_trx_id:m_ids列表中最小的事务 ID。max_trx_id: 创建读视图时,系统下一个要分配的事务 ID。creator_trx_id: 创建这个读视图的事务本身的 ID。
四、MVCC 的核心算法:可见性判断
有了版本链和读视图,MVCC 的可见性判断逻辑就变得清晰了。当一个快照读事务要读取一行数据时,它会按照以下步骤判断数据行的哪个版本是可见的:
-
获取最新版本: 从数据页上获取当前行的最新版本。
-
检查
DB_TRX_ID: 获取到最新版本后,检查这个版本的DB_TRX_ID。 -
判断可见性:
- 如果该行的
DB_TRX_ID==creator_trx_id: 如果是当前事务自己修改的,可见。 - 如果该行的
DB_TRX_ID<min_trx_id: 如果该版本是在读视图创建之前就已经提交 的事务修改的,可见。 - 如果该行的
DB_TRX_ID>=max_trx_id: 如果该版本是在读视图创建之后才启动的事务修改的,不可见 ,回溯到 Undo Log 版本链中的上一个版本。 - 如果该行的
DB_TRX_ID存在于m_ids列表中(活跃事务列表): 如果该版本是由一个当前正在活跃 (未提交)的事务修改的,不可见 ,回溯到 Undo Log 版本链中的上一个版本。 - 如果该行的
DB_TRX_ID不存在于m_ids列表中(即在读视图创建时已提交): 可见。
- 如果该行的
-
回溯: 如果当前版本不可见,则沿着
DB_ROLL_PTR回溯到 Undo Log 版本链中的上一个版本,重复步骤 2 和 3,直到找到一个可见版本,或者版本链回溯到末尾(表示该行在读视图创建后才插入,对当前事务不可见)。
五、MVCC 在不同隔离级别下的应用与案例
MVCC 主要在 READ COMMITTED 和 REPEATABLE READ 隔离级别下发挥作用。它们的主要区别在于读视图的生成时机。
1. READ COMMITTED (RC) 隔离级别
- 读视图生成时机: 每一次快照读 (
SELECT语句)都会生成一个新的读视图。 - 效果: 事务可以读到其他事务已提交的最新修改。
- 解决了: 脏读。
- 存在问题: 不可重复读(同一个事务内,两次读取同一行数据可能不同)。
示例:RC 隔离级别下的不可重复读
假设 student 表初始数据:id=1, age=22, name='Saul'
| 事务101 | 事务102 | 事务103 | 事务104 |
|---|---|---|---|
| 开启事务 | 开启事务 | 开启事务 | 开启事务 |
| 修改id为1的数据行,将age修改为25 | |||
| 修改id为1的数据行,将name修改为:Jaclal | |||
| 提交事务 | 修改id为1的数据行,将age修改为18 | ||
| 查询id为1的记录 | |||
| 提交事务 | |||
| 查询id为1的记录 | |||
| 提交事务 | |||
| [ ] |
在上述事务操作时序中,我们可以清晰地看到事务是如何并发执行并修改同一条记录的。在所有事务完成提交后,该记录最终的状态是 id=1, age=18, name='Jaclal'。然而,为了实现 MVCC (多版本并发控制),每一次对记录的修改都不会直接覆盖原始数据,而是会生成一个新的记录版本,并将旧版本的数据放入 Undo Log 中。通过 DB_ROLL_PTR 这个指针,这些旧版本数据在 Undo Log 中形成了一条完整的链条。这个 Undo Log 链正是数据库能够回溯历史版本、实现快照读以及事务回滚的关键所在。正是这些环环相扣的版本,构成了在不同时间点下事务可见性的基础。

分析事务 104 第一次 SELECT 语句(以开启事务后第一行为T1):
当事务 104 执行第一条 SELECT * FROM student WHERE id=1; 语句时,它会生成一个 ReadView。我们来看一下此时的事务状态:
- 事务 101: 在 T3 时刻已经提交。因此,事务 101 不在活跃事务列表中。
- 事务 102: 在 T2 时刻修改数据,但尚未提交 (它的提交操作在 T5 时刻)。因此,在事务 104 执行第一次查询的 T4 时刻,事务 102 处于活跃状态。
- 事务 103: 在 T3 时刻修改数据,但尚未提交 (它的提交操作在 T10 时刻)。因此,在事务 104 执行第一次查询的 T4 时刻,事务 103 处于活跃状态。
- 事务 104: 自身正在执行查询,所以它也是活跃的。
基于以上分析,事务 104 在执行第一次 SELECT 语句时生成的 ReadView 状态如下:
m_ids:m_ids列表中包含了所有在ReadView创建时仍处于活跃状态的事务 ID。根据时序,此时活跃的事务有 102、103 以及事务自身 104 。因此,m_ids = {102, 103, 104}。min_trx_id: 活跃事务m_ids列表中最小的事务 ID 是 102。max_trx_id: 当前系统中下一个即将分配的事务 ID,假设为 105。creator_trx_id: 创建这个ReadView的事务 ID,即事务自身 104。
因此,事务 104 第一次执行 SELECT 语句时,其 ReadView 的状态正是如下图中所示的 m_ids: {102, 103, 104}, min_trx_id: 102, max_trx_id: 105, creator_trx_id: 104。 这个 ReadView 将用于判断它能够看到哪些历史版本的数据。

判断可见性:事务 104 第一次 SELECT 如何读取数据
在 InnoDB 的 MVCC 机制下,当事务 104 执行 SELECT 语句时,它会结合自身生成的 ReadView 和当前记录的数据以及 Undo Log 链 来判断哪个版本的数据是可见的。这个过程遵循以下可见性判断规则:
- 如果该行的
DB_TRX_ID==creator_trx_id: 是当前事务自己修改的,可见。 - 如果该行的
DB_TRX_ID<min_trx_id: 该版本是在读视图创建之前就已经提交 的事务修改的,可见。 - 如果该行的
DB_TRX_ID>=max_trx_id: 该版本是在读视图创建之后才启动的事务修改的,不可见 ,回溯到 Undo Log 版本链中的上一个版本。 - 如果该行的
DB_TRX_ID存在于m_ids列表中(活跃事务列表): 该版本是由一个当前正在活跃 (未提交)的事务修改的,不可见 ,回溯到 Undo Log 版本链中的上一个版本。 - 如果该行的
DB_TRX_ID不存在于m_ids列表中(即在读视图创建时已提交): 可见。
现在,我们结合事务 104 的 ReadView (m_ids: {102, 103, 104}, min_trx_id: 102, max_trx_id: 105, creator_trx_id: 104) 和 Undo Log 链 ,来判断事务 104 在执行第一次 SELECT 查询时,最终会读到哪条数据:
-
检查当前行(最新版本):
- 数据:
id=1, age=18, name='Jaclal',DB_TRX_ID=103 - 判断: 该行
DB_TRX_ID为103。103 == creator_trx_id (104)?否。103 < min_trx_id (102)?否。103 >= max_trx_id (105)?否。103存在于m_ids列表{102, 103, 104}中?是。
- 结果: 由于
DB_TRX_ID=103存在于m_ids活跃事务列表中,表示该版本是由一个活跃事务(事务 103)修改的,对事务 104 不可见。需要回溯到Undo Log链中的上一个版本,即U3.
- 数据:
-
检查
Undo Log版本U3:- 数据:
id=1, age=25, name='Jaclal',DB_TRX_ID=102 - 判断: 该版本
DB_TRX_ID为102。102 == creator_trx_id (104)?否。102 < min_trx_id (102)?否。102 >= max_trx_id (105)?否。102存在于m_ids列表{102, 103, 104}中?是。
- 结果: 由于
DB_TRX_ID=102存在于m_ids活跃事务列表中,表示该版本是由一个活跃事务(事务 102)修改的,对事务 104 不可见。需要回溯到Undo Log链中的上一个版本,即U2.
- 数据:
-
检查
Undo Log版本U2:- 数据:
id=1, age=25, name='Saul',DB_TRX_ID=101 - 判断: 该版本
DB_TRX_ID为101。101 == creator_trx_id (104)?否。101 < min_trx_id (102)?是。
- 结果: 由于
DB_TRX_ID=101小于ReadView的min_trx_id (102),表示该版本是由一个在ReadView创建之前就已经提交的事务(事务 101)修改的,对事务 104 可见。
- 数据:
结论:
根据上述可见性判断过程,当事务 104 执行第一次 SELECT 查询时,它会回溯 Undo Log 链,最终发现 Undo Log 中的 U2 版本 (id=1, age=25, name='Saul', DB_TRX_ID=101) 是对它可见的 。因此,事务 104 的第一次查询将读取到 id=1, age=25, name='Saul' 这条数据。
分析事务 104 第二次 SELECT 语句(以开启事务后第一行为T1):
当事务 104 执行第二条 SELECT * FROM student WHERE id=1; 语句时,它依然会生成一个新的 ReadView。我们来看一下此时的事务状态:
- 事务 101: 在 T3 时刻已经提交。因此,事务 101 不在活跃事务列表中。
- 事务 102: 在 T2 时刻修改数据,在 T5 时刻提交。因此,在事务 104 执行第二次查询的 T8 时刻,事务 102 不在活跃事务列表中。
- 事务 103: 在 T3 时刻修改数据,它的提交操作在 T10 时刻。因此,在事务 104 执行第二次查询的 T8 时刻,事务 103 处于活跃状态。
- 事务 104: 自身正在执行查询,所以它也是活跃的。
基于以上分析,事务 104 在执行第一次 SELECT 语句时生成的 ReadView 状态如下:
m_ids:m_ids列表中包含了所有在ReadView创建时仍处于活跃状态的事务 ID。根据时序,此时活跃的事务有 103 以及事务自身 104 。因此,m_ids = {103, 104}。min_trx_id: 活跃事务m_ids列表中最小的事务 ID 是 103。max_trx_id: 当前系统中下一个即将分配的事务 ID,假设为 105。creator_trx_id: 创建这个ReadView的事务 ID,即事务自身 104。
因此,事务 104 第二次执行 SELECT 语句时,其 ReadView 的状态正是如下图中所示的 m_ids: {103, 104}, min_trx_id: 103, max_trx_id: 105, creator_trx_id: 104。 这个 ReadView 将用于判断它能够看到哪些历史版本的数据。

同样,我们来分析判断过程:
-
检查当前行(最新版本):
- 数据:
id=1, age=18, name='Jaclal',DB_TRX_ID=103 - 判断: 该行
DB_TRX_ID为103。103 == creator_trx_id (104)?否。103 < min_trx_id (103)?否。103 >= max_trx_id (105)?否。103存在于m_ids列表{103, 104}中?是。
- 结果: 由于
DB_TRX_ID=103存在于m_ids活跃事务列表中,表示该版本是由一个活跃事务(事务 103)修改的,对事务 104 不可见。需要沿着DB_ROLL_PTR回溯到Undo Log链中的上一个版本,即U3.
- 数据:
-
检查
Undo Log版本U3:- 数据:
id=1, age=25, name='Jaclal',DB_TRX_ID=102 - 判断: 该版本
DB_TRX_ID为102。102 == creator_trx_id (104)?否。102 < min_trx_id (103)?是。
- 结果: 由于
DB_TRX_ID=102小于ReadView的min_trx_id (103),表示该版本是由一个在ReadView创建之前就已经提交的事务(事务 102)修改的,对事务 104 可见。
- 数据:
结论:
根据上述可见性判断过程,当事务 104 执行第二次 SELECT 查询时,它会回溯 Undo Log 链,最终发现 Undo Log 中的 U3 版本 (id=1, age=25, name='Jaclal', DB_TRX_ID=102) 是对它可见的 。因此,事务 104 的第二次查询将读取到 id=1, age=25, name='Jaclal' 这条数据。
2. REPEATABLE READ (RR) 隔离级别
- 读视图生成时机: 事务中第一次快照读 时生成读视图,此后整个事务生命周期都使用这个固定的读视图。
- 效果: 事务在整个生命周期内,看到的都是它第一次快照读时的数据快照,即便其他事务修改并提交了数据,当前事务也"看不到"。
- 解决了: 脏读 、不可重复读。
- 存在问题: 幻读 (虽然通过 MVCC 解决了大部分幻读,但对于
INSERT操作,RR 结合间隙锁来彻底解决幻读)。
数据读取行为: 在 REPEATABLE READ 隔离级别下,事务 104 两次 SELECT 查询的数据读取行为与 READ COMMITTED 隔离级别的主要区别在于 ReadView 的复用机制。由于 ReadView 在事务首次快照读时即已固定,因此后续的快照读会基于相同的活跃事务列表和事务 ID 范围进行可见性判断。
具体到本例:
- 事务 104 第一次
SELECT: 其ReadView(m_ids: {102, 103, 104}, min_trx_id: 102, max_trx_id: 105, creator_trx_id: 104) 会导致其回溯到Undo Log链中的U2版本,读取到id=1, age=25, name='Saul'。 - 事务 104 第二次
SELECT: 即使在两次查询之间有其他事务(如事务 102)提交了其修改,事务 104 仍然复用第一次查询时生成的ReadView(m_ids: {102, 103, 104}, min_trx_id: 102, max_trx_id: 105, creator_trx_id: 104)。因此,它将再次回溯到Undo Log链中的U2版本,仍然读取到id=1, age=25, name='Saul'。
核心差异总结:
REPEATABLE READ 隔离级别通过其"事务级快照"的 ReadView 策略,确保了在一个事务的生命周期内,对于同一条记录的多次快照读都能得到一致的结果,从而有效地避免了 READ COMMITTED 隔离级别下可能出现的不可重复读问题。它隔离了其他事务在当前事务启动后提交的修改。
六、当前读 (Current Read):获取最新数据并加锁
MVCC 主要解决的是快照读 的并发问题。但是,有时我们确实需要读取最新 的数据,并且要防止其他事务立即修改它。这时就需要用到当前读。
当前读会读取最新的数据,并对读取到的记录加锁。
主要有以下几种情况属于当前读:
- 所有 DML 语句:
INSERT、UPDATE、DELETE。- 为了修改数据,必然需要先获取最新的数据。这些操作都会对相关行加排他锁。
SELECT ... FOR UPDATE:- 读取最新数据,并对读取到的行加排他锁(X 锁)。其他事务不能读取(会阻塞)、不能修改这些被锁定的行。通常用于悲观锁定。
SELECT ... LOCK IN SHARE MODE:- 读取最新数据,并对读取到的行加共享锁(S 锁)。其他事务可以读取这些行(也能加共享锁),但不能修改这些行。
示例:SELECT ... FOR UPDATE 的应用
假设在 accounts 表中 id=101, balance=1000。
会话 A (转账前的余额检查与锁定)
sql
START TRANSACTION;
-- 读取账户余额并加排他锁,确保在我检查并扣款期间,没有其他事务能修改这个账户
SELECT balance FROM accounts WHERE account_id = 101 FOR UPDATE;
-- 结果:balance = 1000。此时 account_id = 101 的行被会话 A 加上了排他锁。
-- 假设业务逻辑判断余额足够,进行扣款
UPDATE accounts SET balance = balance - 200 WHERE account_id = 101;
-- 此时 account_id = 101 的 balance 在会话 A 的事务中是 800。
COMMIT;
并发会话 B 尝试操作:
sql
-- 会话 B
START TRANSACTION;
-- 尝试读取 (快照读)
SELECT balance FROM accounts WHERE account_id = 101;
-- 结果:如果隔离级别是 RR,会读到 1000(会话 A 之前的版本)。
-- 尝试更新 (当前读,会加锁)
UPDATE accounts SET balance = balance + 50 WHERE account_id = 101;
-- 这条语句会因为 account_id = 101 被会话 A 持有的排他锁而**阻塞**,直到会话 A 提交。
七、总结
MVCC 是 InnoDB 实现高并发和高可用性的基石之一。通过巧妙地利用 Undo Log 版本链 和 读视图,它允许读操作不阻塞写操作,写操作不阻塞读操作,从而在保证数据一致性的同时,大幅提升了数据库的并发处理能力。
- 快照读 (Snapshot Read) :普通的
SELECT,利用 MVCC 读取历史版本,不加锁,不阻塞。 - 当前读 (Current Read) :
INSERT/UPDATE/DELETE或SELECT ... FOR UPDATE/LOCK IN SHARE MODE,读取最新版本,并加锁,保证数据强一致性。
理解 MVCC 的原理,有助于我们更深入地优化 SQL 查询、选择合适的隔离级别,并在设计高并发系统时做出更明智的决策。
希望通过这篇博客,你对 MVCC 会有了从 0 到 1 的系统认知!