当数据库上有多个事务同时执行的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题,为了解决这些问题,就有了隔离级别的概念。

事务隔离级别

1. 事务并发执行遇到的问题

脏写(Dirty Write)
如果一个事务修改了另一个未提交事务修改过的数据,那就意味着发生了脏写,示意图如下:
image.png
如图,Session A 和 Session B 都对同一条记录进行了更新。如果之后 Session B 中的事务进行了回滚,那么 Session A 中的更新也将不复存在,这种现象就称为脏写。即对于 Session A 来说,明明更新了数据也提交了事务,最后它的更新却没有了。

脏读(Dirty Read)
如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读,示意图如下:
image.png
如图,Session B 中的事务先将 number 列为 1 的记录的 name 列更新为关羽,然后 Session A 中的事务再去查询这条 number 为 1 的记录,如果读到列 name 的值为关羽,而 Session B 中的事务稍后进行了回滚,那么 Session A 中的事务相当于读到了一个不存在的数据,这种现象就称之为脏读。

不可重复读(Non-Repeatable Read)
如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了不可重复读,示意图如下:
image.png
如图,我们在 Session B 中提交了几个隐式事务(隐式事务意味着语句结束事务就提交了),这些事务都修改了 number 列为 1 的记录的列 name 的值,每次事务提交之后,如果 Session A 中的事务都可以查看到最新的值,则这种现象被称为不可重复读。

幻读(Phantom)
如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读,示意图如下:
image.png
如图,Session A 中的事务先根据条件 number>0 查询出了一条记录,之后 Session B 提交了一个隐式事务,该事务向表 hero 中插入了一条新记录,之后 Session A 中的事务再根据相同的条件 number>0 查询得到的结果集中包含 Session B 中的事务新插入的那条记录,这种现象被称之为幻读。

如果 Session B 中是删除了一些符合 number>0 的记录而不是插入新记录,那 Session A 中第二次查询得到的记录就变少了,这种现象不属于幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录。对于先前已经读到但之后又读不到的情况,其实这相当于对每一条记录都发生了不可重复读的现象。幻读重点强调的是读取到了之前读取没有读到的记录。

2. 隔离级别

上边介绍了几种并发事务执行过程中可能遇到的问题,我们给这些问题按严重性来排一下序:

  1. 脏写 > 脏读 > 不可重复读 > 幻读

为了解决这些问题,有一帮人制定了一个所谓的 SQL 标准,在标准中设立了 4 个隔离级别。隔离级别就是在数据库事务中,为保证并发数据读写的正确性而提出的定义:

  • 读未提交(Read Uncommitted)就是一个事务能够看到其他事务尚未提交的修改,这是最低的事务隔离水平,允许脏读出现。


  • 读已提交(Read Committed),事务能够看到的数据都是其他事务已提交的修改,不会看到任何中间状态的数据。读已提交仍是比较低级别的隔离,并不保证再次读取时能够获取同样的数据,也就是允许其他事务并发修改数据,允许不可重复读和幻象读出现。


  • 可重复读(Repeatable Read),保证同一个事务中多次读取的数据是一致的。


  • 串行化(Serializable),并发事务之间是串行化的,意味着读取需要获取共享读锁,更新需要获取排他写锁,这是最高的隔离级别。

针对不同的隔离级别,并发事务可以发生不同严重程度的问题。但要注意,隔离级别越高,并发效率越低。因此很多时候,我们都要在二者之间寻找一个平衡点。

隔离级别 脏读 不可重复读 幻读
READ UNCOMMITTED(未提交读)
READ COMMITTED(已提交读)
REPEATABLE READ(可重复读)
SERIALIZABLE(可串行化)

不同数据库厂商对 SQL 标准中规定的四种隔离级别支持不一样,MySQL 虽然支持四种隔离级别,但与 SQL 标准中所规定的各级隔离级别允许发生的问题却有些出入,MySQL 在 REPEATABLE READ 隔离级别下是可以禁止幻读问题的发生(通过 Next-Key Lock 算法)。因此 MySQL 默认隔离级别为 REPEATABLE READ。
image.png
我们可以通过如下语句修改事务的隔离级别:

  1. SET [GLOBAL | SESSION] TRANSACTION ISOLATION LEVEL level;

如果不指定作用范围为 GLOBAL 或 SESSION ,则只会对执行语句后的下一个事务产生影响。

MVCC 原理

所谓的 MVCC(Multi-Version Concurrency Control,多版本并发控制)指的就是在使用 READ COMMITTD、REPEATABLE READ 这两种隔离级别的事务在执行普通的 SEELCT 操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。

1. 版本链

前面说过,对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列:

  • trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务 id 赋值给 trx_id 隐藏列。

  • roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修改前的信息。

假设,现在有两个事务 id 分别为 100、200 的事务进行如下 UPDATE 操作,操作流程如下:
image.png
每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有一个 old roll_pointer 属性(INSERT 操作对应的 undo 日志没有该属性,因为该记录并没有更早的版本)用来将这些针对同一条记录的 undo 日志串连成一个链表,具体如下图所示:
image.png
该记录每次更新后,都会将旧值放到一条 undo 日志中,算是该记录的一个旧版本,随着更新次数的增多,所有版本都会被 old roll_pointer 属性连成一个链表,我们把这个链表称为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务 id。

2. ReadView

对于使用 READ UNCOMMITTED 隔离级别的事务来说,由于可以读到未提交事务修改过的记录,所以直接读取记录的最新版本即可。对于使用 SERIALIZABLE 隔离级别的事务来说,InnoDB 使用加锁的方式来访问记录。

而对于使用 READ COMMITTEDREPEATABLE READ 隔离级别的事务来说,都必须保证读到已经提交了的事务修改过的记录,也就是说假如另一个事务已经修改了记录但尚未提交,是不能直接读取最新版本的记录的,因此需要判断一下版本链中的哪个版本是当前事务可见的。为此,InnoDB 提出一个 ReadView 的概念。不同时刻启动的事务会有不同的 ReadView,这个 ReadView 主要包含四个比较重要的内容:

  • m_ids:生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表,后面如果事务提交了,会在该集合中剔除这个已提交的事务 id。
  • min_trx_id:生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id,即 m_ids 中最小值。
  • max_trx_id:生成 ReadView 时系统中应该分配给下一个事务的 id 值。
  • creator_trx_id:生成该 ReadView 的事务的事务 id。

通过上面这四个属性,我们把 min_trx_id 值记为低水位,把 max_trx_id 值记为高水位。通过这个低水位值和高水位值以及 m_ids 视图数组,就组成了当前事务的一致性视图(ReadView)。
image.png
有了 ReadView 后,当前事务在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:

  • 如果被访问版本的 trx_id 值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。

  • 如果被访问版本的 trx_id 值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。

  • 如果被访问版本的 trx_id 值大于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问。

  • 如果被访问版本的 trx_id 值在 ReadView 的 min_trx_id 和 max_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,然后继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录。

在 MySQL 中,READ COMMITTED 和 REPEATABLE READ 隔离级别的一个很大区别就是它们生成 ReadView 的时机不同。下面我们看下这两个隔离级别生成 ReadView 的时机到底不同在哪里。

  • 在读提交(READ COMMITTED)隔离级别下,这个视图是在每一次普通 SELECT 操作前创建的。

  • 在可重复读(REPEATABLE READ)隔离级别下,这个视图是在第一次普通 SELECT 操作前创建的,之后的整个事务执行期间都用这个视图。

2.1 READ COMMITTED

我们还是以表 hero 为例,假设现在表 hero 中只有一条由事务 id 为 80 的事务插入的一条记录,并且当前系统里有两个事务 id 分别为 100、200 的事务正在执行:

  1. # Transaction 100
  2. BEGIN;
  3. UPDATE hero SET name = '关羽' WHERE number = 1;
  4. UPDATE hero SET name = '张飞' WHERE number = 1;
  5. # Transaction 200
  6. BEGIN;
  7. # 更新了一些别的表的记录...

此刻,表 hero 中 number 为 1 的记录得到的版本链表如下所示:
image.png
如果现在有一个使用 READ COMMITTED 隔离级别的事务开始执行:

  1. # 使用 READ COMMITTED 隔离级别的事务
  2. BEGIN;
  3. # SELECT1: Transaction100、200均未提交
  4. SELECT * FROM hero WHERE number = 1; # 得到的列name的值为刘备

这个 SELECT1 的执行过程如下:

  • 在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内容就是 [100, 200],min_trx_id 为 100,max_trx_id 为 201,creator_trx_id 为 0(因为这是一个查询操作)。

  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是张飞,该版本的 trx_id 值为 100,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。下一个版本的 trx_id 值也为 100,所以也不符合要求,继续跳到下一个版本。

  • 下一个版本的 trx_id 值为 80,小于 ReadView 中的 min_trx_id 值 100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为刘备的记录。

之后,我们把事务 id 为 100 的事务提交一下,然后再到事务 id 为 200 的事务中更新一下表 hero 中 number 为 1 的这条记录:

  1. # Transaction 100
  2. BEGIN;
  3. UPDATE hero SET name = '关羽' WHERE number = 1;
  4. UPDATE hero SET name = '张飞' WHERE number = 1;
  5. COMMIT;
  6. # Transaction 200
  7. BEGIN;
  8. # 更新了一些别的表的记录 ...
  9. UPDATE hero SET name = '赵云' WHERE number = 1;
  10. UPDATE hero SET name = '诸葛亮' WHERE number = 1;

此刻,表 hero 中 number 为 1 的记录的版本链就长这样:
image.png
然后再到刚才使用 READ COMMITTED 隔离级别的事务中继续查找这个 number 为 1 的记录,如下:

  1. # 使用READ COMMITTED隔离级别的事务
  2. BEGIN;
  3. # SELECT1: Transaction100、200均未提交
  4. SELECT * FROM hero WHERE number = 1; # 得到的列name的值为刘备
  5. # SELECT2: Transaction100提交、Transaction200未提交
  6. SELECT * FROM hero WHERE number = 1; # 得到的列name的值为张飞

这个 SELECT2 的执行过程如下:

  • 在执行 SELECT 语句时会又会单独生成一个 ReadView,该 ReadView 的 m_ids 列表的内容就是 [200],因为事务 id 为 100 的那个事务已经提交了,所以再次生成快照时就没有它了,min_trx_id 为 200,max_trx_id 为 201,creator_trx_id 为 0。

  • 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是诸葛亮,该版本的 trx_id 值为 200,在 m_ids 列表内,故不符合可见性要求,根据 roll_pointer 跳到下一个版本。下一个版本的 trx_id 值也为 200,所以也不符合要求,继续跳到下一个版本。

  • 下一个版本的 trx_id 值为 100,小于 ReadView 中的 min_trx_id 值 200,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为张飞的记录。

以此类推,如果之后事务 id 为 200 的记录也提交了,再次使用 READ COMMITTED 隔离级别的事务中查询这条记录时,得到的结果就是诸葛亮了。总结一下就是:使用 READ COMMITTED 隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView。

2.2 REPEATABLE READ

对于使用 REPEATABLE READ 隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView,之后的查询就不会重复生成了。比方现在系统里有两个事务 id 分别为 100、200 的事务在执行:

  1. # Transaction 100
  2. BEGIN;
  3. UPDATE hero SET name = '关羽' WHERE number = 1;
  4. UPDATE hero SET name = '张飞' WHERE number = 1;
  5. # Transaction 200
  6. BEGIN;
  7. # 更新了一些别的表的记录 ...

此刻,表 hero 中 number 为 1 的记录得到的版本链表如下所示:
image.png
假设现在有一个使用 REPEATABLE READ 隔离级别的事务开始执行:

  1. # 使用REPEATABLE READ隔离级别的事务
  2. BEGIN;
  3. # SELECT1: Transaction100、200未提交
  4. SELECT * FROM hero WHERE number = 1; # 得到的列name的值为刘备

这个 SELECT1 的执行过程如下:

  • 在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内容就是 [100, 200] ,min_trx_id 为 100,max_trx_id 为 201,creator_trx_id 为 0。

  • 然后从版本链中挑选可见的记录,直到找到版本的 trx_id 值为 80 的那个版本,因为其 trx_id 小于 ReadView 中的 min_trx_id 值 100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为刘备的记录。

之后,我们把事务 id 为 100 的事务提交一下,然后再到事务 id 为 200 的事务中更新一下该记录:

  1. # Transaction 100
  2. BEGIN;
  3. UPDATE hero SET name = '关羽' WHERE number = 1;
  4. UPDATE hero SET name = '张飞' WHERE number = 1;
  5. COMMIT;
  6. # Transaction 200
  7. BEGIN;
  8. # 更新了一些别的表的记录 ...
  9. UPDATE hero SET name = '赵云' WHERE number = 1;
  10. UPDATE hero SET name = '诸葛亮' WHERE number = 1;

此刻,表 hero 中 number 为 1 的记录的版本链就长这样:
image.png
然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 number 为 1 的记录:

  1. # 使用REPEATABLE READ隔离级别的事务
  2. BEGIN;
  3. # SELECT1: Transaction100、200均未提交
  4. SELECT * FROM hero WHERE number = 1; # 得到的列name的值为刘备
  5. # SELECT2: Transaction100提交、Transaction200未提交
  6. SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为刘备

这个 SELECT2 的执行过程如下:

  • 因为当前事务的隔离级别为 REPEATABLE READ,而之前在执行 SELECT1 时已经生成过 ReadView 了,所以此时直接复用之前的 ReadView。

  • 然后从版本链中挑选可见的记录,由于 ReadView 还是同一个,所以还是只能找到版本的 trx_id 值为 80 的那个记录才符合要求,最后返回给用户的还是这条列 name 为刘备的记录。

也就是说两次 SELECT 查询得到的结果是一样的,这就是可重复读的含义。