谈谈 MySQL 事务的隔离性

事务就是一组数据库操作,它具有原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability),简称为 ACID。本文将介绍 MySQL 事务的隔离性以及对其的思考。
尽管这是一个老生常谈的话题,网上也有很多相关的资料,但是要理解它并不容易。即使林晓斌老师在 《MySQL 实战 45 讲》 中用了两个章节进行介绍,但是你在评论区中会发现有些分享或讨论的观点彼此矛盾。原因可能有很多,比如为了易于理解使用简化概念进行分析,有些具体细节各人各执一词同时它们又不好通过测试进行验证,用词不严谨等等。本文尽可能为自己梳理出一个完善并且前后一致的认知体系,再针对一些容易引起误解的地方作进一步的说明。

隔离级别

SQL 标准的事务隔离级别包括:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(serializable)。当多个事务同时执行时,不同的隔离级别可能发生脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)等一个或多个现象。隔离级别越高,效率越低,因此很多时候,我们需要在二者之间寻找一个平衡点。

隔离级别 脏读 不可重复读 幻读
读未提交 Y Y Y
读提交 N Y Y
可重复读 N N Y
串行化 N N N

读未提交和串行化很少在实际应用中使用。

通过以下示例说明隔离级别的影响,V1V2V3 在不同隔离级别下的值有所不同。

事务 A 事务 B 读未提交 读提交 可重复读 串行化
开启事务 开启事务
查询得到值 1
查询得到值 1
修改值为 2
查询得到值 V1 2(读到B未提交的修改) 1 1 1
提交事务
查询得到值 V2 2 2(读到B已提交的修改) 1 1
提交事务
查询得到值 V3 2 2 2(A在事务期间数据一致) 1
补充说明 B的修改阻塞至A提交

通过测试验证以上结论可以帮助你更直观地感受隔离级别的作用:

  • 新建连接 mysql –h localhost –u root -P 3306 –p
  • 查看会话的事务隔离级别 show variables like 'transaction_isolation';
  • 设置会话的事务隔离级别 set session transaction isolation level read uncommitted|read committed|repeatable read|serializable;
  • 测试和验证
1
2
3
4
5
6
mysql> show variables like 'transaction_isolation';
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+

5.7 引入了 transaction_isolation 作为 tx_isolation 的别名,8.0.3 废弃后者。

了解数据库的隔离级别及其影响对于理解自身正在使用的数据库的行为、根据业务场景设置隔离级别优化性能以及迁移数据都是有帮助的。Oracle 数据库的默认隔离级别是“读提交”,MySQL 的默认隔离级别是“可重复读”。

事务隔离的实现

MySQL 中,事务隔离是通过 lockundo logread view 共同协作实现的。很多时候,我们关注 MVCC 在“读提交”和“可重复读”隔离级别中的作用而忽视事务隔离和锁的关系。

MySQL 各个事务隔离级别的实现原理简述如下:

  • 串行化:读加共享锁,写加排他锁,读写互斥
  • 读未提交:写加排他锁,读不加锁
  • 可重复读:第一次读操作时创建快照,基于该快照进行读取
  • 读提交:每次读操作时重置快照,基于该快照进行读取

前两者通过锁(lock)实现比较容易理解;后两者通过多版本并发控制(MVCC)实现。MVCC 是一种实现非阻塞并发读的设计思路,在 InnoDB 引擎中主要通过 undo logread view 实现。

以下示意图表现了在 InnoDB 引擎中,同一行数据存在多个“快照”版本,这就是数据库的多版本并发控制(MVCC),当你基于快照读取时可以获得旧版本的数据。

  • 假设一个值从 1 按顺序被修改为 2、3、4,最新值为 4。
  • 事务将基于各自拥有的“快照”读取数据而不受其他事务更新的影响,也不阻塞其他事务的更新。

在接下来我们将通过锁、事务 ID、回滚日志和一致性视图逐步介绍 InnoDB 事务隔离的实现原理。

锁(lock)

事务在本质上是一个并发控制问题,而锁是解决并发问题的常见基础方案。MySQL 正是通过共享锁排他锁实现串行化隔离级别。但是读加共享锁影响性能,尤其是在读写冲突频繁时,若不加发生“脏读”的缺陷又比较大,MVCC 就是用于在即使有读写冲突的情况下,不加读锁实现非阻塞并发读。

InnoDB 的事务中,行锁(共享锁或排他锁)是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放,这个就是两阶段锁协议

理解两阶段锁协议,你会更深地体会读写冲突频繁时锁对性能的影响以及 MVCC 的作用。长事务可能导致一个锁被长时间持有,导致拖垮整个库。

事务 ID

InnoDB 引擎中,每个事务都有唯一的一个事务 ID,叫做 transaction id。它是在事务开始的时候向 InnoDB 的事务系统申请的,是按申请顺序严格递增的。同时每一行数据有一个隐藏字段 trx_id,记录了插入或更新该行数据的事务 ID

创建事务的时机

事务启动方式如下:

  1. 显式启动事务语句是 beginstart transaction,配套的提交语句是 commit,回滚语句是 rollback
  2. 隐式启动事务语句是 set autocommit = 0,该设置将关闭自动提交。当你执行 select,将自动启动一个事务,直到你主动 commitrollback

但注意,实际上不论是显式启动事务情况下的 beginstart transaction,还是隐式启动事务情况下的 commitrollback 都不会立即创建一个新事务,而是直到第一次操作 InnoDB 表的语句执行时,才会真正创建一个新事务

可以通过以下语句查看当前“活跃”的事务进行验证:

1
select * from information_schema.innodb_trx;

只读事务的事务 ID 和更新事务不同。

可以使用 commit work and chain; 在提交的同时开启下一次事务,减少一次 begin; 指令的交互开销。

回滚日志(undo log)

InnoDB 引擎中,每条记录在更新的时候都会同时记录一条回滚操作。记录的最新值,通过回滚操作,可以得到之前版本的值。它的作用是:

  • 数据回滚:当事务回滚或者数据库崩溃时,通过 undolog 进行数据回滚。
  • 多版本并发控制:当读取一行记录时,如果该行记录已经被其他事务修改,通过 undo log 读取之前版本的数据,以此实现非阻塞并发读。

实际上,每一行数据还有一个隐藏字段 roll_ptr。很多相关资料简单地描述“roll_ptr 用于指向该行数据的上一个版本”,但是该说法容易让人误解旧版本的数据是物理上真实存在的,好像有一张链表结构的历史记录表按顺序记录了每一个版本的数据。

有些资料会特地强调旧版本的数据不是物理上真实存在的,undo log 是逻辑日志,记录了与实际操作语句相反的操作,旧版本的数据是通过 undo log 计算得到的。

说实话,在不了解细节的前提下,通过计算得到旧版本的数据更加反直觉。总而言之,InnoDB 的数据总是存储最新版本,尽管该版本所属的事务可能尚未提交;任何事务其实都是从最新版本开始回溯,直到获得该事务认为可见的版本。

回滚日志的删除时机

回滚日志不会一直保留,在没有事务需要的时候,系统会自动判断和删除。基于该结论,我们应该避免使用长事务。长事务意味着系统里面可能会存在很老的 read view,这些事务可能访问数据库里的任何数据,所以在这个事务提交之前,数据库里它可能用到的回滚日志都必须保留,这就会导致大量存储空间被占用。在 MySQL 5.5 及之前的版本中,回滚日志是和数据字典一起放在 ibdata 文件里的,即使长事务最终提交,回滚段被清理,但只是代表那部分存储空间可复用,文件并不会变小,需要重建整个库才能解决问题。

一致性视图(read view)

一致性读视图(read view)又可以称之为快照(snapshot),它是基于整库的,但是它并不是真的拷贝了整个数据库的数据,否则随着数据量的增长,显然无法实现秒级创建快照。read view 可以理解为发出一个声明:“以我创建的时刻为准,如果一个数据版本所属的事务是在这之前提交的,就可见;如果是在这之后提交的,就不可见,需要回溯上一个版本判断,重复直到获得可见的版本;如果该数据版本属于当前事务自身,是可见的”。

以上声明类似于功能的需求描述,它比具体实现更简洁和易于理解。

“快照”结合“多版本”等词,和 undo log 的情况类似很容易让人误解为有一个物理上真实存在的数据快照,但实际上 read view 只是在沿着数据版本链回溯时用于判断该版本对当前事务是否可见的依据。在具体实现上,InnoDB 为每一个事务构造了一个数组用于保存创建 read view 时,当前正在“活跃”的所有事务 ID ,其中“活跃”指的是启动了但尚未提交。数组中事务 ID 的最小值记为低水位,当前系统里面已经创建过的事务 ID 的最大值加 1 记为高水位。这个数组和高水位就组成了当前事务的一致性视图(read view)。对于当前事务的 read view 而言,一个数据版本的 trx_id,有以下几种可能:

  • 如果小于低水位,表示这个版本是已提交的事务生成的,可见
  • 如果大于等于高水位,表示这个版本是创建 read view 之后启动的事务,不可见
  • 如果大于等于低水位且小于高水位
    • 如果这个版本的 trx_id 在数组中,表示这个版本是已启动但尚未提交的事务生成的,不可见
    • 如果这个版本的 trx_id 不在数组中,表示这个版本是已提交的事务生成的,可见

InnoDB 利用“所有数据都有多个版本,每个版本都记录了所属事务 ID”这个特性,实现了“秒级创建快照”的能力。有了这个能力,系统里面随后发生的更新,就和当前事务可见的数据无关了,当前事务读取时也不必再加锁。

以上“具体实现”相较于之前的“需求描述”显得有些啰嗦和复杂,然而这里的细节是值得推敲的。即便是林晓斌老师在《MySQL 实战 45 讲》中的详细讲解也让部分读者包括我本人感到困惑。

林晓斌老师的数据版本可见性示意图如下,容易让人产生误解的地方在于三段式的划分给人一种已提交的事务全都是小于低水位的错觉

事实上,已提交事务的分布可能如下,大部分人的疑问其实只是“在大于等于低水位小于高水位的范围中,为什么会有已提交的事务”。

要理解该问题需要理解另外一个问题——“创建 read view 的时机”。

创建 read view 的时机

很多资料介绍“可重复读”隔离级别下的 read view 创建时机为在事务启动时,但这并不严谨,还会导致理解 read view 数组困难。创建事务并不等于创建 read view

官方文档:With REPEATABLE READ isolation level, the snapshot is based on the time when the first read operation is performed. With READ COMMITTED isolation level, the snapshot is reset to the time of each consistent read operation.

  • 对于“读提交”隔离级别,每次读操作都会重置快照。这意味着只要当前事务持续足够长的时间,它最后读取时完全可能熬到在它之前甚至之后创建的事务提交。
  • 对于“可重复读”隔离级别,在第一次执行快照读时创建快照。这意味着当前事务可以执行很多次以及很久的 update 语句后再执行读取,熬到在它之前甚至之后创建的事务提交。

有些人可能想到了前者,但对于后者存疑或者不知道如何验证,其实测试并不复杂:

事务 A 事务 B
begin; begin;
update t set k = 2 where id = 2;(创建事务)
update t set k = 666 where id = 1;(创建事务)
commit;
select * from t where id = 1;(创建 read view,k = 666)
commit;

因此,严谨地说,创建事务的时机和创建一致性视图的时机是不同的。通过 start transaction with consistent snapshot; 可以在开启事务的同时立即创建 read view

当前读和快照读

现在我们知道在 InnoDB 引擎中,一行数据存在多个版本。MVCC 使得在“可重复读”隔离级别下的事务好像与世无争。但是在以下示例中,事务 B 是在事务 A 的一致性视图之后创建和提交的,为什么事务 A 查询到的 k 为 3 呢?

事务 A 事务 B
start transaction with consistent snapshot;(k = 1)
update t set k = k + 1 where id = 1;(自动提交事务)
update t set k = k + 1 where id = 1;当前读
select * from t where id = 1;(k = 3)
commit;

其实,更新数据是先读后写的,并且是“当前读”。

  • 当前读:读取一行数据的最新版本,并保证在读取时其他事务不能修改该行数据,因此需要在读取时加锁。以下操作属于当前读的情况:
    • 共享锁:select lock in share mode
    • 排他锁:select for updateupdateinsertdelete
  • 快照读:在不加锁的情况下通过 select 读取一行数据,但和“读未提交”隔离级别下单纯地读取最新版本不同,它是基于一个“快照”进行读取。

因此在事务 A 中更新时,读取到的是事务 B 更新后的最新值,在事务 A 更新后,依据 read view 的可见性原则,它可以看到自身事务的更新后的最新值 3。

如果事务 B 尚未提交的情况下,事务 A 发起更新,会如何呢?这时候就轮到“两阶段锁协议”派上用场了:

  • 事务 B 在更新时,对改行数据加排他锁,在事务 B 提交时才会释放
  • 当事务 A 发起更新,将阻塞直到事务 B 提交
事务 A 事务 B
start transaction with consistent snapshot;(k = 1)
begin;
update t set k = k + 1 where id = 1;排他锁
update t set k = k + 1 where id = 1;阻塞至 B 提交
commit;
select * from t where id = 1;(k = 3)
commit;

至此,我们将锁和 MVCC 在事务隔离的实现原理中串联起来了。两者是互相独立又互相协作的两个机制,前者实现了“当前读”,后者实现了“快照读”。

总结

卡壳好几天,想到有不少好的文章却仍然会给读者留下困惑,想到自己在当初学习时对一些不严谨的表达抓耳挠腮想不通为什么,就有点不知道如何下笔。最终围绕着自己当初的一些困惑,一点一点修修补补完了。

参考文章