在数据库的世界里,并发控制 是一个永恒的话题。如何在多用户同时操作数据时,既保证数据的一致性,又能实现高性能?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
-- 事务 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
-- 事务 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 (转账前的余额检查与锁定)
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 尝试操作:
-- 会话 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 的系统认知!