MVCC多版本并发控制
MVCC
MVCC 是 Inno DB 实现高并发、高性能事务处理的核心机制之一,尤其对于 READ COMMITTED
和 REPEATABLE READ
这两个常用的事务隔离级别至关重要。它的核心思想是:通过保存数据在某个时间点的多个版本来实现非锁定读(快照读),从而避免读写操作相互阻塞,提高数据库的并发能力。
MVCC要解决的核心问题
在传统的锁机制(如共享锁、排他锁)下:
- 读操作阻塞写操作:当一个事务持有读锁(S锁)时,其他事务无法获取写锁(X锁)进行修改,导致写操作被阻塞。
- 写操作阻塞读操作:当一个事务持有写锁(X锁)时,其他事务无法获取读锁(S锁)进行读取,导致读操作被阻塞。
- 锁竞争激烈:高并发场景下,锁竞争会成为性能瓶颈。
MVCC的目标就是让读操作(通常是 SELECT)不再需要加锁,从而避免上述阻塞,极大提高并发性能。写操作(也就是 INSERT、UPDATE、DELETE)仍然需要适当的锁(通常是行锁)来保证数据的一致性和隔离性。
MVCC的核心思想与关键组件
MVCC通过为数据库中的每一行记录维护多个历史版本来实现。当一个事务需要读取数据的时候,它看到的是在事务开始时或语句开始时已提交的数据版本(一个“快照”),而不是最新的、可能正在被其他事务修改的数据。这依赖于以下几个关键机制:
-
隐藏的系统字段:
-
InnoDB 在存储每一行记录时,会自动添加两个(有时三个)隐藏的系统字段:
-
DB_TRX_ID
(6 Bytes): 事务ID。 记录最后一次修改(INSERT 或 UPDATE) 该行记录的事务ID。删除操作在内部被视为一种特殊的 UPDATE(设置删除标记)。 -
DB_ROLL_PTR
(7 Bytes): 回滚指针。 指向该行记录在 Undo Log 中的上一个历史版本的指针。通过这个指针可以构建出一条记录的所有历史版本链表(版本链)。 -
DB_ROW_ID
(6 Bytes): 行ID (隐含主键)。 如果表没有定义主键,InnoDB 会自动生成一个单调递增的行 ID 作为聚簇索引。注意: 这个字段主要是为了构建索引,在 MVCC 机制中作用相对次要。
-
-
-
Undo Log (回滚日志):
-
Undo Log 是 MVCC 实现多版本的核心存储。当事务修改(UPDATE 或 DELETE)一行数据时:
-
会先将该行数据的当前版本(修改前的状态)拷贝到 Undo Log 中。
-
然后更新内存中该行数据的
DB_TRX_ID
为当前事务的 ID。 -
更新内存中该行数据的
DB_ROLL_PTR
指向刚刚写入 Undo Log 的历史版本。
-
-
对于 INSERT 操作,Undo Log 只需要记录新插入行的主键信息,以便在事务回滚时删除它。
-
Undo Log 中的历史版本通过
DB_ROLL_PTR
指针连接起来,形成一个按修改时间倒序排列的版本链。链表的头部是当前最新的版本。
-
-
Read View (读视图):
-
Read View 是 MVCC 机制中判断数据版本可见性的关键数据结构。当事务执行快照读(普通的
SELECT
)时,会生成一个 Read View(具体生成时机取决于隔离级别)。 -
Read View 本质上是事务启动时(或语句启动时,取决于隔离级别)数据库系统的一个快照,它包含以下重要信息:
-
m_ids
: 生成 Read View 时,当前系统中所有活跃(已启动但未提交)事务的事务ID列表。 -
min_trx_id
:m_ids
中的最小事务ID。 -
max_trx_id
: 生成 Read View 时,系统应该分配给下一个新事务的事务ID(即当前最大事务ID + 1)。 -
creator_trx_id
: 创建该 Read View 的事务自身的事务ID(对于只读事务,该ID可能为0)。
-
-
MVCC如何工作?——数据可见性规则
当一个事务执行快照读操作时,对于要访问的每一行数据,InnoDB会沿着该行数据的版本链,利用当前事务的 Read View 来判断哪个版本的数据对该事务是可见的。判断规则如下(按顺序检查):
-
检查最新版本的
DB_TRX_ID
: 如果该值等于creator_trx_id
(即该行最后是被自己修改的),可见。 -
检查
DB_TRX_ID
是否小于min_trx_id
: 如果DB_TRX_ID
<min_trx_id
,说明该版本是在生成 Read View 之前就已经提交了的,可见。 -
检查
DB_TRX_ID
是否大于等于max_trx_id
: 如果DB_TRX_ID
>=max_trx_id
,说明该版本是由在生成 Read View 之后才启动的事务修改的,不可见。 -
检查
DB_TRX_ID
是否在m_ids
列表中:-
如果在,说明修改该版本的事务在生成 Read View 时还处于活跃状态(未提交),不可见。
-
如果不在,说明修改该版本的事务在生成 Read View 时已经提交了,可见。
-
-
沿着版本链回溯: 如果当前版本不可见,则通过
DB_ROLL_PTR
指针找到上一个历史版本,重复步骤 1-4 进行判断,直到找到一个可见的版本或到达链尾(表示该行对该事务完全不可见)。
简单总结可见性规则:数据行版本的事务ID(DB_TRX_ID)必须满足以下条件之一才对该Read View可见:
- 等于创建Read View的事务自身ID(自己改的)。
- 小于Read View中的min_trx_id(老早以前提交的)。
- 不在Read View的活跃事务列表m_ids中,并且小于max_trx_id(在Read View创建时已经提交的)。
MVCC在不同隔离级别下的行为差异
MVCC的行为与事务隔离级别紧密相关,主要体现在Read View的生成时机上:
-
READ COMMITTED (RC - 读已提交):
-
Read View 生成时机: 每次执行
SELECT
语句时都会生成一个新的 Read View。 -
效果: 每次读取都能看到最新提交的数据。这解决了脏读(因为只读已提交的),但导致了不可重复读(因为两次读之间如果有其他事务提交了修改,新生成的Read View能看到这些修改)。
-
举例: 事务A第一次查询得到值X。事务B修改X为Y并提交。事务A第二次查询(生成新的Read View)就能看到Y。
-
-
REPEATABLE READ (RR - 可重复读 - InnoDB默认):
-
Read View 生成时机: 只在事务中第一次执行快照读(
SELECT
)时生成一个 Read View,并在整个事务期间都使用这个相同的 Read View。 -
效果: 在整个事务中,多次读取同一行数据,看到的都是第一次读时(或事务开始时)已经提交的数据版本。这解决了脏读和不可重复读。
-
幻读的特殊处理: 虽然RR级别下快照读通过固定的Read View避免了幻读(因为新插入的数据版本的事务ID大于
max_trx_id
,不可见),但当前读(SELECT ... FOR UPDATE
,SELECT ... LOCK IN SHARE MODE
,UPDATE
,DELETE
)会看到最新的已提交数据,可能感知到新插入的行(幻读)。InnoDB 通过 Next-Key Locking(间隙锁) 来防止其他事务在事务A扫描的范围内插入新行,从而在当前读层面也解决了幻读问题。所以,InnoDB 的 RR 级别通过 MVCC (快照读) + Next-Key Locking (当前读) 的组合,在绝大多数场景下解决了幻读问题。
-
-
READ UNCOMMITTED (RU - 读未提交):
-
不使用 MVCC 的快照读,总是读取最新的物理记录(可能未提交),所以存在脏读。InnoDB 实际实现中,RU 级别下
SELECT
语句不会生成 Read View,也不走 Undo Log 链回溯,直接读最新版本。
-
-
SERIALIZABLE (串行化):
-
不使用 MVCC 的快照读。InnoDB 会将普通的
SELECT
语句自动转换为SELECT ... LOCK IN SHARE MODE
,即加共享锁读。所有读写操作都严格串行化,通过锁机制保证最高隔离,性能最低。不使用 Read View。
-
MVCC的优势
- 高并发读:读操作(快照读)完全不加锁,不会被写操作阻塞,极大地提高了数据库的并发读取性能。这是MVCC最核心的优势。
- 非阻塞读:写操作在更新数据时(需要加锁)不会阻塞读操作(基于旧版本快照)。
- 降低死锁概率:减少了读操作需要获取锁的情况,从而降低了发生死锁的可能性。
- 实现特定隔离级别: 高效地实现了
READ COMMITTED
和REPEATABLE READ
隔离级别的语义,特别是避免了脏读和不可重复读(对快照读而言)。