vlambda博客
学习文章列表

MySQL提高01-理解InnoDB的事务隔离机制

-- 文章由来 --

  关于该篇文章,内容是 InnoDB 的事务隔离,这是一个热门话题,网上也有不少资料,而本人也经常在面试或被面试过程中与候选人或面试官讨论该部分的内容,该文章主要参考了极客时间林晓斌老师的《MySQL 实战 45 讲》课程内容,加了一些简单的实例验证,对 InnoDB 的事务隔离机制进行讲解,水平有限,若有朋友发现错误,欢迎及时批评指正。

-- 正文 --

1、事务四大特性:ACID

  ACID 是事务的四个基本特性,一个引擎要支持事务,就是要对这四个特性做支持,其中,原子性、一致性、持久性是对单个事务的要求,而隔离性则是对事务之间的协同给出了理论上的规则,接下来我们重点讨论事务的隔离性。

  Atomicity 原子性: 事务的操作要么一起成功,要么一起失败。

  Consistency 一致性: 一致性是对数据可见性的约束,一个事务多次操作数据的中间状态对其他事务不可见。

  Isolation 隔离性: 多个事务并发执行时,一个事务不应该受到其他事务的影响,InnoDB引擎支持不同的隔离级别来满足不同场景的需求。

  Durability 持久性: 事务完成之后,所有的操作结果都保存到了数据库之中,不会丢失,不能回滚。

2、事务并发产生的问题

  上面我们谈到要支持事务,就要支持事务的隔离性,也就是在多个事务并发时处理好事务之间的相互影响,那么我们先来看一下事务并发时会产生哪些问题。

  脏读: 对于两个事务 T1 和 T2,T1 读取了已经被 T2 更新但还没有被提交的字段,之后若 T2 进行回滚,T1 读取的内容就是临时且无效的。

  不可重复读: 对于两个事务 T1 和 T2 , T1 读取了一个字段,然后 T2 更新了该字段,之后 T1 再次读取同一个字段,值就不同了。

  幻读: 对于两个事务 T1,T2,T1 从表中读取了一个字段,然后 T2 在该表中插入了一些新的行,之后 T1 再次读取同一个表,就会多出几行。

  不可重复读和幻读的区别: 不可重复读针对的是更新和删除操作,幻读针对的是插入操作,比如:T1 正在操作一条记录,如果加锁,T2 就不能对这条记录进行更新和删除,这就避免了 不可重复读,但无法避免 T2 插入新的数据,也就是无法避免幻读,InnoDB 中通过 gap 锁来解决幻读问题,这里我们不讨论 gap 锁。

3、InnoDB 如何解决事务并发问题

  InnoDB 为了解决事务并发导致的脏读、不可重复读、幻读问题,提供了事务隔离机制,共有四种隔离级别:

  读未提交: 一个事务还未提交,他的变更就能被其他事务看到,这个级别就是没有任何隔离;

  读已提交: 一个事务的变更,只有在提交后才能被其他事务看到;

  可重复读: 一个事务执行过程中看到的数据,总是跟这个事务启动时看到的数据一致,即使数据被其他事务更改并提交,也是不可见的;

  串行化: 对于同一行记录,写会加写锁,读会加读锁,当出现读写锁冲突时,后访问的事务必须等之前的事务执行完成才能继续执行;

  隔离级别越高,数据一致性越能得到保障,但并发性也就越低,MySQL 默认的隔离级别是可重复读,在 MySQL 中针对隔离级别的相关操作如下:

-- 查看隔离级别
show variables like 'transaction_isolation';

-- 设置当前会话隔离级别
set session transaction isolation level read uncommitted;
set session transaction isolation level read committed;
set session transaction isolation level repeatable read;
set session transaction isolation level serializable;

-- 设置整个库的隔离级别
set global transaction isolation level read uncommitted;
set global transaction isolation level read committed;
set global transaction isolation level repeatable read;
set global transaction isolation level serializable;

4、InnoDB 隔离级别的实现

  InnoDB 的四种隔离级别,读未提交不需要做任何操作,做任何操作都读当前最新的值就可以了,串行化是严格的互斥操作,通过加锁来实现,读已提交和可重复读则通过 MVCC(多版本并发控制)来实现,下面我们详细介绍一下 MVCC 的工作原理。

4.1 两种读模式

  在介绍 MVCC 之前,我们先了解一下 MySQL 中的两种读模式:

  快照读: 读取事务快照的历史版本数据,无需加锁,正常的 select 语句就是快照读。

  当前读: 读取数据库最新的数据,需要加锁才能实现,采用当前读实现的 SQL 语句有以下几种:

select ... lock in share mode;
select ... for update;
delete
update
insert into
replace into

  我们通过以下两个例子来理解下当前读和快照读:(按照代码行后面的标号顺序来执行):

/**
 * case 1:
 * 假设 num 初始值为 1,按照代码行中的标号顺序进行执行,
 * session1 中 ② 和 ⑦ 查询到的 num 分别是多少?
 */
session1:
start transaction with consistent snapshot; -- ①
select * from test; -- ②
select * from test; -- ⑦
commit; -- ⑧

session2:
start transaction with consistent snapshot; -- ③
update test set num = 2 where id = 1; -- ④
select * from test; -- ⑤
commit; -- ⑥

/**
 * case 2:
 * 假设 num 初始值为 1,按照 代码行中的标号顺序进行执行,
 * session1 中 ② 和 ⑧ 分别查到什么结果?⑤ 能顺利执行吗?
 */
session1:
start transaction with consistent snapshot; -- ①
select * from test; -- ②
update test set num = 3 where id = 1; -- ⑤
select * from test; -- ⑧
commit; -- ⑨

session2:
start transaction with consistent snapshot; -- ③
update test set num = 2 where id = 1; -- ④
select * from test; -- ⑥
commit; -- ⑦

  以上两个例子:

  第一个例子, 由于读操作是快照读,而事务开始时已经通过 with consistent snapshot 语句生成了快照,因此两次查询结果都是 1。

  第二个例子, 第一个读是快照读,结果是 1,执行到更新语句时,由于 session2 先一步做了更新,而更新属于当前读,因此两个更新同时操作时会冲突,这里通过锁来解决冲突,从而可知,session1 的 ⑤ 在 session2 提交之前都处于阻塞等待状态,session 2 提交之后锁释放了,session1 才能继续执行,第二个查询得到的结果是更新之后的结果 3。

4.2 undo log

  MySQL 中对于每一条更新操作,都会记录 undo log 用于回滚和支持 MVCC。

  undo log 记录的是逻辑日志,可以认为当 delete 一条记录时,undo log 中会记录一条对应的 insert 记录,反之亦然,当 update 一条记录时,它记录一条对应相反的 update 记录。(这里对于 undo log 的理解有些浅薄)

  应用到多版本控制的时候,是这样工作的:当读取的某一行被其他事务锁定时,它可以从 undo log 中分析出该行记录以前的数据是什么,从而提供该行版本信息,让用户实现非锁定一致性读取。

  这里我们不做 undo log 的详细工作原理的研究,只需要知道 MVCC 需要根据 undo log 来实现多版本数据的查找就可以了。

4.3 表的隐藏字段

  InnoDB 里每个事务都有一个唯一的 ID,即:transaction id,它是事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。

  InnoDB 通过将每条数据与操作它的 transaction id 和 undo log 做关联,来达到维护多个数据版本的目的,那么如何做关联呢?就是为表增加隐藏列,InnoDB 会默认为每张表增加三个列:

列名 描述
DB_ROW_ID 行标识(隐藏的自增 id,如果没有明确的聚集索引,InnoDB  会自动生成一个聚集索引,这个聚集索引的值就是该 id);
DB_TRX_ID 插入或更新行的最后一个事务 id(删除也视为更新,但会标记为已删除);
DB_ROLL_PTR 指向对应的 undo log 用于回滚到上一个版本;

  一个事务对一行进行插入或更新,会将这一行的 DB_TRX_ID 的值修改为自身的事务 id,并将 DB_ROLL_PTR 指向生成的 undo log。

4.4 一致性视图

  MySQL 中有两种视图,一种是 view,是用一个查询语句定义的虚拟表,在调用的时候调用查询语句并生成结果,另一种是实现 MVCC 时用到的一致性视图 consistent read view,用于支持 RC(读提交)和 RR(可重复读)隔离级别的实现。

  一个事务对数据的可见性以生成一致性视图的时刻为准来确定,如果一个数据版本在一致性视图生成之前之前生成,事务就认为该数据可见,如果在一致性视图生成之后生成事务就认为该数据不可见,必须通过“回滚”的方式找到上一个版本,如果上一个版本还在一致性视图生成之后,那就继续往前回滚,直到找到比一致性视图生成时刻更早的数据版本为止,这个“回滚”操作就是通过前面表隐藏字段记录的版本信息和 undo log 来实现的。

  事务生成的时刻:

-- 以下事务中,第一次执行快照读操作的时候生成一致性视图
start transaction;
...
commit / rollback;

-- 可以通过以下语句控制事务在启动时就生成一致性视图
start transaction with consistent snapshot;

  在实现上,InnoDB 为每个事物构建了一个数组,用来存储这个事务启动瞬间,当前所有活跃的事务 id,所谓活跃是指已经启动但未提交,数组里最小的 id 记为低水位,当前已创建的最大的事务 id+1 记为高水位,这个数组和高水位一起组成了当前事务的一致性视图;(在分配事务 id 和生成视图之间的时间段,可能产生新的事务,其 id 大于当前事务 id,若其在当前事务的一致性视图生成之前提交了,则其结果对当前事务也是可见的

  可以通过低水位和高水位将 MySQL 中的事务 id 分为三段:小于低水位、大于等于低水位且小于高水位、大于等于高水位,我们将三段简称为 低段、中段、高段,判断一条数据是否对当前事务可见,直接根据这条数据的 DB_TRX_ID 与当前事务的一致性视图比较即可:

  1)如果数据的 DB_TRX_ID 落在了低段,即:DB_TRX_ID 小于低水位,则说明这条数据在事务生成一致性视图时已提交,可见。

  2)如果数据的 DB_TRX_ID 落在了高段,即:DB_TRX_ID 大于等于高水位,则说明这条数据在事务生成一致性视图时还未提交,不可见。

  3)如果数据的 DB_TRX_ID 落在了中段,即:DB_TRX_ID 大于等于低水位且小于高水位,此时,若 DB_TRX_ID 在数组中,则说明生成一致性视图时该数据还未提交,不可见,否则,这条数据可见。

  以上就是一致性视图的工作原理。

  RR 隔离级别是在事务执行第一条快照读语句时创建一致性视图的。

  RC 隔离级别则是每次执行快照读语句时都会创建最新的一致性视图。

4.5 例程

我们一起通过上面的隔离级别实现原理,来分析一下下面例子的执行结果:

执行序号 session1 session2 session3
1 start transaction;

2
start transaction;
3 insert into test(num) values(100);

4
insert into test(num) values(101);
5 select num from test;

6

insert into test(num) values(102);
7

insert into test(num) values(103);
8

insert into test(num) values(104);
9

select num from test;
10
select num from test;
11 select num from test;

  问题: 最终,三个 session 中,第5、9、10、11四行中的查询语句结果分别是多少?

  分析:

  1)假设 session1 中的事务 id 为 5,则 session2 中事务 id 为 6,session3 中四个事务的 id 分别为 7、8、9、10(MySQL 中不显示指定事务时,默认一条语句为一个事务)。

  2)根据以上假设:

  session1 中第 5 行创建一致性视图时,活跃事务队列为(5, 6),低水位为 5,高水位为 7,由于 session2 的事务 id 在队列中,因此 session1 的事务对插入的 101 不可见,因此第 5 行的查询结果为 100。

  session3 中第 9 行创建一致性视图时,活跃事务队列为(5, 6, 10),低水位为 5,高水位为 11,它不可见前面两个事务插入的数据,因此查询的结果为 102、103、104。

  session2 中第 10 行创建一致性视图时,活跃事务队列为(5, 6),低水位为 5,高水位为 11(此时全局最大的事务 id 为 10),它对 session3 的几个事务插入的数据均可见,但对 session1 插入的数据不可见,因此查询的结果为 101、102、103、104。

  session1 中第 11 行,由于之前已经创建过一致性视图了,因此这里不再创建,查询结果还是 100。

  以上操作均在 RR 隔离级别下进行,若是 RC 隔离级别,则每次快照读都会创建最新的一致性视图,具体结果可自行推导验证。

5、参考资料

  极客时间 林晓斌 老师的《MySQL 实战 45 讲》

  https://github.com/zhangyachen/zhangyachen.github.io/issues/68