1. MVCC 的底层实现原理
MVCC 的实现主要依赖于以下几个核心概念:隐藏字段、undo log、版本链 和 ReadView。
1.1 隐藏字段
对于 InnoDB 存储引擎,每一行数据记录中除了我们定义的字段外,还包含了几个隐藏字段,其中最重要的两个是:
trx_id(事务 ID):记录了最近一次修改(插入或更新)该行数据的事务的 ID。这个 ID 是自增长的。roll_pointer(回滚指针):指向该行上一个版本的undo log记录。通过这个指针,可以将一行数据的多个版本串联起来。
还有一个隐藏字段 row_id,它是在没有主键和非空唯一索引时,InnoDB 自动生成的单调递增的行 ID,但它与 MVCC 的直接关系不大。
1.2 undo log
undo log 即回滚日志,它在 MVCC 中扮演着至关重要的角色,主要有两个作用:
- 事务回滚:当事务需要回滚时,可以利用
undo log将数据恢复到修改前的状态,保证原子性。 - 构建版本链:
undo log存储了数据的历史版本,MVCC 利用它来实现快照读。
undo log 主要分为两种:
- insert undo log: 在事务插入新记录时产生,仅在事务回滚时需要,事务提交后即可丢弃。
- update undo log: 在事务更新或删除记录时产生,不仅用于事务回滚,也用于快照读。只有在没有事务需要通过它来构建历史版本时,才会被清理。
1.3 版本链
当多个事务对同一行数据进行修改时,会产生多个版本。这些版本通过 roll_pointer(回滚指针)连接成一个链表,这个链表就被称为版本链。链表的头部是当前最新的数据版本,链表中的每个版本都记录了修改它的 trx_id。
ReadView 是 MVCC 实现可见性判断的核心。当一个事务执行快照读(普通的 SELECT 语句)时,数据库会为其生成一个 ReadView。这个 ReadView 记录并维护了在生成这个 ReadView 的时刻,系统中所有活跃(即未提交)的事务 ID 列表。可以理解为,ReadView 保存了当前事务不应该看到的其他事务 ID 列表。
ReadView 包含以下几个重要属性:
creator_trx_id:创建这个 ReadView 的事务的 ID。trx_ids:一个列表,记录了在生成 ReadView 时,系统中所有活跃(未提交)的读写事务 ID。up_limit_id:活跃事务列表trx_ids中最小的事务 ID。如果trx_ids为空,则等于low_limit_id。low_limit_id:生成 ReadView 时,系统下一个将要分配的事务 ID,即当前已出现的最大事务 ID + 1。
可见性判断规则:
当一个事务去访问某个版本链中的数据时,会用其持有的 ReadView 与该数据版本的 trx_id 进行比较,判断该版本是否可见:
- 如果
trx_id等于creator_trx_id:说明这个版本是当前事务自己修改的,可见。 - 如果
trx_id小于up_limit_id:说明修改这个版本的事务在当前事务生成 ReadView 之前已经提交了,因此该版本可见。 - 如果
trx_id大于或等于low_limit_id:说明修改这个版本的事务是在当前事务生成 ReadView 之后才开启的,因此该版本不可见。 - 如果
trx_id在up_limit_id和low_limit_id之间:需要进一步判断trx_id是否存在于trx_ids列表中。- 如果存在,说明在生成 ReadView 时,修改该版本的事务还是活跃的(未提交),因此该版本不可见。
- 如果不存在,说明在生成 ReadView 时,修改该版本的事务已经提交了,因此该版本可见。
如果一个版本经过上述判断后被认为是不可见的,事务就会通过 roll_pointer 顺着版本链继续寻找上一个版本,然后重复进行可见性判断,直到找到一个可见的版本为止。
2. MVCC 在不同隔离级别下的工作方式
MVCC 在读已提交 (RC) 和 可重复读 (RR) 级别下的核心区别在于生成 ReadView 的时机:
- 读已提交 (RC):事务中的每一次快照读(
SELECT语句)都会生成一个新的、最新的 ReadView。这意味着,在一个事务中多次查询,可能会看到其他已提交事务所做的修改,从而导致不可重复读。 - 可重复读 (RR):事务中的第一次快照读会生成一个 ReadView,并且在整个事务期间都复用这个 ReadView。因此,无论其他事务如何修改并提交,该事务看到的数据版本都是一致的,从而避免了不可重复读。
3. MVCC 能否解决幻读?
这是一个非常有争议的问题,严谨的结论是:MVCC 在一定程度上避免了快照读的幻读,但不能完全解决幻读问题。RR 级别下彻底解决幻读,需要依靠锁(临键锁 Gap Lock + Record Lock)机制。
3.1 为什么说 MVCC 不能完全解决幻读?
- 事务 A (RR 级别) 执行
SELECT * FROM user WHERE id = 5;,因为 ReadView 的缘故,查询结果为空。 - 事务 B 插入一条
id = 5的记录并提交。 - 事务 A 再次执行
SELECT * FROM user WHERE id = 5;,由于复用旧的 ReadView,查询结果依然为空。从这个角度看,似乎没有幻读,因为两次读取结果一致,这是 MVCC 保证的“可重复读”。
但是,如果事务 A接下来执行插入操作:INSERT INTO user (id, name) VALUES (5, '田七');
此时数据库会报错,提示主键冲突。这就产生了“幻觉”:我明明查询不到 id=5 的记录,但插入时却告诉我记录已存在。这就是幻读。
这个例子清晰地表明,单纯依靠 MVCC 机制,事务无法感知到其他事务已经提交的插入操作,导致在执行写入(当前读)时出现问题。
3.2 快照读 vs. 当前读
- 快照读 (Snapshot Read):普通的
SELECT语句,不加锁,读取的是符合可见性规则的历史版本数据。 - 当前读 (Current Read):会读取记录的最新版本,并对读取的记录加锁,以保证其他事务不能并发修改。以下语句都是当前读:
SELECT ... FOR UPDATE(加排他锁)SELECT ... LOCK IN SHARE MODE(加共享锁)INSERT,UPDATE,DELETE(隐式加锁)
MVCC 只对快照读有效。对于当前读,为了避免幻读,MySQL 的 InnoDB 引擎在 RR 级别下引入了Next-Key Lock(临键锁),它锁住了记录本身及其之前的间隙,从而防止其他事务在这个范围内插入新的记录。