数据库中的多版本并发控制(MVCC)

马谦马谦马谦 2020年1月25日18:11:47 发表评论
文章最后编辑于:2020-1-25 18:12:53

一、概述

事务的出现给并发带来了巨大的便利性,它的ACID特性使得数据在并发时更加可靠。但是对于事务而言,它也会导致出现第一类丢失更新第二类丢失更新脏读不可重复读以及幻读的问题,当然又出现了多种事务隔离级别来避免在产生这几类问题。那么隔离级别是如何实现的呢?

这就是多版本并发控制(MVCC)要做的事情了。《高性能MySQL》中对MVCC的描述为:

  • MySQL的大多数事务性存储引擎实现的都不是简单的行级锁。基于提升并发性能的考虑,它们一般都同时实现了多版本并发控制。不仅是MySQL,包括Oracle、PostgreSQL等其他数据库系统也都实现了MVCC,但各自的实现机制不尽相同,因为MVCC没有一个统一的实现标准。
  • 可以认为MVCC是行级锁的一个变种,但是它在很多情况下避免了加锁操作,因此开销更低。虽然实现机制有所不同,但大都实现了非阻塞的读操作,写操作也只锁定必要的行。
  • MVCC的实现,是通过保存数据在讴歌时间点的快照来实现的。也就是说,不管需要执行多长时间,每个事务看到的数据都是一致的。根据事务开始时间的不同,每个事务同一张表、同一时刻看到的数据可能是不一样的。

MVCC的核心功能点是快照,多个事务更新相同数据时,各自都会生成一份对应数据的快照,这个快照被称为一致性读视图(consistent read view)。有了这个视图之后,每个事务都只对自己内部的视图进行更改,这样就不会影响其他事务了,数据并发就不会受到影响。

问题的关键点就在于快照如何创建以及如何把多个事务的更改统一起来。

因为MyISAM不支持事务,因此本篇文章主要讨论的是InnoDB。

二、MVCC基本原理

首先第一个问题:快照是如何创建的?是不是就是给每个事务都拷贝一份数据呢,是不是有100G数据,那每个事务也要拷贝100G数据呢?当然不是。

每个事务都有一个事务ID叫做transaction id,这个id在事务刚启动的时候向InnoDB申请,它不重复并且严格递增。InnoDB隐藏了一个包含最新改动的事务id,每个事务修改后都会把这个字段设置为自己的事务ID。其他事务启动的时候记录下这个最新ID,然后修改的时候比对ID是否有修改。如果没有修改,说明这一行没有改动过,当前事务也能直接修改。如果ID变化了,则就要查找undolog,找到可用的合适的记录。

因此,创建快照就只要记录下这个事务ID就可以了,无需复制所有的数据。

在实现上,InnoDB给每个数据表都添加了隐藏的三列数据DB_TRX_ID/DB_ROLL_PTR/DB_ROW_ID,三者的含义:

  1. DB_TRX_ID: 标记了最新更新这条行记录的transaction id,每处理一个事务,其值自动+1。
  2. DB_ROLL_PTR: 回滚指针,记录了最新一次修改该条记录的undo log,回滚的时候就通过这个指针找到undo log回滚。
  3. DB_ROW_ID: 当数据表没有指定主键时,数据库会自动以这个列来作为主键,生成聚集索引。

每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务ID(即DB_TRX_ID列)。同时,旧的数据版本要保留(通过undo log保留),并且在新的数据版本中,能够有信息可以直接拿到它。也就是说,数据表中的一行数据,其实可能有多个版本。

例如存在以下数据表:

数据库中的多版本并发控制(MVCC)

它实际上的表现形式为:

数据库中的多版本并发控制(MVCC)

假设此时修改年龄为25,此时数据列和undo log的状态是:

数据库中的多版本并发控制(MVCC)

undolog新生成了一个记录,保存了改动之前的数据。新记录中,通过设置DB_ROLL_PRT指向备份的undo log记录,方便回滚。如若再次修改年龄为18,那么两者的状态为:

数据库中的多版本并发控制(MVCC)

三、MVCC具体过程

以下通过一个示例来描述MVCC具体的执行过程:

数据库中的多版本并发控制(MVCC)

关于start transaction with consistent snapshot:

前面我们说过,行锁的加锁实际并不是事务启动的时候就创建的,而是在修改对应行的时候才创建的。这里的一致性视图默认情况下和视图也是一样,用到的时候才创建,并非事务已启动就创建。

start transaction with consistent snapshot语句的作用就是在启动事务的时候就创建一致性视图。

以上面的学生表为例,同时存在三个事务修改同一行数据中的值,其中事务A和事务B先执行,然后事务C更新数据并提交。此刻事务A和事务B的更新和查询记录的结果会是怎样?

初始时的行数据为:

id name age
1 maqian 24

测试

首先启动启动两个终端模拟事务A和事务B,执行start transaction with consistent snapshot

然后开启第三个终端模拟事务C,执行:

然后在事务B执行以下操作:先查询age的值,然后也把age加一,然后再查询age的值。

数据库中的多版本并发控制(MVCC)

事务B在更新操作执行之前,查询age的值是24,执行更新操作之后,再查询age值是26。

此时事务A查询age的值是24:

数据库中的多版本并发控制(MVCC)

因此能得到的结论是:

  1. 事务B在更新数据前查到的age = 24,更新后查到的age = 25。
  2. 事务A查询到的数据是24。

看起来不符合逻辑,为什么会这样呢?

3.1 查询逻辑

我们以trx_id_first/trx_id_last/trx_id_currennt分别表示事务的低水位、高水位和当前事务。

如何理解高低水位:

  • 低水位:已经提交事务的最大值,即启动当前事务时候已经提交了的事务。
  • 高水位:未开始事务的最小值,即当前事务启动时还未启动的事务。
  • 当前事务:高低水位之间的事务,即当前事务启动时候已经存在的未提交事务。

以图形表示为:

数据库中的多版本并发控制(MVCC)

图片来源:极客时间

在事务开始的时候,除了生成一致性视图,还要生成一个对应的视图数组,这个数组里面表示的就是所有未提交事务的集合(黄色区域)。查询数据的时候有三种情况:

  1. 数据未提交,数据不可见。
  2. 数据已提交,但是事务ID处于当前事务的高水位段,不可见。
  3. 数据已提交,并且事务ID实在当前事务之前创建,可见。

以上面的测试过程为例,事务A的id等于1,事务B的id等于2,事务C的id等于3。那么事务启动时,事务A的视图数组为[0, 1],事务B的视图数组为[0, 1, 2],事务C的视图数组为[0, 1, 2, 3]

数据库中的多版本并发控制(MVCC)

事务C最后启动,因为是自动提交,因此执行完update之后就已经commit了,此时记录的DB_TRX_ID = 3。它处于事务B的高水位区,虽然已经提交但是也不可见,命中第二条规则。因此它要先通过undo log找到一个可见的版本,找到上一个版本trx_id = 0位于它的可见区,然后读取这条记录的age值为24.

而事务B对于事务A而言,也是处于高水位区,并且事务B修改age之后没有提交,所以rtx_id = 2的事务对事务A是不可见的,命中了规则一,要往前找其他版本。先找到上一个版本trx_id = 3后发现还不是可见的,需要继续往前找,找到rtx_id = 0的记录,它对事务A可见,再读取age值为24。

3.2 更新逻辑

上面有一个不合逻辑的点在于事务B,事务B它在更新age的前后分别查询age的值是对不上的:加一之前是24,加一之后是26。这是个什么逻辑呢?

对于更新而言,它有一个很重要的概念是当前读,当前读的意思是:更新的时候,要使用当前版本的记录来读。所谓当前版本指的就是最新更新后的记录,在上面的例子中也就是trx_id = 3的记录。

数据库中的多版本并发控制(MVCC)

在事务B修改age的值之前,此时读取age值就像上面所说:先找到trx_id = 3的记录,发现不可见,然后再读取trx_id = 0的记录。

但是事务B在修改age的时候,先读取当前是最新的改动trx_id = 3这条记录,此时age的值为25。然后事务C把age值加一,并设置trx_id = 2。事务C再读取数据,发现最新的改动事务id是2,也就是自己。处于未提交的当前事务区,就能读到age是26了。

四、参考

MySQL-InnoDB-MVCC多版本并发控制

本文共执行64次查询,耗时0.667秒!
马谦马谦马谦

发表评论

:?: :razz: :sad: :evil: :!: :smile: :oops: :grin: :eek: :shock: :???: :cool: :lol: :mad: :twisted: :roll: :wink: :idea: :arrow: :neutral: :cry: :mrgreen: