认识 MVCC

什么是 MVCC

MVCC(Mutil Version Concurrency Control)多版本并发控制,是一种并发控制的方法,用于在多个并发事务同时读写数据库时保持数据的一致性和隔离性。

它是通过在每个数据行上维护多个版本的数据来实现的。当一个事务要对数据库中的数据进行修改的时候,MVCC 会为该事务创建一个数据快照,而不是直接修改实际的数据行。(个人认为这一定上参考了COW写时复制机制)

同时,读取数据时,会根据事务的“可见性规则”,选择合适的历史版本进行读取。这种机制让“读运行”和“写操作”可以并行执行,互不阻塞。

在 MySQL 的 InnoDB 引擎中,使用了这种技术,来实现对数据库的并发访问。

上面的解释比较抽象,下面来一点一点分析。

并发事务可能产生的问题

当一个事务访问数据库的数据时,无论读、写,都不会产生并发问题。因为只有一个线程在操作共享资源

但是,当两个以上事务同时访问数据库中的相同数据时,就可能出现并发问题了,以两个为例子:

  • 都读:两个事务都查询数据。当两个事务对相同数据全部是读操作时,不会产生任何并发问题。
  • 读 + 写:一个事务查询数据,一个事务修改数据。这就有可能出现脏读,幻读,不可重复读的三大事务问题
  • 都写:两个事务都对相同的数据同时进行写操作,这是极其容易出现问题的(回滚丢失、覆盖丢失)

那如何针对上面的问题采取策略呢?

  • 都读不会产生并发问题,不用管

  • 并发事务对同时出现读和写操作的时候,常规操作一般会对要操作的数据加锁来解决读写并发进行的问题

    提前说一下,MySQL 的 InnoDB 实现了 MVCC 来更好地处理读写冲突,可以做到即使存在并发读写,也可以不用加锁,实现非阻塞情况的并发读

  • 并发事务对数据的写操作,只能通过加锁,乐观锁或者悲观锁,来解决。

所以说,在没有MVCC的传统锁机制中,并发场景会面临严重的性能问题:

  • 读阻塞写:当事务 A 读取某条资料时,会加共享锁(S锁),此时事务B要修改该内容,需加排他锁(X锁),但S锁和X锁互斥,事务 B 会被阻塞;
  • 写阻塞读:当事务 A 修改数据加 X 锁时,事务 B 读取该数据需加 S 锁,同样会被阻塞。

假设你的银行卡余额是 1000 元,现在有两个并发操作:

  • 事务 A(读):你 APP 上查余额(加 S 锁),需要 1 秒;
  • 事务 B(写):别人给你转 500 元(加 X 锁),需要 0.5 秒。
  1. 读阻塞写的情况
    • 事务 A 先执行:0 秒开始查余额,加 S 锁 → 1 秒结束,释放 S 锁;
    • 事务 B 在 0.5 秒时发起转账:想加 X 锁,但 S 锁和 X 锁互斥 → 事务 B 必须等事务 A 的 S 锁释放(也就是 1 秒后)才能执行;
    • 结果:原本 0.5 秒能完成的转账,硬生生等了 0.5 秒,并发效率极低。
  2. 写阻塞读的情况
    • 事务 B 先执行:0 秒开始转账,加 X 锁 → 0.5 秒结束,释放 X 锁;
    • 事务 A 在 0.2 秒时查余额:想加 S 锁,但 X 锁未释放 → 事务 A 必须等 0.5 秒后才能执行;
    • 结果:你查余额要等 0.3 秒,体验极差。

也就是说,传统锁机制下,读锁和写锁强互斥,只要一个操作占着锁,另一个就必须等,这和 Java JUC 思想中的读写锁分离的情况是不一致的。

MVCC(多版本并发控制)是怎么解决这个问题的?

MVCC 的是解决读写都存在时候的并发问题的,为数据保留多个历史版本,读操作读历史版本,写操作生成新版本,二者互不干扰

那么,具体实现过程就是

  1. 读操作 SELECT

    当一个事务执行读操作时,它会使用快照读取。快照数据是基于事务开始时候数据库的数据状态创建的,因此事务不会读取其他事务尚未提交的修改。具体工作情况如下:

    • 对于读取操作,事务会查找符合条件的数据行,并选择符合其事务开始时间的数据版本进行读取
    • 如果某个数据行有多个版本,事务会选择不晚于其开始时间的最新版本,确保事务只读取在它开始之前已经存在的数据
    • 事务读取的是快照数据,因此其他并发事务对数据行的修改不会影响当前事务的读取操作。
  2. 写操作 INSERT UPDATE DELETE

    当一个事务执行写操作时,它会生成一个新的数据版本,并将修改后的数据写入数据库。具体工作情况如下:

    • 对于写操作,事务会为要修改的数据创建一个新的版本,数据的具体修改发生在这个新版本中
    • 新版本的数据会带有当前事务的版本号,以便其他事务能够正确读取和理解该版本
    • 原始版本的数据不受影响,供其他事务使用快照读取,实现了并发写不影响并发读
  3. 事务的提交和回滚

    • 当一个事务提交时,它所做的修改将成为数据库的最新版本,并且对其他事务可见。
    • 当一个事务回滚时,它的修改将会被撤销,对其他事务不可见(可以理解为这个版本被删了)
  4. 版本回收

    • 为了防止数据库中的版本无限增长,MVCC 会定期进行版本的回收。回收机制会删除已经不再需要的旧版本数据,从而释放空间。

总之,重点就是:读操作使用旧版本数据的快照,写操作创建新版本

这样就能保证原始版本的一定可用性,部分事务实现了并发执行,避免加锁,降低开销,提高了数据库的并发性能和数据一致性

一致性非锁定读和锁定读

当前读和快照读

在进一步了解MySQL中实现MVCC的细节之前,还需要了解两个定义:

当前读

读取的数据是最新版本,读取数据时还要保证其他并发事务不会修改当前的数据,所以当前读会对读取的记录加锁。

快照读

每一次修改数据,都会在 undo log 中存有快照记录,这里的快照就是读取 undo log 中的某一版本数据的快照。这种方式的优点是可以不用加锁就可以读取到数据,缺点是可能不是最新。

一般的查询都是快照读

一致性非锁定读

这是 InnoDB 中默认的读操作模式,比如普通的 SELECT,也是 MVCC 发挥作用的核心场景。

  • 一致性:读出来的数据是 符合事务隔离级别 的一致性快照,不会读到未提交的修改
  • 非锁定:读取时完全不加任何锁,也不会被写操作的 X 锁阻塞;

对于 一致性非锁定读(Consistent Nonlocking Reads)的实现,通常情况下不读最新数据,通常做法是加一个版本号或者时间戳字段,在更新数据的时候版本+1或者时间戳更新。

查询时,将当前可见的版本号与对应记录的版本号进行比对,如果记录的版本小于可见版本,则表示该记录可见。(也就是不能预知未来)

InnoDB 存储引擎中,多版本控制 (multi versioning) MVCC 就是对非锁定读的实现。读不阻塞写,写不阻塞读。

如果读取的行正在执行 DELETEUPDATE 操作,这时读取操作不会去等待行上锁的释放。反而 InnoDB 会读取该行的一个快照数据,对于这种读取历史数据的方式,就是上面的快照读

在可重复读和读已提交的隔离级别下,如果是执行普通的 SELECT 语句(不包括加锁的SELECT和原子更新查询 select ... lock in share mode ,select ... for update)则会使用 一致性非锁定读(MVCC)。并且在 可重复读 下 MVCC 实现了可重复读和防止部分幻读

锁定读

当业务需要 强一致性,必须读到最新的、未被修改的真实数据时,普通的非锁定读就不满足需求了,因为它读的是历史版本,可能不是最新数据。这时需要用「锁定读」,主动给读取的行加锁,确保后续操作的原子性。

虽然它回到了传统锁机制的逻辑,但只锁行,不是表,性能比表锁好很多

如果执行的是下列语句,就是 锁定读(Locking Reads)

  • select ... lock in share mode
  • select ... for update
  • insertupdatedelete 操作

在锁定读下,读取的是数据的最新版本,这种读也被称为 当前读(current read)。锁定读会对读取到的记录加锁

  • select ... lock in share mode
    • 给读取的行加共享锁(S 锁),允许其他事务加 S 锁(一起读),但阻止其他事务加 X 锁(修改);
    • 一般是需要确认数据状态,且不允许别人修改
  • select ... for updateinsertupdatedelete
    • 给读取的行加排他锁(X 锁),阻止其他事务加任何锁(既不能读也不能改)
    • 一般是读取后要立即修改,确保数据不会被其他事务篡改

在一致性非锁定读下,即使读取的记录已被其它事务加上 X 锁,这时记录也是可以被读取的,但是读取的是快照数据。

上面说了,在可重复读的隔离情况下,MVCC 防止了部分幻读,因为只能读取到第一次查询之前所插入的数据

但是是,如果是 当前读 ,每次读取的都是最新数据,这时如果两次查询中间有其它事务插入数据,就会产生幻读。

所以, InnoDB 在实现 可重复读 时,如果执行的是当前读,每次都尝试读取的是最新的数据,则会对读取的记录使用 Next-key Lock ,来防止其它事务在间隙间插入数据

MVCC➕Next-key-Lock 在 RR 下防止幻读

InnoDB存储引擎在 RR 级别下通过 MVCCNext-key Lock 来解决幻读问题:

  1. 执行普通 select,此时会以 MVCC 快照读的方式读取数据

    在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 ReadView,并使用此事务提交。

    所以在生成 ReadView 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的幻读

  2. 执行 select…for update/lock in share mode、insert、update、delete 等当前读

    在当前读下,读取的都是最新的数据,如果其他事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读

    InnoDB 使用 Next-key Lock 来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。只要我不让你插入,就不会发生幻读

标准的 SQL 隔离级别定义里,repeatable-read 是无法防止幻读的,但 InnoDB 的实现通过以下机制很大程度上避免了幻读,但是也没办法完全避免

  • 快照读:普通的 SELECT 语句,通过 MVCC 机制,事务启动时创建一个数据快照,后续的快照读都读取这个版本的数据,从而避免了看到其他事务新插入的行(幻读)或修改的行(不可重复读)
  • 当前读:像 SELECT ... FOR UPDATEX锁,SELECT ... LOCK IN SHARE MODES锁,INSERTUPDATEDELETE 这些操作。InnoDB 使用 Next-Key Lock 来锁定扫描到的索引记录及其间的间隙,防止其他事务在这个间隙内插入新的记录,造成幻读。
    • Next-Key Lock 是行锁(Record Lock)和间隙锁(Gap Lock)的组合。

MVCC的核心原理

MySQL 中 MVCC 主要是通过行记录中的隐藏字段,包括 隐藏主键 row_id、事务ID trx_id、回滚指针 roll_pointer,和undo logReadView来实现的。

行记录中的隐藏字段

InnoDB会为每一条数据记录自动添加3个隐藏列,用于记录版本信息

  • row_id:当数据库表没定义主键时,InnoDB 会以 row_id 为主键生成一个聚集索引。
  • trx_id:事务ID记录了新增或者最近修改这条记录的事务的 id,事务 id 也是自增的
  • roll_pointer:在 undo log 中,回滚指针指向当前记录的上一个版本

Undo Log

而在修改数据的时候,存储引擎会向 redo log 中记录修改的页内容,为了在数据库宕机重启后恢复对数据库的操作,也会向 undo log 记录数据原来的快照,用于回滚事务和实现 MVCC

Undo Log 不仅是事务回滚的依据,也是 MVCC 存储 历史版本数据 的载体。

当事务修改数据时候,InnoDB会先将数据的 修改前的版本 写入Undo Log,再修改当前数据并更新三个隐藏列:

  • 若事务执行ROLLBACK,可通过 Undo Log 恢复旧版本;
  • 若其他事务需要读取历史版本,可通过roll_pointer从Undo Log中获取对应版本数据。

例如:事务1(TRX_ID=100)执行UPDATE user SET age=21 WHERE id=1,InnoDB会:

  1. 将数据的旧版本(id=1, name="张三", age=20, DB_TRX_ID=0)写入Undo Log;

    img
  2. 事务1 执行了 UPDATE 修改当前数据的age为21,更新DB_TRX_ID=100DB_ROLL_PTR指向Undo Log中旧版本的地址;此时数据的版本链如下:

    • 当前版本:(age=21, DB_TRX_ID=100, DB_ROLL_PTR→Undo Log旧版本)
    • Undo Log中的历史版本:(age=20, DB_TRX_ID=0, DB_ROLL_PTR=NULL)

别忘了InnoDB 存储引擎中 undo log 分为两种:insert undo logupdate undo log

  • insert undo log:指在 insert 操作中产生的 undo log。因为 insert 操作的记录只对事务本身可见,对其他事务不可见,故该 undo log 可以在事务提交后直接删除。不需要进行 purge 操作

    img
  • update undo logupdatedelete 操作中产生的 undo log。该 undo log可能需要提供 MVCC 机制,因此不能在事务提交时就进行删除。提交时放入 undo log 链表,等待 purge线程 进行最后的删除

    img

ReadView 读视图

多个事务对 id=1 的数据修改后,这行记录除了最新的数据,在 undo log 中还有多个版本的快照。那其他事务查询时能查到最新版本的数据吗?如果不能,能读到哪个版本的快照呢?这就要由 ReadView 来决定了。

Read View是事务读取数据时的 可见性判断依据,它本质是一个“事务ID集合”,包含以下4个核心参数:

  • m_low_limit_id:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见,不能预知未来
  • m_up_limit_id:活跃事务列表 m_ids 中最小的事务 ID,如果 m_ids 为空,则 m_up_limit_idm_low_limit_id。小于这个 ID 的数据版本均可见
  • m_ids:活跃事务id列表,当前系统中所有活跃的(也就是没提交的)事务的事务id列表。
  • m_creator_trx_id:创建该 Read View 的事务 ID

数据可见性算法

InnoDB 存储引擎中,创建一个新事务后,执行每个 select 语句前,都会创建一个快照(Read View),快照中保存了当前数据库系统中正处于活跃(没有 commit)的事务的 ID 号,当用户在这个事务中要读取某个记录行的时候,InnoDB 会将该记录行的 DB_TRX_IDRead View 中的一些变量及当前事务 ID 进行比较,判断是否满足可见性条件

其实也就是保存了当前不应该被本事务看到的其他事务 ID 列表 m_ids,因为他们没有提交

具体算法如下

当版本链中记录的 trx_id 等于当前事务 id,也就是 trx_id = m_creator_trx_id,说明版本链中的这个版本是当前事务修改的,所以该快照记录对当前事务可见。

当版本链中记录的 trx_id 小于活跃事务的最小 id,trx_id < m_up_limit_id,版本链中这个事务已经被提交了,所以该快照记录对当前事务可见。

当版本链中记录的 trx_id 大于下一个将被分配的事务 id,trx_id > m_low_limit_id,这个事务还没有被提交,防止预知未来,不可见

当版本链中记录的 trx_id 在 m_up_limit_id 和 m_low_limit_id 之间时,m_low_limit_id >= trx_id >= m_up_limit_id,,如果版本链中记录的 trx_id 在活跃事务 id 列表m_ids中,说明生成 ReadView 时,修改记录的事务还没提交,不可见,如果不再就可见

一图总结如下

trans_visible

RC 和 RR 隔离级别下 MVCC 的差异

在事务隔离级别 RCRRInnoDB 存储引擎使用 MVCC(非锁定一致性读),但它们生成 Read View 的时机却不同

  • 在 RC 读已提交 隔离级别下的 每次SELECT, 查询前都生成一个Read View (m_ids 列表)
  • 在 RR 可重复读 隔离级别下只在事务开始后的第一次SELECT,数据前生成一个Read View(m_ids 列表)