Skip to content

MVCC

MVCC(Multiversion Concurrency Control 多版本并发控制)是一种思想,为了解决读写的并发控制,做到即使有读写冲突时,也能通过不加锁,实现非阻塞并发读。

在 MySQL 通过 Read View 隐藏字段和 undolog 实现了 MVCC。大致思路就是通过生成一个 Read View,并判断当时哪些记录是可见的。

下面详细讲一下。

什么是当前读和快照读

  • 当前读

    select lock in share mode(共享锁),select for updateupdateinsertdelete(排他锁),这些操作都是一种当前读,为啥叫当前读?因为他要读取的是当前记录的最新版本,读取的时候还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

  • 快照读

    不加锁的 select 就是快照读。快照读的前提是隔离级别不是串行级别,串行级别下会退化成当前读。(都已经这么严格的串行级别了嘛)

    快照读是为了提高并发性能,是基于 MVCC 实现的,可以认为 MVCC 是行锁的一个变种,在很多情况下,避免了加锁操作,降低了开销。既然是基于多版本,所以快照读可能读到的不一定是最新的数据,可能是之前的历史版本。

MVCC 能在大部分场景下解决幻读的问题,其主要方式就是通过 ReadView 来判断当前能看到哪些记录:

- 快照读:直接读 ReadView 内记录即可,也不会有不一致问题。

- 当前读:为了防止其他事务同时修改相同数据,此时会通过加上临键锁(Next-Key Lock)来锁住当前记录及其周边范围的记录(Gap Lock + Record Lock)。

MVCC 的实现原理

主要依赖:

  • 3个隐式字段
  • undo日志
  • ReadView

隐式字段

  • db_trx_id:最近已提交的事务ID,记录最后一次创建/修改这条记录的事务ID
  • db_roll_ptr:回滚指针,指向这条记录的上一个版本(存储在 rollback segment里)
  • db_row_id:隐含的自增主键

undo log

主要分两种:

  • insert undo log

    事务在 insert 新记录时产生的 undo log,只在事务回滚时需要,事务提交后可以被立即丢弃。

  • update undo log

    事务在 updatedelete 时产生的 undo log,不仅在事务回滚时需要,在快照读时也需要;不能随便删,只有在快速读或事务回滚不涉及该日志时,对应的日志才会被 purge 线程统一清除。

Read View

主要理解三个属性(名字瞎给的,不一定对):

  • active_trx_ids

    在创建 Read View 时,当前数据库中「活跃事务」的 事务 id 列表。「活跃事务」指的就是,启动了但还没提交的事务。

  • min_active_trx_id

    在创建 Read View 时,当前数据库中「活跃事务」中事务 id 最小的事务,也就是 active_trx_ids 的最小值。

  • max_trx_id

    在创建 Read View 时,当前数据库中应该给下一个事务的 id 值,也就是全局事务中最大的事务 id 值 +1。

    不是 active_trx_ids 中的最大值,active_trx_ids 只是「活跃事务」的事务 id 列表,当前数据库可能还有其他已提交的事务,所以不能认为 max_trx_idtrx_ids 中的最大值 +1。

  • createtor_trx_id

    创建该 Read View 事务的事务 id。

所有的事务 id 分布示意如下图所示: 根据上图,当一个事务创建 Read View 去访问数据库记录时,除了自己的更新记录总是可见外,还会有以下几种情况:

  • 访问的记录的 db_trx_id 小于 Read View 中 min_active_trx_id 值,表示这个版本的记录是在创建 Read View 就已经提交事务了,所以该版本的记录对当前事务是可见的。

  • 访问的记录的 db_trx_id 大于等于 Read View 中 max_trx_id 值,表示这个版本的记录是在创建 Read View 的事务中生成的,所以该版本的记录对当前事务是不可见的。

  • 访问的记录的 db_trx_idmin_active_trx_idmax_trx_id 之间,需要进一步判断:

    • 如果 db_trx_id active_trx_ids 中,表示该版本的记录的事务还活跃着,所以该版本的记录对当前事务不可见
    • 如果 db_trx_id 不在 active_trx_ids 中,表示该版本的记录的事务已提交,所以该版本的记录对当前事务是可见的。

上述讲的只是 ReadView 中的记录可见范围哦,和 RC、RR 这种都没关系。

  • RC 能读每次提交后的数据,是因为 RC 会在每次 select 前去创建 ReadView。

  • RR 能保证在同一个事务中重复读都一样,是因为 RR 在创建事务的时候就去创建了 ReadView。

回顾下上面说 MVCC 也可能会出现幻读问题,什么样的场景呢?

-> 当前读和快照读交替场景下。

  • 假设数据库没有 id = 2 的记录,现在事务 A 先快照读:select empty where id = 2;,很明显返回空(但事务还没关闭哦)。

  • 然后事务 B 插入了一条 id = 2 的记录。

  • 接着事务 A 这么去更新了 id = 2 的记录:update T set name = 'Jack' where id = 2;,然后再 select id = 2 的记录,此时事务 A 内就能看到这条记录了。

因为事务 A 更新了 id = 2 的记录后,那条记录上的 db_trx_id 就是事务 A 的了,当后面再去快照读的时候,自然而然能看到这条数据了。

举例说明

示例一

person 表中初始有条记录:{"name": "Jerry", "age": 17},经历了以下几个操作:

  1. 事务 1 修改 name 为 Tom
  2. 事务 2 接着修改 age 为 18

数据库的操作流程如下:

可以看到,对同一记录的不断修改,会在 undo log 不断增长这条记录的版本链表,链首就是最新的上一条记录,链尾就是最早的记录(最早的这条记录不一定是数据最开始的记录,事务已结束的 undo log 可能会被 purge 线程清理掉)。

示例二

事务一 事务二 事务三 事务四
事务开始 事务开始 事务开始 事务开始
... ... ... 修改且已提交
进行中 快照读 进行中
... ... ...

当「事务二」开始「快照读」时,假设当前事务 id 为 2,此时 事务 1事务 3 还在活跃中,事务 4事务 2 快照读前提交了,所以当前的事务 id 分布如下:

  1. 事务 2 查看到此时这条记录的 1 < db_trx_id = 4 < 5,所以继续看当前事务下这条记录的 db_trx_id 是否在 active_trx_ids 里面;
  2. db_trx_id = 4 不在 active_trx_ids 里面,该记录版本对当前事务可见。

所以可得出当前事务 2 创建的 Read View 读取到的这条记录是 db_trx_id = 4 时记录的值。