关键字
快照、版本回滚、事务隔离、锁
0.引子
在之前的课程中,我们已经知道,在“可重复读”的事务隔离级别下,MySQL会在事务启动的时候创建一个视图,在之后的执行中,事务看到的数据始终保持这样的状态。也就是说,在可重复读的隔离级别下执行的事务,好像不受外界影响。
你可能会纳闷,如果我数据库中有 100G 的数据,启动一个可重复读的事务时,难道要主动备份全部内容吗?而在使用事务时,它似乎并没有进行如此大规模的数据复制啊?如果不进行复制,那 MySQL 是如何实现这个功能的呢?
举个例子,我们使用下面的语句创建一个表:
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`k` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, k) values(1,1),(2,2);
现在,有三个事务,它们的执行过程如下:

注:begin/start transaction并不会立即开始一个事务,在执行到第一个操作 InnoDB表 的语句时才启动。如果使用 start transaction with consistent snapshot 会立即启动事务,并创建一个一致性视图。
在上面的例子中,事务B 的查询结果为 3,事务A 的查询结果为 1,你不是有点晕呢?
今天,你就会明白为什么会这样。
注:在MySQL中,有两个关于“视图”的概念:
- 第一个是 view,它是一个用查询语句定义的虚拟表,调用方式和查询表的方式一样。
- 第二个是 InnoDB 在实现 MVCC 中用到的一致性视图(consistent read view),用于实现不同隔离级别下的数据读取。本节所说的视图就是这种视图,为了避免混淆,我使用“快照”代替这个说法。
1.“快照”是如何实现的
在之前我们已经介绍过,在可重复读的隔离级别下,事务在启动的时候就创建了一个整个数据库的快照,并且简单地说明了 MySQL 是如何利用回滚还原数据的。
在这里,让我们看看这个快照是如何实现的:
1.1.1行数据中的事务ID
在一个事务被创建的同时,InnoDB 会为它分配事务 ID,叫做 transaction id。它是 严格递增 且 不重复的。
每行数据起始会有多个版本。每次事务更新数据的时候,都会生成一个新的版本,并将这个事务的 ID 记在其中,记作row trx_id。如下图,就是某个数据行在多次更新后的状态:
在图中,一行数据有四个版本,最新版本为 V4,k 为 22,它是由 transaction id 为 25 的事务更新的。
在日志章节讲过,语句更新会生成 undo log(回滚日志)。在上图中,三个虚线代表的就是 undo log。
实际上,V1、V2、V3 三个版本并不在物理上存在,而是根据 undo log 回滚完成的。例如,当某个事务的快照需要 V3 版本下的数据,MySQL 就会通过一次回滚获得该数据。
讲到这里,你可能就明白了,快照并不在物理上存在,而是根据回滚动态生成的。
有了这个概念,我们再来看看,对于一个快照来说,MySQL 是如何确定回滚到哪个版本。
2.回滚版本选择(查询版本选择)
让我们从事务的角度,看一看一个快照该如何确定一个数据的版本:
- 如果一个数据在快照进行“拍照”前就已经被事务提交了,那就应该承认这个版本。
- 如果一个数据在拍照后被其它事务修改了,那这个版本的数据就不被承认,需要进行回滚。
- 特别的:如果这个数据被自己更改,应当承认。
你可能要问了:如何确定一个数据版本是在拍照前,还是拍照后被更改的呢?
答案是:使用前面说过的,行数据中记录的 row trx_id 进行判断。该属性记录了更改数据的事务ID,我们可以分析该 ID 判断这个更改出现在拍照前,还是拍照后。
具体操作如下:
- 当一个事务被创建时,会有一个数组记录这个时刻仍然活跃的所有事务的 ID,“活跃”是指,启动了但是未提交的事务。
- 该数组中 事务ID 的最小值记为 低水位,小于低水位的事务 ID 都是已经提交的事务的ID。
- 当前系统中已存在的事务ID 的最大值 +1 为 高水位,也就是说,大于高水位的事务 ID 都是未开始的事务的ID。
- 在高低水位中间的集合,会有两种情况:
1.ID 存在于“活跃数组”中,证明这个事务在快照拍照时还没有提交。其中包括该事务自身,这一点需要额外注意。
2.不存在于数组中,证明这个事务在快照拍照时已经完成了提交。
基于上面的原则,构建了一个数据版本的可见性规则:

对于任何一个版本中可能出现的事务ID(即 row trx_id),都会落在上图的区间中,对此我们也有了选择规则:
- 如果 row trx_id 落在绿色部分,承认该数据,数据可见。
- 如果落在红色部分,不承认,对数据版本进行回滚。
- 如果落在黄色部分,分析其中两种情况:
a. 若 row trx_id 在活跃数组中,不承认该数据,回滚。(特例:如果 row trx_id 为自身事务的 transaction id,承认。)
b. 若不在活跃数组中,承认,数据可见。
至此,根据上述规则,你会明白,InnoDB 利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。
回到开篇的问题,你就能够理解,为什么事务 A 的查询结果为 1 了,假设:
- 事务A 开始前,系统中只有 99 活跃。
- A、B、C 的ID分别为 100、101、102,且当前系统只有这四个事务。
- A 开始前,(1,1) 的数据 row trx_id 为 90.

当 事务A 进行查询时,需要对数据版本进行判断:
- 当前版本(1,3),版本号 101,高于高水位,落于红色区域,回滚。
- 当前版本(1,2),版本号 102,红色区域,回滚。
- 当前版本(1,1),版本号 90,低于低水位,落在绿色区域,承认数据,查询完成。
- 查询结果:k = 1.
查询问题到这里就结束了,下面我们来分析一个之前提到的特殊情况:如果数据的版本由事务本身更新,该怎么办。
3.更新逻辑
对于开篇的 事务B,为什么它的读取结果为 3 呢?它的更新逻辑如下:
此时,在第一个问号处,执行 set k=k+1 ,结果是该行数据更新一个版本,其中 k=3,版本号为 101 。注意,这个操作是在(1,2)的基础上执行的。(这是当然的,因为 事务C,已经提交,任何数据库不可能将一个提交的事务的数据无视。)
随后,到达第二个问号处,此时最新版本为 row trx_id = 101,k = 3,此时进行查询。查询情境为:版本号落在黄色区域,row trx_id 为自身,承认最新版本,返回查询结果 k = 3。
4.当前读
你会发现,在一个快照中查询数据,返回的数据可能和数据库中的真实数据不同。那么,如何在可重复度的隔离级别下获取到数据库中真正的数据呢?
以开篇的问题为例,如果在事务 A 中使用下列两个语句,查询结果都会是最新数据,也就是(1,3)的数据:
mysql> select k from t where id=1 lock in share mode;
mysql> select k from t where id=1 for update;
其中,第一个语句加了读锁(S锁,共享锁),第二个语句加了写锁(X锁,排他锁)。这样获取当前数据库中数据的读取,你可以称之为“当前读”。
实际上,在进行数据 update 的时候,读到的数据就是“当前读”级别的数据,而不是快照提供的。
在这里,可以将开篇的例子进行改写,事务C 变成事务 C`,你可以想一想它会怎样:
在 事务B 执行 update 时,需要进行当前读操作,而此时事务 C` 没有提交,依然持有该行的 写锁,所以,B会被阻塞,直到锁被释放(还记得什么时候会被释放吗?没错,是事务 C` 被提交的时候,这就是我们之前说的,二段提交。):

至此,我们就将之前的知识点串起来了。
5.如何实现读提交的隔离级别
非常简单,可重复读在事务创建时拍摄数据库“快照”,而在读提交级别下,在每个语句前创建一个快照即可。
总结
- 事务的快照通过事务的 transaction id 和 数据行 中的 row trx_id 可以快速建立。
- 快照并不存在,而是通过数据的版本回滚实现。
- 在事务中,select 的查询结果可能和数据库中的实际数据不符。
- 想要避免这种情况,可以对查询语句加锁,实现可重复读,获取数据的最新版本。
思考题
又到思考题时间了。我用下面的表结构和初始化语句作为试验环境,事务隔离级别是可重复读。现在,我要把所有“字段 c 和 id 值相等的行”的 c 值清零,但是却发现了一个“诡异”的、改不掉的情况。请你构造出这种情况,并说明其原理。
mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
insert into t(id, c) values(1,1),(2,2),(3,3),(4,4);

复现出来以后,请你再思考一下,在实际的业务开发中有没有可能碰到这种情况?你的应用代码会不会掉进这个“坑”里,你又是怎么解决的呢?
答案我会在下一篇文章中贴出来。
以上就是关于快照的全部内容,希望你有所收获。
至此,课程中的基础部分就全部结束了,之后会进入实践篇。
注:本文章的主要内容来自我对极客时间app的《MySQL实战45讲》专栏的总结,我使用了大量的原文、代码和截图,如果想要了解具体内容,可以前往极客时间