详解MySQL MVCC多版本并发控制
目 录
一、事务是什么
二、隔离性与隔离级别
三、事务隔离的实现
四、事务的启动方式
五、一致性读
六、当前读
一、事务是什么
事务保证一组数据库操作,要么全部成功,要么全部失败。在Mysql中,事务支持是在引擎层实现的。MySQL 是一个支持多引擎的系统,但并不是所有的引擎都支持事务。比如 MySQL 原生的 MyISAM 引擎就不支持事务,这也是 MyISAM 被 InnoDB 取代的重要原因之一。
事务具备ACID四个特性:
原子性(Atomicity):事务是一个不可分割的工作单位,事务中的操作要么全部成功,要么全部失败;
一致性(Consistency):事务执行前后,数据库都必须处于一致性状态;
隔离性(Isolation):在并发环境中,并发的事务是相互隔离的,一个事务的执行不能被其他事务干扰
持久性(Durability):一旦事务提交,那么它对数据库中的对应数据的状态的变更就会永久保存到数据库中。即使发生系统崩溃或机器宕机等故障,只要数据库能够重新启动,那么一定能够将其恢复到事务成功结束的状态
下边重点介绍隔离性:
二、隔离性与隔离级别
当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了“隔离级别”的概念。
在谈隔离级别之前,你首先要知道,你隔离得越严实,效率就会越低。因此很多时候,我们都要在二者之间寻找一个平衡点。SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable ):
读未提交是指,一个事务还没提交时,它做的变更就能被别的事务看到。
读提交是指,一个事务提交之后,它做的变更才会被其他事务看到。
可重复读是指,一个事务执行过程中看到的数据,总是跟这个事务在启动时看到的数据是一致的。当然在可重复读隔离级别下,未提交变更对其他事务也是不可见的。
串行化,顾名思义是对于同一行记录,“写”会加“写锁”,“读”会加“读锁”。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。
事务A | 事务B |
---|---|
我们来看看在不同的隔离级别下,事务 A 会有哪些不同的返回结果,也就是图里面 V1、V2、V3 的返回值分别是什么:
若隔离级别是“读未提交”, 则 V1=2。这时候事务 B 虽然还没有提交,但是结果已经被 A 看到了。V2=2,V3=2。
若隔离级别是“读提交”,则 V1=1,V2=2。事务 B 的更新在提交后才能被 A 看到。V3=2。
若隔离级别是“可重复读”,则 V1=1,V2=1,V3=2。之所以 V2 还是 1,遵循的就是这个要求:事务在执行期间看到的数据前后必须是一致的。
若隔离级别是“串行化”,则在事务 B 执行“将 1 改成 2”的时候,会被锁住。直到事务 A 提交后,事务 B 才可以继续执行。所以从 A 的角度看, V1=1,V2=1,V3=2。
在实现上,数据库里面会创建一个视图,访问的时候以视图的逻辑结果为准。
在“可重复读”隔离级别下,这个视图是在事务第一个select语句时创建的(整个库的视图,而不仅仅selec语句用到的表),整个事务存在期间都用这个视图。
在“读提交”隔离级别下,这个视图是在每个 select语句开始执行的时候创建的。
“读未提交”隔离级别下直接返回记录上的最新值,没有视图概念;
而“串行化”隔离级别下直接用加锁的方式来避免并行访问。
三、事务隔离的实现
理解了事务的隔离级别,我们再来看看事务隔离具体是怎么实现的。这里我们展开说明“可重复读”。
在 MySQL 中,实际上每条记录在更新的时候都会同时记录一条回滚操作。记录上的最新值,通过回滚操作,都可以得到前一个状态的值。
假设一个值从 1 被按顺序改成了 2、3、4,在回滚日志里面就会有类似下面的记录。
图1
当前值是 4,但是在查询这条记录的时候,不同时刻启动的事务会有不同的 read-view。如图中看到的,在视图 A、B、C 里面,这一个记录的值分别是 1、2、4,同一条记录在系统中可以存在多个版本,就是数据库的多版本并发控制(MVCC)。对于 read-view A,要得到 1,就必须将当前值依次执行图中所有的回滚操作得到。
同时你会发现,即使现在有另外一个事务正在将 4 改成 5,这个事务跟 read-view A、B、C 对应的事务是不会冲突的。
那么回滚日志什么时候删除呢?就是当系统里没有比这个回滚日志更早的 read-view 的时候。
因此,建议你尽量不要使用长事务。长事务意味着系统里面会存在很老的事务视图。由于这些事务随时可能访问数据库里面的任何数据,所以这个事务提交之前,数据库里面它可能用到的回滚记录都必须保留,这就会导致大量占用存储空间。
四、事务的启动方式
MySQL 的事务启动方式有以下几种:
显式启动事务语句, begin 或 start transaction。配套的提交语句是 commit,回滚语句是 rollback。
set autocommit=0,这个命令会将这个线程的自动提交关掉。意味着如果你只执行一个 select 语句,这个事务就启动了,而且并不会自动提交。这个事务持续存在直到你主动执行 commit 或 rollback 语句,或者断开连接。
如果用set autocommit=0,接下来的查询都在事务中,如果是长连接,就导致了意外的长事务。
因此,建议总是使用 set autocommit=1, 通过显式语句的方式来启动事务。但这样就“多一次间交互”,对于一个需要频繁使用事务的业务,建议在提交时,使用 commit work and chain 语法(提交事务并自动启动下一个事务)
可以在 information_schema 库的 innodb_trx 这个表中查询长事务,比如下面这个语句,用于查找持续时间超过 60s 的事务。
代码块
SQL
select * from information_schema.innodb_trx where TIME_TO_SEC(timediff(now(),trx_started))>60
五、一致性读
读取快照中的数据,多次读取的数据完全一致,包括select语句
一致性视图:启动时刻的活跃事务ID数组 + 高水位
高水位:当前系统里面已经创建过的事务 ID 的最大值加 1
低水位:获取事务ID数组中的最小值
在可重复读隔离级别下,事务在执行第一个select语句的时候就“拍了个快照”。注意,这个快照是基于整库的。
但我们并不需要将整个库拷贝一遍,我们先来看看这个快照是怎么实现的。
InnoDB 里面每个事务有一个唯一的事务 ID,叫作 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把 transaction id 赋值给这个数据版本的事务 ID,记为 row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
也就是说,数据表中的一行记录,其实可能有多个版本 (row),每个版本有自己的 row trx_id。
如图所示,就是一个记录被多个事务连续更新后的状态。
图2
中虚线框里是同一行数据的 4 个版本,当前最新版本是 V4,k 的值是 22,它是被 transaction id 为 25 的事务更新的,因此它的 row trx_id 也是 25。
图中的三个虚线箭头(U1、U2、U3),就是 回滚日志(undo log);V1、V2、V3 并不是物理上真实存在的,而是每次需要的时候根据当前版本和 undo log 计算出来的。比如,需要 V2 的时候,就是通过 V4 依次执行 U3、U2 算出来。
明白了快照怎么实现后,我们再来看看如何定义一个快照的?
按照可重复读的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。
因此,一个事务只需要在启动的时候声明说,“以我启动的时刻为准,如果一个数据版本是在我启动之前生成的,就认;如果是我启动以后才生成的,我就不认,我必须要找到它的上一个版本”。
当然,如果“上一个版本”也不可见,那就得继续往前找。还有,如果是这个事务自己更新的数据,它自己还是要认的。
在实现上, InnoDB 为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务 ID。“活跃”指的就是,启动了但还没提交。
数组里面事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。
这个视图数组和高水位,就组成了当前事务的一致性视图(read-view)。
而数据版本的可见性规则,就是基于数据的 row trx_id 和这个一致性视图的对比结果得到的。
这个视图数组把所有的 row trx_id 分成了几种不同的情况。
图3
这样,对于当前事务的启动瞬间来说,一个数据版本的 row trx_id,有以下几种可能:
如果落在绿色部分,表示这个版本是已提交的事务或者是当前事务自己生成的,这个数据是可见的;
如果落在红色部分,表示这个版本是由将来启动的事务生成的,是肯定不可见的;
如果落在黄色部分,那就包括两种情况
若 row trx_id 在数组中,表示这个版本是由还没提交的事务生成的,不可见;
若 row trx_id 不在数组中,表示这个版本是已经提交了的事务生成的,可见。
比如,对于图 2 中的数据来说,如果有一个事务,它的低水位是 18,那么当它访问这一行数据时,就会从 V4 通过 U3 计算出 V3,所以在它看来,这一行的值是 11。
六、当前读
更新数据时,读取最新的已提交数据,包括update、insert、delete语句,以及加锁的select语句
如果在更新语句时,也按照一致性读,会出现什么问题呢?
事务A | 事务B |
---|---|
事务A先查询一次,创建一个一致性试图,然后再增加1,那么k应该为2,这样就造成事务B的更新被丢失了。
所以,这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。
因此,在更新的时候,当前读拿到的数据是事务B更新后的2,更新后生成了新版本的数据 3。在执行事务A的查询语句时,查询到的k值就应该是3。
不仅仅是更新语句,加锁的select语句也同样使用“当前读”。
代码块SQL:
select * from table lock in share mode; -- 读锁,共享锁
select * from table for update; -- 写锁,排他锁
接下来用一个实际的例子来说明一致性读和当前读。
下边是一个只有两行数据的表
代码块SQL
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);
三个事务的执行顺序如下:
事务A(trx_id=100) | 事务B(trx_id=101) | 事务C(trx_id=102) |
---|---|---|
在这三个事务中,事务A的查询结果是1,事务B的查询结果是3
下边详细解释原因:
这里,我们不妨做如下假设:
事务 A 开始前,系统里面只有一个活跃事务 ID 是 99;
事务 A、B、C 的版本号分别是 100、101、102,且当前系统里只有这四个事务;
三个事务开始前,(1,1)这一行数据的 row trx_id 是 90。
这样,事务 A 的视图数组就是[99,100], 事务 B 的视图数组是[99,100,101], 事务 C 的视图数组是[99,100,101,102]。
图4
从图中可以看到,第一个有效更新是事务 C,把数据从 (1,1) 改成了 (1,2)。这时候,这个数据的最新版本的 row trx_id 是 102,而 90 这个版本已经成为了历史版本。
第二个有效更新是事务 B,采用当前读,读到最新的已提交数据是(1,2),把数据从 (1,2) 改成了 (1,3)。这时候,这个数据的最新版本(即 row trx_id)是 101,而 102 又成为了历史版本。
在事务 A 查询的时候,其实事务 B 还没有提交,但是它生成的 (1,3) 这个版本已经变成当前版本了。但这个版本对事务 A 必须是不可见的,否则就变成脏读了。
现在事务 A 要来读数据了,它的视图数组是[99,100],高水位101,低水位99。当然了,读数据都是从当前版本读起的。所以,事务 A 查询语句的读数据流程是这样的:
找到 (1,3) 的时候,判断出 row trx_id=101,比高水位大,处于红色区域,不可见;
接着,找到上一个历史版本,一看 row trx_id=102,比高水位大,处于红色区域,不可见;
再往前找,终于找到了(1,1),它的 row trx_id=90,比低水位小,处于绿色区域,可见。
这样执行下来,虽然期间这一行数据被修改过,但是事务 A 不论在什么时候查询,看到这行数据的结果都是一致的,所以我们称之为一致性读。
简单来说,可以总结为以下3个规则:
版本未提交,不可见;
版本已提交,但是是在视图创建后提交的,不可见;
版本已提交,而且是在视图创建前提交的,可见。