mvcc多版本并发控制
MySQL 中 InnoDB 存储引擎实现 MVCC
一、MVCC 的含义
MVCC,全称是 Multi-Version Concurrency Control,即多版本并发控制。
它是一种数据库管理技术,用于高效地处理多用户环境下的读-写和写-写冲突,从而实现非阻塞的读操作,并提高数据库的并发性能。
核心思想:
为每一行数据维护多个版本(通常是快照)。当一个事务需要读取数据时,数据库会呈现一个在事务开始时刻就已经存在的、一致的数据库快照。这个快照是基于某个时间点的版本,而不会看到在其之后其他事务的修改(取决于隔离级别)。这样,读操作就不会被写操作阻塞,反之亦然。
解决的问题:
- 读-写阻塞:在没有 MVCC 的情况下,为了避免“脏读”、“不可重复读”等问题,通常需要通过加锁(如
SELECT ... FOR UPDATE
)来实现,这会导致读操作等待写操作释放锁,或者写操作等待读操作释放锁,严重降低并发性。 - 提升性能:MVCC 使得大部分普通的
SELECT
查询(快照读)无需申请锁,极大地提升了数据库的读并发能力和整体性能。
主要支持的隔离级别:
MVCC 最常用于 READ COMMITTED 和 REPEATABLE READ 这两个隔离级别。InnoDB 的 RR 级别很大程度上借助 MVCC 避免了幻读问题。
二、MySQL InnoDB 的 MVCC 实现机制
InnoDB 通过以下三个核心组件来实现 MVCC:
1. 隐藏字段
InnoDB 为每一行数据(聚簇索引记录)添加了三个系统隐藏字段:
- DB_TRX_ID (6字节):事务ID。表示最后一次插入或更新该行的事务ID。删除在 InnoDB 内部也被视为一次更新。
- DB_ROLL_PTR (7字节):回滚指针。指向该行数据的前一个版本(存储在 Undo Log 中)的指针。通过它可以将数据回溯到之前的快照。
- DB_ROW_ID (6字节):行ID。单调递增的ID。如果表没有定义主键,InnoDB 会自动生成一个聚簇索引基于此字段。
2. Undo Log (回滚日志)
Undo Log 保存了数据被修改前的多个版本。当一行数据被更新时,旧版本的数据不会立即被删除,而是会被拷贝到 Undo Log 中,形成一个版本链。新版本的 DB_ROLL_PTR
字段就指向这个旧的版本。
- 这个版本链的头是最新的记录,通过
DB_ROLL_PTR
可以不断回溯到更早的记录。 - Undo Log 的另一个作用是用于事务回滚。
3. Read View (读视图)
这是决定一个事务能看到哪个版本数据的关键。当一个事务执行快照读(普通的 SELECT
)时,InnoDB 会为该事务生成一个Read View,它就像是给数据库拍了一个“快照”,但这个快照并非真实的数据拷贝,而是一个基于特定规则的视图。
Read View 主要包含以下内容:
- m_ids:生成 Read View 时,系统中活跃(已启动但未提交)的读写事务ID列表。
- min_trx_id:
m_ids
中的最小值。 - max_trx_id:生成 Read View 时,系统应该分配给下一个事务的ID。(注意:它并不是
m_ids
中的最大值,而是已分配的最大ID+1)。 - creator_trx_id:创建该 Read View 的当前事务自己的ID(只有写操作的事务才有ID,只读事务的ID为0)。
三、可见性判断规则
当一个事务要访问某行数据时,InnoDB 会遍历该行的版本链,并将链中每个版本的 DB_TRX_ID
与当前事务的 Read View 进行比对,以决定该版本是否可见。判断规则如下:
- 如果
DB_TRX_ID
等于creator_trx_id
,说明该版本是由当前事务自己修改的,可见。 - 如果
DB_TRX_ID
小于min_trx_id
,说明该版本在生成 Read View 时已经提交,可见。 - 如果
DB_TRX_ID
大于等于max_trx_id
,说明该版本是在生成 Read View 之后才开启的事务修改的,不可见。 - 如果
DB_TRX_ID
在min_trx_id
和max_trx_id
之间(即min_trx_id <= DB_TRX_ID < max_trx_id
),则需要检查DB_TRX_ID
是否在m_ids
(活跃事务列表)中:- 如果在,说明修改该版本的事务在生成 Read View 时还未提交,该版本不可见。
- 如果不在,说明修改该版本的事务在生成 Read View 时已经提交,该版本可见。
如果某个版本对当前事务不可见,则沿着 DB_ROLL_PTR
回滚指针找到上一个版本,重复上述判断规则,直到找到第一个可见的版本为止。
四、举例说明
假设我们有一张简单的表 accounts
:
id | name | balance | (DB_TRX_ID) | (DB_ROLL_PTR) |
---|---|---|---|---|
1 | Alice | 1000 | ... | ... |
事务执行序列如下:
时间点 | 事务A (Trx-ID=10) | 事务B (Trx-ID=20) | 事务C (Trx-ID=30,当前查询事务) |
---|---|---|---|
T1 | BEGIN; |
||
T2 | UPDATE accounts SET balance=900 WHERE id=1; |
||
T3 | BEGIN; |
||
T4 | UPDATE accounts SET balance=800 WHERE id=1; |
||
T5 | COMMIT; (提交) |
||
T6 | BEGIN; (开始一个快照读) |
||
T7 | SELECT balance FROM accounts WHERE id=1; |
此时,id=1
这行数据的版本链如下(从新到旧):
- Version N:
balance=800
(由 Trx-ID=20 修改,DB_ROLL_PTR
指向 Version M) - Version M:
balance=900
(由 Trx-ID=10 修改,DB_ROLL_PTR
指向 Version C) - Version C:
balance=1000
(创建该行数据的事务,假设已提交)
在 T7 时刻,事务C (Trx-ID=30
) 执行 SELECT
,会生成一个 Read View:
- 假设此时系统中只有事务B (20) 是活跃的,事务A (10) 已提交。
- 那么 Read View 的内容大约是:
m_ids
: [20]min_trx_id
: 20max_trx_id
: 31 (下一个事务ID)creator_trx_id
: 30 (事务C自己的ID)
现在,事务C 开始遍历版本链,判断哪个版本对它可见:
-
首先访问最新的 Version N (
balance=800
,trx_id=20
)- 规则1:
20
!=30
,不是自己修改的。 - 规则2:
20
>=min_trx_id
(20),不小于。 - 规则3:
20
<max_trx_id
(31),不大于等于。 - 规则4:
20
在m_ids=[20]
中?在! 说明修改它的事务(20)在生成ReadView时还未提交。 - 结论:Version N 不可见。 继续沿着指针找上一个版本。
- 规则1:
-
访问 Version M (
balance=900
,trx_id=10
)- 规则1:
10
!=30
,不是自己修改的。 - 规则2:
10
<min_trx_id
(20)?是! - 结论:Version M 可见! (因为修改它的事务10在ReadView生成前已经提交了)
- 规则1:
所以,事务C 最终读到的结果是 balance=900
。
哪些版本对事务C不可见?
- Version N (
balance=800
):因为修改它的事务B (20) 在事务C生成ReadView时还是活跃的,未提交。根据规则,不能看到未提交事务的修改(避免了脏读)。 - 所有在 ReadView 生成之后才产生的新版本(本例中没有),根据规则3都不可见。
关键点:
- 在 REPEATABLE READ 隔离级别下,事务只在第一次执行快照读时生成ReadView,后续的所有读操作都复用这个ReadView。因此,它每次读到的都是同一个快照,实现了可重复读。
- 在 READ COMMITTED 隔离级别下,事务在每次执行快照读时都会生成一个新的ReadView。这样,它每次都能看到最新已提交的数据。