Skip to content

Mvcc介绍

MVCC,全称为多版本并发控制(Multi-Version Concurrency Control),是一种用来在数据库中处理并发访问的技术,旨在提高数据库的并发性能。在传统的数据库系统中,为了保证数据的一致性,通常采用加锁的方式来处理读写冲突,但这会导致读写、写读、写写操作之间相互阻塞,严重影响并发能力。

MVCC的核心思想是,通过保存数据在某个时间点的快照来实现。这意味着每个事务看到的数据版本是其启动时就确定好了的,事务期间其他已提交事务所做的修改对它不可见。这样一来,读操作就不会阻塞写操作,写操作也不会阻塞读操作,只有写操作与写操作之间才会相互阻塞,从而极大地提升了数据库的并发处理能力。

在内部实现中,数据库通过undo log保存了数据的多个历史版本。当一个事务需要读取数据时,系统会根据事务的隔离级别和版本可见性规则,从版本链中找到一个合适的历史版本提供给该事务,而不是最新的数据。

适用隔离级别:MVCC主要工作在读已提交(Read Committed, RC)和可重复读(Repeatable Read, RR)这两个隔离级别下。因为未提交读总是读取最新的数据,而串行化则会对所有读取的数据行加锁。

MVCC的底层实现原理

MVCC的实现主要依赖于以下几个核心概念:隐藏字段undo log版本链ReadView

1. 隐藏字段

对于InnoDB存储引擎,每一行数据记录中除了我们定义的字段外,还包含了几个隐藏字段,其中最重要的两个是: * trx_id (事务ID):记录了最近一次修改(插入或更新)该行数据的事务的ID。这个ID是自增长的。 * roll_pointer (回滚指针):指向该行上一个版本的undo log记录。通过这个指针,可以将一行数据的多个版本串联起来。

还有一个隐藏字段row_id,它是在没有主键和非空唯一索引时,InnoDB自动生成的单调递增的行ID,但它与MVCC的直接关系不大。

2. undo log

undo log即回滚日志,它在MVCC中扮演着至关重要的角色,主要有两个作用: 1. 事务回滚:当事务需要回滚时,可以利用undo log将数据恢复到修改前的状态,保证原子性。 2. 构建版本链undo log存储了数据的历史版本,MVCC利用它来实现快照读。

undo log主要分为两种: * insert undo log: 在事务插入新记录时产生,仅在事务回滚时需要,事务提交后即可丢弃。 * update undo log: 在事务更新或删除记录时产生,不仅用于事务回滚,也用于快照读。只有在没有事务需要通过它来构建历史版本时,才会被清理。

3. 版本链

当多个事务对同一行数据进行修改时,会产生多个版本。这些版本通过roll_pointer(回滚指针)连接成一个链表,这个链表就被称为版本链。链表的头部是当前最新的数据版本,链表中的每个版本都记录了修改它的trx_id

1#### 4. ReadView (读视图)

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进行比较,判断该版本是否可见:

  1. 如果trx_id等于creator_trx_id:说明这个版本是当前事务自己修改的,可见
  2. 如果trx_id小于up_limit_id:说明修改这个版本的事务在当前事务生成ReadView之前已经提交了,因此该版本可见
  3. 如果trx_id大于或等于low_limit_id:说明修改这个版本的事务是在当前事务生成ReadView之后才开启的,因此该版本不可见
  4. 如果trx_idup_limit_idlow_limit_id之间:需要进一步判断trx_id是否存在于trx_ids列表中。
    • 如果存在,说明在生成ReadView时,修改该版本的事务还是活跃的(未提交),因此该版本不可见
    • 如果不存在,说明在生成ReadView时,修改该版本的事务已经提交了,因此该版本可见

如果一个版本经过上述判断后被认为是不可见的,事务就会通过roll_pointer顺着版本链继续寻找上一个版本,然后重复进行可见性判断,直到找到一个可见的版本为止。

MVCC在不同隔离级别下的工作方式

MVCC在读已提交 (RC)可重复读 (RR) 级别下的核心区别在于生成ReadView的时机

  • 读已提交 (RC):事务中的每一次快照读(SELECT语句)都会生成一个新的、最新的ReadView。这意味着,在一个事务中多次查询,可能会看到其他已提交事务所做的修改,从而导致不可重复读。
  • 可重复读 (RR):事务中的第一次快照读会生成一个ReadView,并且在整个事务期间都复用这个ReadView。因此,无论其他事务如何修改并提交,该事务看到的数据版本都是一致的,从而避免了不可重复读。

MVCC能否解决幻读?

这是一个非常有争议的问题,严谨的结论是:MVCC在一定程度上避免了快照读的幻读,但不能完全解决幻读问题。RR级别下彻底解决幻读,需要依靠锁(临键锁 Gap Lock + Record Lock)机制。

1. 为什么说MVCC不能完全解决幻读?

  1. 事务A (RR级别) 执行 SELECT * FROM user WHERE id = 5;,因为ReadView的缘故,查询结果为空。
  2. 事务B 插入一条 id = 5 的记录并提交
  3. 事务A 再次执行 SELECT * FROM user WHERE id = 5;,由于复用旧的ReadView,查询结果依然为空。从这个角度看,似乎没有幻读,因为两次读取结果一致,这是MVCC保证的“可重复读”。

但是,如果事务A接下来执行插入操作:INSERT INTO user (id, name) VALUES (5, '田七'); 此时数据库会报错,提示主键冲突。这就产生了“幻觉”:我明明查询不到id=5的记录,但插入时却告诉我记录已存在。这就是幻读

这个例子清晰地表明,单纯依靠MVCC机制,事务无法感知到其他事务已经提交的插入操作,导致在执行写入(当前读)时出现问题。

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(临键锁),它锁住了记录本身及其之前的间隙,从而防止其他事务在这个范围内插入新的记录。

结论: * MVCC机制通过保证事务在快照读时数据的一致性,解决了不可重复读问题。 * 对于幻读问题,MVCC只能避免快照读场景下的幻读(即多次SELECT结果一致),但无法解决当前读场景下的幻读。 * 在MySQL的RR隔离级别下,幻读问题最终是由锁机制(主要是Next-Key Lock)来解决的。