数据库事务之MVCC机制

数据库事务之MVCC机制

Posted by Ted on November 10, 2022

1、并发事务的四种场景

并发事务中又会分为四种情况,分别是读-读、写-写、读-写、写-读,这四种情况分别对应并发事务执行时的四种场景。

1.1、读-读场景

读-读场景即是指多个事务/线程在一起读取一个相同的数据,比如事务T1正在读取ID=88的行记录,事务T2也在读取这条记录,两个事务之间是并发执行的。读-读场景不存在任何数据竞争问题,不需要并发控制。

1.2、写-写场景

写-写场景也比较简单,也就是指多个事务之间一起对同一数据进行写操作,比如事务T1对ID=88的行记录做修改操作,事务T2则对这条数据做删除操作,事务T1提交事务后想查询看一下,结果连这条数据都不见了,这也是所谓的脏写问题,也被称为更新覆盖问题,对于这个问题在所有数据库、所有隔离级别中都是零容忍的存在,最低的隔离级别也要解决这个问题。

1.3、读-写、写-读场景

读-写、写-读实际上从宏观角度来看,可以理解成同一种类型的操作,但从微观角度而言则是两种不同的情况,

  • 读-写是指一个事务先开始读,然后另一个事务则过来执行写操作,
  • 写-读则相反,主要是读、写发生的前后顺序的区别。

并发事务中同时存在读、写两类操作时,这是最容易出问题的场景,脏读、不可重复读、幻读都出自于这种场景中,当有一个事务在做写操作时,读的事务中就有可能出现这一系列问题,因此数据库才会引入各种机制解决。

1.4、各场景下解决问题的方案

在《锁机制》中,对于写-写、读-写、写-读这三类场景,都是利用加锁的方案确保线程安全,但是,加锁会导致部分事务串行化,因此效率会下降,而MVCC机制的诞生则解决了这个问题。写-写场景必须要加锁才能保障安全,因此先将该场景排除在外。

基于读-写并存的场景,推出了MVCC机制,在线程安全问题和加锁串行化之间做了一定取舍,让两者之间达到了很好的平衡,即防止了脏读、不可重复读及幻读问题的出现,又无需对并发读-写事务加锁处理。

总结
  • 读-读:无需并发控制
  • 写-写:必须加锁执行串行
  • 读-写、写-读:可以引入MVCC机制,在不加锁的情况下,保证并发安全,提升效率

2、MVCC是什么?

MVCC,全称是多版本并发控制(Multi-Version Concurrency Control),是一种常用的并发控制方法。它通过在每个读取的数据行中创建数据行的“快照” —— 即该行在事务处理开始时的精确副本,来解决读-写冲突的问题。这样,每个事务读取的都是一致的行数据快照,而不是最新的行版本。

MVCC的目的:用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

MVCC如何保证并发安全:MVCC通过对数据进行多版本保存,根据比较版本号来控制数据是否展示,从而达到读取数据时无需加锁就可以实现事务的隔离性。

MySQL 是怎么解决幻读的?

MySQL InnoDB 引擎的默认隔离级别虽然是「可重复读」,但是它很大程度上避免幻读现象(并不是完全解决了,解决的方案有两种:

  • 针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读,因为可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,是查询不出来这条数据的,所以就很好了避免幻读问题。
  • 针对当前读(select … for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读,因为当执行 select … for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入,所以就很好了避免幻读问题。

3、MVCC的实现原理

MVCC的实现原理主要包括以下几个步骤:

  • 版本号:每个事务开始时,都会被分配一个唯一的事务ID,这个ID同时也是该事务的版本号。对于每一行数据,都会有两个版本号,
    • 一个是创建版本号,表示创建这行数据的事务的版本号;
    • 另一个是删除版本号,表示删除这行数据的事务的版本号。初始时,删除版本号为空。
  • 读操作:当一个事务要读取一行数据时,会检查这行数据的创建版本号和删除版本号。只有当创建版本号小于等于当前事务的版本号,并且删除版本号要么为空,要么大于当前事务的版本号,这行数据才会被当前事务读取。这样可以确保每个事务都是在一致的快照上进行操作。
  • 写操作:当一个事务要修改一行数据时,不会直接覆盖原数据,而是会复制一份新的数据行,然后修改这份新的数据行。新的数据行的创建版本号为当前事务的版本号,删除版本号为空。同时,原数据行的删除版本号会被设置为当前事务的版本号。
  • 提交事务:当一个事务提交时,所有由该事务创建的新数据行的删除版本号都会被设置为无穷大,表示这些数据行现在是可见的。同时,所有被该事务删除的原数据行的删除版本号会被设置为当前事务的版本号,表示这些数据行现在是不可见的。
  • 垃圾回收:为了防止数据版本过多导致的性能问题,需要定期进行垃圾回收。垃圾回收的原则是,只有当没有任何事务会访问到某个数据版本时,这个数据版本才会被回收。

MVCC的优点是读操作不会被写操作阻塞,写操作也不会被读操作阻塞,大大提高了数据库的并发性能。但是,MVCC也有一些缺点,比如版本链过长会影响读性能,以及需要定期进行垃圾回收等。

如何实现?

MVCC机制主要通过隐藏字段、Undo-log日志、ReadView这三个东西实现的,其中的多版本主要依赖Undo-log日志来实现,而并发控制则通过表的隐藏字段+ReadView快照来实现。

3.1 undo log

Undo log主要用于事务回滚时恢复原来的数据。mysql在执行sql时,会将一天逻辑相反的日志保存到undo log中。因此,undo log中记录的也是逻辑日志。

  • 执行Insert语句时,会在undo log日志中记录本次插入的主键id。等事务回滚时,delete删除此id。
  • 执行update语句时,MySQL会将修改前的数据保存在undo log中。等事务回滚时,再执行一次update,得到原来的数据。
  • 执行delete语句时,会在undo log中保存删除前的数据。等事务回滚时,再执行insert,插入原来的数据。

ReadView要通过undo log链条找到合适自己读取的记录。

3.2 隐藏字段

在数据库的每行上,除了存放真实的数据以外,还存在3个隐藏的列:row_id、trx_id和roll_pointerrow_id,

row_id,行号:

  • 如果当前表有整数类型的主键,那么row_id的值就是主键的值
  • 如果没有整数类型的主键,则MySQL会按照字段的顺序选择一个非空的整数类型的唯一索引为row_id
  • 如果都没有找到,则会创建一个自动增长的整数作为row_id

trx_id,是哪个事务记录的这条log:

  • 当一个事务开始执行前,MySQL就会为这个事务分配一个全局自增的事务id。
  • 之后该事务对当前进行的增、改、删除等操作时,都会将自己的事务ID记录到trx_id中,表明是哪个事务修改的。

roll_pointer,回滚指针:undo日志中指向修改之前的的一行记录。当一直有事务对该行改动时,就会一直生成undo log,最终将会形成undo log版本链。

3.3 ReadView

ReadView可以被理解为一个过滤器或者说是一个视图,它定义了在当前事务中,哪些数据版本是可见的,哪些数据版本是不可见的。

具体来说,ReadView包含以下几个重要的信息:

  • creator_trx_id:当前事务id
  • m_ids:这是一个列表,包含了在当前事务开始时,所有活跃(即还未提交)的事务的ID。
  • min_trx_id:这是m_ids列表中的最小事务ID。这个ID表示了在当前事务开始时,系统中最早开始的尚未提交的事务ID。
  • max_trx_id:表示生成readview时,系统中应该分配给下一个事务的id值

4、MVCC如何是运作的

根据undo log的链中的每一条trx_id与min_trx_id和max_trx_id进行对比:

  1. 如果小于min_trx_id,说明这个trx_id的log记录就已经事务已经提交过了,完成的事务,创建本事务之前就已经存在的日志,当然可以读。
  2. 如果大于max_trx_id说明这个版本的数据log是在创建RV之后生成的,不可读。
  3. 如果是在这之间,查看trx_id是否包含m_ids列表之中:

    • 不包含说明创建RV之前这个事务已经被提交了,那么是可读的。

    • 包含说明创建RV的时候,还是活跃(没提交)事务。那么是不可读的,有可能脏读;到了这里说明这条数据的变更版本在RV之内,则要查看creator_trx_id与trx_id是否一致:

      • 一致说明就是当前事务创建的;允许使用;
      • 否则说明是当前RV的其他事务操作的,不能使用;
4.1 可重复读是如何工作的?

一旦创建是不可变的,即便其他事务提交了,也不会影响当前事务创建的ReadView,你可以理解为一个副本快照。

4.2 读已提交是如何工作的?

读已提交隔离级别是在每次读取数据时,都会生成一个新的Read View。