MySQL 中的 MVCC 是什么?

Sherwin.Wei Lv7

MySQL 中的 MVCC 是什么?

回答重点

MVCC(Multi-Version Concurrency Control,多版本并发控制)是一种并发控制机制,允许多个事务同时读取和写入数据库,而无需互相等待,从而提高数据库的并发性能。

在 MVCC 中,数据库为每个事务创建一个数据快照。每当数据被修改时,MySQL 不会立即覆盖原有数据,而是生成新版本的记录。每个记录都保留了对应的版本号或时间戳。

多版本之间串联起来就形成了一条版本链,这样不同时刻启动的事务可以无锁地获得不同版本的数据(普通读)。此时读(普通读)写操作不会阻塞。

写操作可以继续写,无非就是会创建新的数据版本(但只有在事务提交后,新版本才会对其他事务可见。未提交的事务修改不会影响其他事务的读取),历史版本记录可供已经启动的事务读取。

image.png

(为保持简短,简化了SQL语句,下面的内容也同样简化)

扩展知识

Undo Log

Undo Log 是 MySQL InnoDB 中用于支持事务的回滚操作的一种日志机制。它记录了数据修改的历史信息,使得在事务失败或需要撤销某些操作时,可以将数据恢复到先前的状态。

实际上 MVCC 所谓的多版本不是真的存储了多个版本的数据,只是借助 undolog 记录每次写操作的反向操作,所以索引上对应的记录只会有一个版本,即最新版本。只不过可以根据 undolog 中的记录反向操作得到数据的历史版本,所以看起来是多个版本。

image.png

拿上面的 insert (1,XX)这条语句举例,成功插入之后数据页的记录上不仅存储 ID 1,name XX,还有 trx_id 和 roll_pointer 这两个隐藏字段:

  • trx_id:当前事务ID。
  • roll_pointer:指向 undo log 的指针。
image.png

从图中可以得知此时插入的事务 ID 是 1,此时插入会生成一条 undolog ,并且记录上的 roll_pointer 会指向这条 undolog ,而这条 undolog 是一个类型为TRX_UNDO_INSERT_REC的 log,代表是 insert 生成的。

里面存储了主键的长度和值(还有其他值,不提),所以 InnoDB 可以根据 undolog 里的主键的值,找到这条记录,然后把它删除来实现回滚(复原)的效果。因此可以简单地理解 undolog 里面存储的就是当前操作的反向操作,所以认为里面存了个 delete 1 就行。

此时事务1提交,然后另一个 ID 为 5 的事务再执行 update NO where id 1 这个语句,此时的记录和 undolog 就如下图所示:

image.png

没错,之前 insert 产生的 undolog 没了,insert 的事务提交了之后对应的 undolog 就回收了,因为不可能有别的事务会访问比这还要早的版本了,访问插入之前的版本?访问个寂寞吗?

而 update 产生的 undolog 不一样,它的类型为 TRX_UNDO_UPD_EXIST_REC

此时事务 5 提交,然后另一个 ID 为 11 的事务执行update Yes where id 1 这个语句,此时的记录和 undolog 就如下图所示:

image.png

没错,update 产生的 undolog 不会马上删除,因为可能有别的事务需要访问之前的版本,所以不能删。这样就串成了一个版本链,可以看到记录本身加上两条 undolog,这条 id 为 1 的记录共有三个版本。

readView

版本链搞清楚了,这时候还需要知道一个概念 readView,这个 readView 就是用来判断哪个版本对当前事务可见的,这里有四个概念:

  • creator_trx_id,当前事务ID。
  • m_ids,生成 readView 时还活跃的事务ID集合,也就是已经启动但是还未提交的事务ID列表。
  • min_trx_id,当前活跃ID之中的最小值。
  • max_trx_id,生成 readView 时 InnoDB 将分配给下一个事务的 ID 的值(事务 ID 是递增分配的,越后面申请的事务ID越大)

对于可见版本的判断是从最新版本开始沿着版本链逐渐寻找老的版本,如果遇到符合条件的版本就返回

判断条件如下:

  • 如果当前数据版本的 trx_id == creator_trx_id 说明修改这条数据的事务就是当前事务,所以可见。
  • 如果当前数据版本的 trx_id < min_trx_id,说明修改这条数据的事务在当前事务生成 readView 的时候已提交,所以可见。
  • 如果当前数据版本的 trx_id 大小在 min_trx_id 和 max_trx_id 之间,此时 trx_id 若在 m_ids 中,说明修改这条数据的事务此时还未提交,所以不可见,若不在 m_ids 中,表明事务已经提交,可见。
  • 如果当前数据版本的 trx_id >= max_trx_id,说明修改这条数据的事务在当前事务生成 readView 的时候还未启动,所以不可见(结合事务ID递增来看)。

来看一个简单的案例,练一练上面的规则。

读已提交隔离级别下的 MVCC

现在的隔离级别是读已提交

假设此时上文的事务1已经提交,事务 5 已经执行,但还未提交,此时有另一个事务在执行update YY where id 2,也未提交,它的事务 ID 为 6,且也是现在最大的事务 ID。

现在有一个查询开启了事务,语句为select name where id 1,那么这个查询语句:

  • 此时 creator_trx_id 为 0,因为一个事务只有当有修改操作的时候才会被分配事务 ID。
  • 此时 m_ids 为 [5,6],这两个事务都未提交,为活跃的。
  • 此时 min_trx_id,为 5。
  • 此时 max_trx_id,为 7,因为最新分配的事务 ID 为 6,那么下一个就是7,事务 ID 是递增分配的。

由于查询的是 ID 为 1 的记录,所以先找到 ID 为 1 的这条记录,此时的版本如下:

image.png

此时最新版本的记录上 trx_id 为 5,不比 min_trx_id 小,在 m_ids 之中,表明还是活跃的,未提交,所以不可访问,根据 roll_pointer 找到上一个版本。

于是找到了图上的那条 undolog,这条log上面记录的 trx_id 为 1,比 min_trx_id 还小,说明在生成 readView 的时候已经提交,所以可以访问,因此返回结果 name 为 XX。

然后事务 5 提交

此时再次查询 select name where id 1,这时候又会生成新的 readView

  • 此时 creator_trx_id 为 0,因为还是没有修改操作。
  • 此时 m_ids 为 [6],因为事务5提交了。
  • 此时 min_trx_id,为 6。
  • 此时 max_trx_id,为 7,此时没有新的事务申请。

同样还是查询的是 ID 为 1 的记录,所以还是先找到 ID 为 1 的这条记录,此时的版本如下(和上面一样,没变):

image.png

此时最新版本的记录上 trx_id 为 5,比 min_trx_id 小,说明事务已经提交了,是可以访问的,因此返回结果 name 为 NO。

这就是读已提交的 MVCC 操作,可以看到一个事务中的两次查询得到了不同的结果,所以也叫不可重复读。

可重复读隔离级别下的MVCC

现在的隔离级别是可重复读

可重复读和读已提交的 MVCC 判断版本的过程是一模一样的,唯一的差别在生成 readView 上

上面的读已提交每次查询都会重新生成一个新的 readView ,而可重复读在第一次生成 readView 之后的所有查询都共用同一个 readView 。

也就是说可重复读只会在第一次 select 时候生成一个 readView ,所以一个事务里面不论有几次 select ,其实看到的都是同一个 readView 。

套用上面的情况,差别就在第二次执行select name where id 1,不会生成新的 readView,而是用之前的 readView,所以第二次查询时:

  • m_ids 还是为 [5,6],虽说事务 5 此时已经提交了,但是这个readView是在事务5提交之前生成的,所以当前还是认为这两个事务都未提交,为活跃的。
  • 此时 min_trx_id,为 5。

(对于判断过程有点卡顿的同学可以再拉上去看看,判断版本的过程和读已提交一致)。

所以在可重复级别下,两次查询得到的 name 都为 XX,所以叫可重复读

可重复读能完全避免幻读的发生吗?

不能。

首先了解下快照读当前读这两个概念。

快照读(Snapshot Read)

快照读是指事务在执行查询时,不直接读取当前最新的数据,而是读取数据的历史版本(快照)。MySQL InnoDB 通过多版本并发控制(MVCC)来实现快照读。快照读只会返回在事务开始时可见的数据,即使其他事务在之后修改了这些数据,快照读也不会受影响。

当前读(Current Read)

当前读是指读取数据的最新版本,并且会加锁以确保数据的一致性。即使其他事务在当前读之后修改了数据,也会立即反映在当前读的结果中。

当前读出现在带有锁的查询中,如 SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODE以及 UPDATE、DELETE等 DML 语句。

当前读需要获取最新版本应该不难理解,例如别的事务已经 delete 一条语句了,当前事务要执行 update 不得先去看看当前最新的数据情况,如果都删除了还 update 成功那不就乱了吗?

当前读会通过加锁(如 Next-Key Locking)锁定读范围内的所有记录和间隙,防止其他事务在该范围内插入新记录。

再回到可重复读和幻读的问题上来,我们来看下这个场景:

image.png

由于当前读的特性,即使在可重复读隔离级别下,也产生了幻读的场景。

如果要避免这个幻读的产生,那么在事务开始的时候,直接就使用 SELECT ... FOR UPDATE,加锁了后其他事务就新增不了数据了,也就避免了幻读的发生。

Comments