我所理解的MySQL(四)事务、隔离级别及MVCC

Planeswalker23 2020年10月25日 共12,062字 238次浏览

你好,有幸相见。

从九月开始,我决定发起「每周一博」的目标:每周至少发布一篇博客,可以是各种源码分析研读,也可以是记录工作中遇到的难题。

在经过了一段时间漫无目的的学习之后,我发现那样用处好像不大,看过的东西过段时间就忘了,而且也没有做什么笔记。

“凡所学,必有所输出。”我认为这才是最适合我的学习方式,这也是「每周一博」活动的来由,朋友们,如果你也觉得经常会忘记以前看过的东西,一起加入这个活动吧。

这是十月的第三篇博客,同时这也是 MySQL 系列的第四篇。


MySQL 系列的第四篇,主要内容是事务,包括事务 ACID 特性,隔离级别,脏读、不可重复读、幻读的理解以及多版本并发控制(MVCC)等内容。

事务(Transaction)能够保证一组不可分割的原子性操作集合要么都执行,要么都不执行。在MySQL 常用的存储引擎中,InnoDB 是支持事务的,原生的 MyISAM 引擎则不支持事务。

在本文中,若未特殊说明,使用的数据表及数据如下所示:

CREATE TABLE `user`  (
  `id` int(11) DEFAULT NULL,
  `name` varchar(12) DEFAULT NULL
) ENGINE = InnoDB;

insert into user values(1, '刺猬');

1. ACID 四大特性

首先需要理解的是事务 ACID 四大特性,即原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability),这也是事务的四个基本要素。

为了详细解释 ACID 特性,在这里先设想一个场景:我向你转账100元。

假设这个操作可以分为以下几步(假设我和你的账户余额均为100元):

  1. 查询我的账户余额
  2. 我的账户扣款100元
  3. 100元开始转移
  4. 查询你的账户余额
  5. 你的账户到账100元

1.1 原子性(Atomicity)

事务的原子性是指:一个事务必须是不可再分割的最小工作单元,一个事务中的操作要么都成功,要么都失败,不可能存在只执行一个事务中部分操作的情况。

在上述的转账场景中,原子性就要求了这五个步骤要么都执行,要么都不执行,不可能存在我的账户扣款100元,而你的账户100元没有到账的情况。

1.2 一致性(Consistency)

事务的一致性是指:数据库总是从一个一致性状态转换到另一个一致性状态,一致性侧重的是数据的可见性,数据的中间状态对外是不可见的。

同时,事务的一致性要求符合开发人员定义的约束,如金额大于0、身高大于0等。

在上述的转账场景中,一致性能够保证最终执行完整个转账操作后,我账户的扣款金额与你账户到账金额是一致的,同时如果我和你的账户余额不满足金额的约束(如小于0),整个事务会回滚。

1.3 隔离性(Isolation)

事务的隔离性是指:在一次状态转换过程中不会受到其他状态转换的影响。

假设我和你都有100元,我发起两次转账,转账金额都是50元,下面使用伪代码来表示的操作步骤:

  1. 查询我的账户余额 read my
  2. 我的账户扣款50元 my=my-50
  3. 50元开始转移
  4. 查询你的账户余额 read yours
  5. 你的账户到账50元 yours=yours+50

如果未保证隔离性就可能发生下面的情况:

时刻第一次转账第二次转账我的账户余额你的账户余额
1read my(100) my=100yours=100
2 read my(100)my=100yours=100
3my=my-50=100-50=50 my=50yours=100
4read yours(100)my=my-50=100-50=50my=50yours=100
5yours=yours+50=100+50=150 my=50yours=150
6 read yours(150)my=50yours=150
7 yours=yours+50=150+50=200my=50yours=200
7endendmy=50yours=200

两次转账后,最终的结果是我的账户余额为50元,你的账户余额为200元,这显然是不对的。

而如果在保证事务隔离性的情况下,就不会发生上面的情况,损失的只是一定程度上的一致性。

1.4 持久性(Durability)

事务的持久性是指:事务在提交以后,它所做的修改就会被永久保存到数据库。

在上述的转账场景中,持久性就保证了在转账成功之后,我的账户余额为0,你的账户余额为200。

2. 自动提交与隐式提交

2.1 自动提交

在 MySQL 中,我们可以通过 begin 或 start transaction 来开启事务,通过 commit 来关闭事务,如果 SQL 语句中没有这两个命令,默认情况下每一条 SQL 都是一个独立的事务,在执行完成后自动提交

比如:

update user set name='重塑' where id=1;

假设我只执行这一条更新语句,在我关闭 MySQL 客户端然后重新打开一个新的客户端后,可以看到 user 表中的 name 字段值全变成了「重塑」,这也印证了这条更新语句在执行后已被自动提交。

自动提交是 MySQL 的一个默认属性,可以通过 SHOW VARIABLES LIKE 'autocommit' 语句来查看,当它的值为 ON 时,就代表开启事务的自动提交。

mysql> SHOW VARIABLES LIKE 'autocommit';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| autocommit    | ON    |
+---------------+-------+
1 row in set (0.00 sec)

我们可以通过 SET autocommit = OFF 来关闭事务的自动提交。

2.2 隐式提交

然而,即便我们已经将 autocommit 变量的值改为 OFF 关闭事务自动提交了,在执行某些 SQL 语句的时候,MySQL 还是会将事务自动提交掉,这被称为隐式提交

会触发隐式提交的 SQL 语句有:

  • DDL(Data definition language,数据定义语言),如 create, drop, alter, truncate
  • 修改 MySQL 自带表数据的语句,如 create/drop user, grant, set password
  • 在一个事务中,开启一个新的事务,会隐式提交上一个事务,如:
时刻事务A事务B
1begin;
2update user set name='重塑' where id=1;
3 select name from user where id=1;(N1)
4begin;
5 select name from user where id=1;(N2)

在事务B中有两个查询语句N1和N2,执行的结果是N1=刺猬,N2=重塑,由此可以证明。

  • 其他还有一些管理语句就不一一举例了,可自行百度。

3. 隔离级别

事务的隔离级别规定了一个事务中所做的修改,在事务内和事务间的可见性。较低级别的隔离通常可以执行更高的并发,系统开销也更低。

在 SQL 标准中定义了四种事务的隔离级别,分别是读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复读(Repeatable Read)、可串行化(Serializable)。

为了详细解释这四种隔离级别及它们各自发生的现象,假设有两个事务即将执行,执行内容如下表:

时刻事务A事务B
1begin;
2 begin;
3 update user set name='重塑' where id=1;
4select name from user where id=1;(N1)
5 commit;
6select name from user where id=1;(N2)
7commit;
8select name from user where id=1;(N3)

在事务A和事务B执行的过程中,有三处查询 N1,N2,N3,在每个隔离级别下,它们值的情况是不同的,下面分别讨论。

3.1 读未提交(Read Uncommitted)

在读未提交的隔离级别下,事务中的修改,即便没有提交,对其他事务也都是可见的

在上述场景中,若数据库的隔离级别为读未提交,由于事务A可以读取未提交事务B修改后的数据,即时刻3中事务B的修改对事务A可见,所以N1=重塑,N2=重塑,N3=重塑。

3.2 读已提交(Read Committed)

在读已提交的隔离级别下,事务中的修改只有在提交之后,才会对其他事务可见

在上述场景中,若数据库的隔离级别为读已提交,由于事务A只能读取事务B提交后的数据,即时刻3中事务B的修改对事务A不可见,N2处的查询在事务B提交之后,故对事务A可见。所以N1=刺猬,N2=重塑,N3=重塑。

3.3 可重复读(Repeatable Read)

可重复读是 MySQL 的默认事务隔离级别。在可重复读的隔离级别下,一个事务中多次查询相同的记录,结果总是一致的

在上述场景中,若数据库的隔离级别为可重复读,由于查询N1和N2在一个事务中,所以它们的值都是「刺猬」,而N3是在事务A提交以后再进行的查询,对事务B的修改是可见的,所以N3=重塑。

3.4 可串行化(Serializable)

在可串行化的隔离级别下,事务都是串行执行的,读会加读锁,写会加写锁,事务不会并发执行,所以也就不会发生异常情况。

在上述场景中,若数据库的隔离级别为可串行化,首先开启事务A,在开启事务B时被阻塞,直到事务A提交之后才会开启事务B,所以N1=刺猬,N2=刺猬。而N3处的查询会在事务B提交之后才执行(事务B先被阻塞,执行顺序在N3查询语句之前),所以N3=重塑。

4. 隔离级别导致的问题

在不同的事务隔离级别中,如果遇到事务并发执行,就会出现很多问题,如脏读(Dirty Read)、不可重复读(Non-Repeatable Read)、幻读(Phantom Read)等,下面就分别用不同的例子来详细说明这些问题。

4.1 脏读(Dirty Read)

脏读(Dirty Read)是指一个事务可以读取另一个未提交事务修改的数据。

看下面的案例,假设隔离级别为读未提交:

时刻事务A事务B
1begin;
2 begin;
3 update user set name='重塑' where id=1;
4select name from user where id=1;(N1)
5 rollback;
6select name from user where id=1;(N2)
7commit;

在读未提交的隔离级别下,N1的值是「重塑」,由于事务B的回滚,N2的值是「刺猬」。这里在N1处就发生了脏读,显然N1处的查询结果是一个脏数据,会对正常业务产生影响。

脏读会发生在读未提交的隔离级别中。

4.2 不可重复读(Non-Repeatable Read)

不可重复读(Non-Repeatable Read)是指,两次执行相同的查询可能会得到不一样的结果。

继续使用介绍隔离级别时的AB事务案例,同时假设隔离级别为读已提交:

时刻事务A事务B
1begin;
2 begin;
3 update user set name='重塑' where id=1;
4select name from user where id=1;(N1)
5 commit;
6select name from user where id=1;(N2)
7commit;
8select name from user where id=1;(N3)

在读已提交的隔离级别下,事务可以读取到其他事务提交的数据。在上述案例中结果是N1=刺猬,N2=重塑,N3=重塑,在事务A中,有两次相同的查询N1和N2,但是这两次查询的结果并不相同,这就发生了不可重复读。

不可重复读会发生在读未提交、读已提交的隔离级别中。

4.3 幻读(Phantom Read)

幻读(Phantom Read)是指,一个事务在读取某个范围内记录时,另外一个事务在该范围内***插入一条新记录***,当之前的事务再次读取这个范围的记录时,会读到这条新记录。

看下面的案例,假设此时隔离级别为可重复读:

时刻事务A事务B
1begin;
2select name from user;(N1)
3 begin;
4 insert into user values(2, '五条人');
5 commit;
6select name from user;(N2)
7select name from user for update;(N3)
8commit;

事务A有三次查询,在N1和N2之间,事务B执行了一条 insert语句并提交,N3处的查询使用的是 for update

N1处的结果很显然只有「刺猬」,N2处的结果由于事务A开启在事务B之前,所以也是「刺猬」,而N3处的结果理论上在可重复读的隔离级别中也应该只有「刺猬」,但实际上N2的结果是「刺猬」和「五条人」,这就发生了幻读。

这就很奇怪了,不是说可重复读的隔离级别能够保证一个事务中多次查询相同的记录,结果总是一致的吗?这种结果并不满足可重复读的定义。

事实上,在可重复读的隔离级别下,如果使用的是当前读,那么就可能发生幻读现象。

当前读和快照读会在下文中介绍事务的实现原理及 MVCC 时讨论,这里先给一个结论。

幻读会发生在读未提交、读已提交、可重复读的隔离级别中。

这里需要额外注意的是:幻读和不可重复读都是说在一个事务中的同一个查询语句结果不同,但幻读更侧重于查询到其他事务新插入的数据(insert)或其他事务删除的数据(delete),而不可重复读的范围更广,只要结果不同就可以认为是不可重复读,但一般我们认为不可重复读更侧重于其他事务对数据的更新(update)。

4.4 小结

通过上面的描述,我们已经知道四种隔离级别的概念以及它们分别会遇到的问题,事务的隔离级别越高,隔离性就越强,所遇到的问题也就越少。但同时,隔离级别越高,并发能力就越弱。

下表是对隔离级别的概念不同隔离级别会发生的问题情况的小结:

隔离级别脏读不可重复读幻读概念
读未提交事务中的修改,即便没有提交,对其他事务也都是可见的
读已提交 事务中的修改只有在提交之后,才会对其他事务可见
可重复读 一个事务中多次查询相同的记录,结果总是一致的
可串行化 事务都是串行执行的,读会加读锁,写会加写锁

5. MVCC

MVCC(Multi-Version Concurrency Control)即多版本并发控制,这是 MySQL 为了提高数据库并发性能而实现的。它可以在并发读写数据库时,保证不同事务的读-写操作并发执行,同时也能解决脏读、不可重复读、幻读等事务隔离问题。

在前文讨论幻读的时候提到过当前读的概念,正是由于当前读,才会在可重复读的隔离级别下也会发生幻读的情况。

在解释可重复读隔离级别下发生幻读的原因之前,首先介绍 MVCC 的实现原理。

5.1 MVCC 的实现原理

首先我们需要知道,InnoDB 的数据页中每一行的数据是有隐藏字段的:

  • DB_ROW_ID: 隐式主键,若表结构中未定义主键,InnoDB 会自动生成该字段作为表的主键
  • DB_TRX_ID: 事务ID,代表修改此行记录的最后一次事务ID
  • DB_ROLL_PTR: 回滚指针,指向此行记录的上一个版本(上一个事务ID对应的记录)

每一条修改语句都会相应地记录一条回滚语句(undo log),如果把每一条回滚语句视为一条数据表中的记录,那么通过事务ID和回滚指针就可以将对同一行的修改记录看作一个链表,链表上的每一个节点就是一个快照版本,这就是 MVCC 中多版本的意思。

举个例子,假设对 user 表中唯一的一行「刺猬」进行多次修改。

update user set name='重塑' where id=1;
update user set name='木马' where id=1;
update user set name='达达' where id=1;

那么这条记录的版本链就是:

版本链

在这个版本链中,头结点就是当前记录的最新版本。DB_TRX_ID 事务ID 字段是非常重要的属性,先 Mark 一下。

除此之外,在读已提交(RC,Read Committed)和可重复读(RR,Repeatable Read)的隔离级别中,事务在启动的时候会创建一个读视图(Read View),用它来记录当前系统的活跃事务信息,通过读视图来进行本事务之间的可见性判断

在读视图中有两个重要的属性:

  • 当前事务ID:表示生成读视图的事务的事务ID
  • 事务ID列表:表示在生成读视图时,当前系统中活跃着的事务ID列表
  • 最小事务ID:表示在生成读视图时,当前系统中活跃着的最小事务ID
  • 下一个事务ID:表示在生成读视图时,系统应该分配给下一个事务的事务ID

需要注意下一个事务I的值,并不是事务ID列表中的最大值+1,而是当前系统中已存在过的事务的最大值+1。例如当前数据库中活跃的事务有(1,2),此时事务2提交,同时又开启了新事务,在生成的读视图中,下一个事务ID的值为3。

我们通过将版本链与读视图两者结合起来,来进行并发事务间可见性的判断,判断规则如下(假设现在要判断事务A是否可以访问到事务B的修改记录):

  • 若事务B的当前事务ID小于事务A的最小事务ID的值,代表事务B是在事务A生成读视图之前就已经提交了的,所以事务B对于事务A来说是可见的。
  • 若事务B的当前事务ID大于或等于事务A下一个事务ID的值,代表事务B是在事务A生成读视图之后才开启,所以事务B对于事务A来说是不可见的。
  • 若事务B的当前事务ID在事务A的最小事务ID下一个事务ID之间(左闭右开,[最小事务ID, 下一个事务ID)),需要分两种情况讨论:
    • 若事务B的当前事务ID在事务A的事务ID列表中,代表创建事务A时事务B还是活跃的,未提交,所以事务B对于事务A来说是不可见的。
    • 若事务B的当前事务ID不在事务A的事务ID列表中,代表创建事务A时事务B已经提交,所以事务B对于事务A来说是可见的。

如果事务B对于事务A来说是不可见的,就需要顺着修改记录的版本链,从回滚指针开始往前遍历,直到找到第一个对于事务A来说是可见的事务ID,或者遍历完版本链也未找到(表示这条记录对事务A不可见)。

这就是 MVCC 的实现原理。

5.2 读视图的创建时机

这里需要注意的是读视图的创建时机,在上面的论述中我们已经知道事务在启动时会创建一个读视图(Read View),而开启一个事务有两种方式,一是 begin/start transaction,二是start transaction with consistent snapshot,通过这两种方式开启事务,创建读视图的时机也是不同的:

  • 如果是以 begin/start transaction 方式开启事务,读视图会在执行第一个快照读语句时创建
  • 如果以 start transaction with consistent snapshot 方式开启事务,同时便会创建读视图

5.3 MVCC 的运行过程

为了详细说明 MVCC 的运行过程,下面举个例子,假设当前存在有两个事务(事务隔离级别为 MySQL 默认的可重复读):

这里需要注意的是事务的启动时机,在上面的论述中我们已经知道事务在启动时会创建一个读视图(Read View),而开启一个事务有两种方式,一是 begin/start transaction,二是start transaction with consistent snapshot,通过这两种方式开启事务,创建读视图的时机也是不同的:

  • 如果是以 begin/start transaction 方式开启事务,读视图会在执行第一个快照读语句时创建
  • 如果以 start transaction with consistent snapshot 方式开启事务,同时便会创建读视图
时刻事务A事务B
1start transaction with consistent snapshot;
2 start transaction with consistent snapshot;
3 update user set name='重塑' where id=1;
4select name from user where id=1;(N1)
5 commit;
6select name from user where id=1;(N2)
7commit;

然后根据上面所描述的版本链以及两个事务开启时的读视图来分析 MVCC 的运行过程。

mvcc1-读视图

上图是两个事务开启时的读视图,而当事务B的更新语句执行之后,id=1行的版本链如下所示。

mvcc2-版本链

先来看N1处的查询语句,事务B的当前事务ID=2,其值等于事务A的下一个事务ID,所以按照上文中所论述的可见性判断,事务B对于事务A来说是不可见的,需要循着当前行的版本链网上检索。

于是循着版本链来到DB_TRX_ID=1事务ID=1的历史版本,恰巧等于事务A的事务ID值,也就是事务A开启时该行的版本,此版本对于事务A来说当然是可见的,所以读取到了id=1行的name='刺猬',即最终N1=刺猬。

再来看N2处的查询语句,此时事务B已提交,版本链还是如上图所示,由于当前版本的事务ID等于事务A读视图中的下一个事务ID,所以当前版本的记录对于事务A来说是不可见的,所以同样N2=刺猬。

这里需要注意的是,若例子中事务A的时刻4语句变更为对该行的更新语句,那么事务A便会等待事务B提交之后再执行更新语句,这是因为事务B未提交,即事务B对于id=1行的写锁未释放,而事务A也要更新该行,必须是更新当前的最新版本(当前读)才可以,所以事务A就被阻塞了,必须等待事务B对该行的写锁释放,才会继续执行更新语句。

5.4 RC 与 RR 生成读视图的时机对比

上面所讨论的 MVCC 运行过程都是针对可重复读(RR, Repeatable Read)隔离级别的,如果是读已提交(RC, Read Committed)级别呢?

上文中已经讨论过读已提交隔离级别中关于不可重复读的情况了,这里就不再举例,直接给出结论就可以了。

  • 可重复读(RR, Repeatable Read)隔离级别下生成读视图(Read View)的时机是开启事务的时候
  • 读已提交(RC, Read Committed)隔离级别下生成读视图(Read View)的时机是每一条语句执行前

对于上文中描述 MVCC 执行过程中的例子,如果隔离级别是读已提交(RC, Read Committed):

  • N1处的查询语句,由于事务B还未提交,事务A可见的版本依旧是事务ID=1的版本,所以N1=刺猬
  • N2处的查询语句,事务B已提交,N2处查询语句执行时也会生成读视图,其当前事务ID=3,而在该记录的版本链中,当前版本的事务ID DB_TRX_ID=2,在N2查询语句事务ID之前,是可见的,所以N2=重塑

5.5 当前读与快照读

  • 当前读:读取记录的最新版本
  • 快照读:读取记录时会根据一定规则读取事务可见版本的记录

5.6 可重复读发生幻读的原因

在理解了 MVCC 之后,我们再来看在可重复读隔离级别下发生幻读的原因。上文中说到正是由于当前读,才会在可重复读的隔离级别下发生幻读的情况,首先来回顾一下例子。

时刻事务A事务B
1begin;
2select name from user;(N1)
3 begin;
4 insert into user values(2, '五条人');
5 commit;
6select name from user;(N2)
7select name from user for update;(N3)
8commit;

N1,N2处的查询想必已经十分明确都是「刺猬」了。而在N3处所使用的查询语句是for update,使用它进行查询就会对目标记录添加一把「行级锁」,行级锁的意义以后再说,现在只需要知道for update能够锁住目标记录就可以了。

加锁自然是防止别人修改,那么理所当然,锁住的当然也就是记录的最新版本了。所以,在使用for update进行查询的时候,会使用当前读,读到目标记录的最新版本,所以在N3处的查询语句就会把事务B中本对于事务A来说不可见的记录也查询出来,也就发生了幻读。

使用当前读的语句有:

  • select ... for update
  • select ... lock in share mode(共享读锁)
  • update ...
  • insert ...
  • delete ...

6. 温故知新

  1. 事务的ACID四大特性
  2. 自动提交与隐式提交(隐式提交的语句)
  3. 事务的隔离级别
  4. 事务各个隔离级别可能出现的问题
  5. MVCC 的实现原理与过程
  6. RC 与 RR 生成读视图的时机

7. 参考资料