MySQL-MVCC多版本并发控制详解
一、MVCC 是什么?
MVCC,全称 Multi-Version Concurrency Control,即多版本并发控制。
它是一种为了提高数据库并发性能而提出的技术方案。其核心思想是:
为每一行数据维护多个历史版本(通常是多个快照),使得在事务并发执行时,读操作不会阻塞写操作,写操作也不会阻塞读操作。 从而极大提高了数据库的读性能,并且避免了常见的锁等待。
MySQL 中,只有 InnoDB 存储引擎支持 MVCC。
二、为什么需要 MVCC?—— 解决锁的弊端
在没有 MVCC 的情况下,为了保证并发事务的隔离性(特别是 REPEATABLE-READ
和 READ-COMMITTED
级别),通常需要加锁。
写操作(如
UPDATE
,DELETE
)会给行加排他锁(X Lock),这会阻塞其他事务的读和写。读操作(如
SELECT
)为了确保读取一致的数据,可能也需要加共享锁(S Lock),这会阻塞其他事务的写。
这种“读-写”或“写-读”操作的相互阻塞,在高并发场景下会严重降低数据库的性能。
MVCC 提供了一种非锁读的机制,通过快照的方式让读操作可以去读历史版本的数据,而写操作可以同时更新当前的最新版本,二者互不干扰,完美解决了上述问题。
三、MVCC 的实现细节
MVCC 在 InnoDB 中的实现依赖于三个核心组件和 Undo Log。
1. 隐藏字段
InnoDB 为每一行数据(聚簇索引记录)添加了两个(或三个)隐藏的系统字段:
DB_TRX_ID
(6字节):最后修改该行数据的事务ID。当一个事务插入或修改某行时,会把自己的transaction id
写入这个字段。DB_ROLL_PTR
(7字节):回滚指针。指向该行数据在undo log
中的上一个历史版本。所有的历史版本通过这个指针串联成一个版本链。DB_ROW_ID
(6字节):行ID(隐藏主键)。如果表没有显式定义主键,InnoDB 会自动生成这个字段作为聚簇索引的主键。
2. Undo Log (回滚日志)
Undo Log 是 MVCC 的基石。它主要分为两种:
insert undo log:记录插入操作产生的日志,只在事务回滚时需要,事务提交后即可丢弃。
update undo log:记录更新和删除操作产生的日志。它才是 MVCC 实现的关键。
当执行 UPDATE
或 DELETE
操作时:
并不是直接物理删除当前数据,而是将当前行数据拷贝一份到 Undo Log 中。
然后用新的数据更新当前行,并更新
DB_TRX_ID
和DB_ROLL_PTR
字段,让DB_ROLL_PTR
指向刚刚存入 Undo Log 的旧版本记录。旧版本记录中的
DB_ROLL_PTR
又指向前一个更旧的版本。这样就形成了一条版本链。链首是最新的记录,链尾是最旧的记录。
3. Read View (读视图)
Read View 是事务进行快照读操作时产生的。 它定义了当前事务能看到哪个版本的数据。
当一个事务执行 SELECT
语句(快照读)时,InnoDB 会为该事务生成一个 Read View。它主要包含以下内容:
m_ids
:生成 Read View 时,系统中所有活跃(未提交)的事务ID列表。min_trx_id
:生成 Read View 时,系统中活跃事务中最小的事务ID,也就是m_ids
中的最小值。max_trx_id
:生成 Read View 时,系统应该分配给下一个事务的ID(即当前最大事务ID + 1)。creator_trx_id
:创建该 Read View 的事务的ID(只有执行写操作的事务才会被分配ID,只读事务的ID默认为0)。
四、可见性算法:如何判断版本是否可见?
有了版本链和 Read View,当一个事务要读取某行数据时,InnoDB 会从最新的版本开始,顺着版本链依次检查每个版本,根据 Read View 中的信息判断该版本是否对当前事务可见。
判断规则如下(假设要判断版本链中的某个记录 R
):
如果
R
的DB_TRX_ID
==creator_trx_id
,说明这个版本是当前事务自己修改的,可见。如果
R
的DB_TRX_ID
<min_trx_id
,说明这个版本在当前 Read View 创建之前就已经提交了,可见。如果
R
的DB_TRX_ID
>=max_trx_id
,说明这个版本是在当前 Read View 创建之后才开启的事务修改的,肯定不可见。如果
R
的DB_TRX_ID
在min_trx_id
和max_trx_id
之间(即trx_id in m_ids?
):如果
DB_TRX_ID
在m_ids
(活跃事务列表)中,说明修改该版本的事务在生成 Read View 时还未提交,该版本不可见。如果
DB_TRX_ID
不在m_ids
中,说明修改该版本的事务在生成 Read View 时已经提交,该版本可见。
如果某个版本对当前事务不可见,就顺着版本链找到上一个版本,重复上述判断规则,直到找到第一个可见的版本或到达链尾。
不同隔离级别下 Read View 的生成时机不同,这导致了可见性的差异:
READ COMMITTED:每次执行快照读时都会生成一个新的 Read View。所以每次都能看到最新提交的数据。
REPEATABLE READ:只在第一次执行快照读时生成一个 Read View,后续所有读操作都复用这个相同的 View。所以整个事务期间看到的数据都是一致的。
五、举例说明
假设有表 t
,初始数据:
id | value |
---|---|
1 | A |
事务时序:
事务Trx10:
BEGIN; UPDATE t SET value = ‘B’ WHERE id = 1;
(未提交)事务Trx20:
BEGIN; UPDATE t SET value = ‘C’ WHERE id = 1; COMMIT;
事务Trx30:
BEGIN;
然后执行SELECT * FROM t WHERE id = 1;
此时,版本链为:当前行 (value=‘C', trx_id=20, roll_ptr) --> Undo Log (value=‘B', trx_id=10, roll_ptr) --> Undo Log (value=‘A', trx_id=初始, roll_ptr=NULL)
情况1:隔离级别为 READ COMMITTED
Trx30 执行 SELECT
,生成新的 Read View:
m_ids
: [10, 30] (假设Trx10未提交,Trx30自己是活跃的)min_trx_id
: 10max_trx_id
: 31 (下一个事务ID)creator_trx_id
: 30
判断过程:
最新版本
value=‘C'
,trx_id=20
。20 <
min_trx_id
(10)? 不成立。20 在
m_ids
中吗?不在。所以此版本对 Trx30 可见。结果:读取到 ‘C'。
情况2:隔离级别为 REPEATABLE READ
Trx30 在事务中第一次执行 SELECT
,生成 Read View:
m_ids
: [10, 30]min_trx_id
: 10max_trx_id
: 31creator_trx_id
: 30
判断过程:
最新版本
value=‘C'
,trx_id=20
。20 不在
m_ids
中,且 20 >min_trx_id
(10),且 20 <max_trx_id
(31)。根据规则4,可见?注意规则4的细节:
trx_id
(20) 在min
和max
之间,需要检查是否在m_ids
中。因为m_ids
是 [10,30],20 不在其中,说明 Trx20 在生成 Read View 时已经提交了?等一下!这里有个关键点:Trx20 的提交是在 Trx30 生成 Read View 之后 还是 之前?
实际上,在 REPEATABLE READ 下,Trx30 的 Read View 在第一次 SELECT
时就生成了。假设 Trx20 的提交发生在 Trx30 的 Read View 生成之后,那么 Trx20 的 ID(20) 对于 Trx30 的 Read View 来说,是大于等于 max_trx_id
(31) 的吗?不,这里需要更精确的理解。
一个更准确的例子是,如果 Trx20 在 Trx30 开始之前就提交了,那么它的 trx_id
(20) 会小于 Trx30 的 min_trx_id
(30的View中的min是10,但20大于10),并且不在活跃事务列表中,因此是可见的。但如果 Trx20 在 Trx30 开始之后才提交,那么它在 Trx30 的 View 中就是不可见的。
为了避免混淆,记住核心:RR 级别下,一个事务只使用它开始时那个瞬间的快照。所有在该瞬间之后提交的事务所做的修改,对它都是不可见的。所以 Trx30 只能看到在它开始之前已经提交的数据(Trx20如果后提交,则不可见),或者它自己修改的数据。
六、Purge(清理)操作
Undo Log 中的历史版本不会永远保留。当系统里没有任何一个 Read View 需要用到某个旧版本时(即没有任何事务还需要看到这个版本),这个 Undo Log 记录就可以被清除了。InnoDB 有后台的 Purge 线程来负责这项工作。
这就是为什么强烈不建议使用长事务的原因。一个很长的事务会一直维持一个很老的 Read View,导致大量的旧版本数据无法被 Purge,从而使得 Undo Log 不断膨胀,占用大量存储空间。
总结
特性 | 说明 |
---|---|
目标 | 实现无锁的非阻塞读,提高并发性能。 |
实现基础 | 隐藏字段 (DB_TRX_ID , DB_ROLL_PTR ) + Undo Log(形成版本链) + Read View(判断可见性)。 |
与隔离级别的关系 | RC:每次读生成新ReadView,能看到最新提交。 RR:第一次读生成ReadView并复用,保证可重复读。 |
优点 | 读不上锁,读写不冲突,并发性能极高。 |
缺点 | 需要维护多版本数据,带来了额外的存储空间(Undo Log)和维护开销。 |