脏读、不可重复读、幻读总结

  • 脏读

假设此时数据库有一行数据 a = 1,事务 A 进行更新操作,将 a = 1 更新为 a = 2,当然该事务 A 未提交,此时事务 B 查询该行数据得到 a = 2,接着,事务 B 拿着刚才查询到的 a = 2 值做各种业务处理。但是,事务 A 进行了数据回滚,导致刚才更新的 a 值由 2 变回了 1。然后事务 B 再次查询该行数据时,得到的是 a = 1。这就是脏读

  • 不可重复读

在讲不可重复读之前,要求不会发生脏读。因为脏读是指事务 A 可以读到事务 B 修改过但还没提交的数据,此时事务 B 一旦回滚,事务 A 再次读就读不到了,这种情况叫脏读。现在我们假设事务 A 只能在事务 B 提交之后才能读到它修改的数据,即不会发生脏读的情况。

那么此时就会出现另一个问题,叫做不可重复读。现在数据库里有一行数据 a = 1,此时事务 A 开启之后,第一次查询这行数据,读到的是 a = 1。接着事务 B 更新了这行数据变为 a = 2,同时事务 B 立刻提交了,注意,此时事务 A 是没提交的,它在事务执行期间第二次查询该行数据,此时查到的是事务 B 修改的值,a = 2,因为事务 B 已经提交了,所以事务 A 可以读到。

紧接着事务 C 再次更新该行数据为 a = 3,并且同样提交了事务,当然此时事务 A 还是没提交,第三次查询该行数据,查到的是 a = 3。至此,事务 A 在执行期间进行了多次查询。

那么这样一个场景有什么问题呢?对于事务 A 来说,它想要的效果是在事务 A 执行过程中不管查询多少次,查询到的数据都和第一次查询的结果一样,叫该行数据可重复读。但是现在三次查询结果都不一样,第一次读得到 a = 1,第二次读得到 a = 2,第三次读得到 a = 3,叫该行数据不可重复读

所以希望数据是【可重复读】还是【不可重复读】,都取决于用户(使用者)希望此时的数据库是什么样子。

  • 幻读

事务 A 中进行第一次批量查询时,得到 10 条数据。接着,来了一个事务 B 向表中插入了几条数据,同时事务 B 还提交了。此时,事务 A 中进行第二次批量查询,按照第一次批量查询一摸一样的条件语句执行,结果查出了 12 条数据,比之前多了 2 条数据。这就是幻读

数据库为什么会出现脏读、不可重复读、幻读的问题?本质是数据库的多事务并发问题。为了解决多事务并发问题,数据库才设计了事务隔离级别、MVCC 多版本并发控制、锁机制。

相关阅读:(18 封私信 / 16 条消息) 大白话讲解脏写、脏读、不可重复读和幻读 – 知乎

事务隔离级别总结

  • READ-UNCOMMITTED(读未提交):最低的隔离级别,允许读取尚未提交的数据变更,可能会导致脏读、不可重复读、幻读。这种级别因为对保证数据一致性的能力太弱而很少使用。
  • READ-COMMITTED(读已提交):允许事务 A 读取事务 B 已经提交的数据,对于未提交的数据无法读取,可以阻止脏读,但是不可重复读和幻读还是可能发生的。
  • REPEATABLE-READ(可重复读):一个事务内对同一个字段的多次读,结果都是一致的。可以阻止脏读和不可重复读,但幻读可能发生。MySQL InnoDB 存储引擎的默认隔离级别是 REPEATABLE-READ(可重复读)。并且,InnoDB 在此级别下通过快照读(原理是 MVCC——多版本并发控制)和当前读(原理是 Next-Key Locks 间隙锁+行锁)两种方式,在很大程度上解决了幻读问题。(注意:InnoDB 存储引擎下可重复读隔离级别并不能完全解决幻读问题,后续会单独讲一下)
  • SERIALIZABLE(可串行化):最高的隔离级别,完全服从 ACID 的隔离级别。并发事务下,每个事务都会按顺序依次执行,保证事务之间完全不会产生干扰,该级别可以防止脏读、不可重复读以及幻读。

InnoDB 的 REPEATABLE-READ(可重复读)对幻读的处理:

InnoDB 存储引擎通过以下两种机制最大程度上避免了幻读:

  • 快照读(Snapshot Read):普通的 SELECT 语句,通过 MVCC 机制实现。事务启动时创建一个数据快照,后续的快照读都读取这个版本的数据,从而避免了看到其他事务新插入的行(幻读)或修改的行(不可重复读)。
  • 当前读(Current Read):像 SELECT … FOR UPDATE,SELECT … LOCK IN SHARE MODE,INSERT,UPDATE,DELETE 这些 SQL 语句。InnoDB 使用 Next-Key Lock(间隙锁+行锁)来锁定扫描到的索引记录(数据行)及其间的范围(间隙)(锁住的范围后续会单独讲一下),防止其它事务在这个范围内插入新的记录,从而避免幻读。Next-Key Lock 是行锁(Record Lock)和间隙锁(Gap Lock)的组合。

为什么说 InnoDB 下可重复读隔离级别并不能完全解决幻读问题?

先说结论:有两种特殊情况会出现幻读现象。

  • 特殊情况一:

假设此时表中总共有 4 条数据,id = 1,id = 2,id = 3,id = 4。事务 A 执行快照读查询 id =5 的记录,此时表中没有该行记录,所以查询得到 0 条数据。然后事务 B 插入一条 id = 5 的记录,并且事务完成提交。此时,事务 A 更新 id = 5 这条记录(这种场景非常少见,很不正常,本身事务 A 就查不到 id = 5 这条记录,还要去更新这条记录),事务 A 再次查询 id = 5 的记录时就能看到事务 B 插入的这条 id = 5 的记录,出现了幻读。

时序图如下:

事务 A 事务 B
Begin;
select * from t_test where id = 5; // 没有任何输出
Begin;
insert into t_test values(5, ‘小美’,18);
Commit;
update t_test set name = ‘梦塔世界’ where id = 5;
select * from t_test where id = 5; // 输出 id = 5 的记录
  • 特殊情况二:

事务 A 先进行一次范围查询(快照读) SELECT * FROM t_test WHERE id > 100;得到了 3 个数据。然后事务 B 向表中插入一个 id = 200 的数据并提交,事务 A 再执行范围查询(当前读)SELECT * FROM t_test WHERE id > 100 FOR UPDATE;就会得到 4 个数据,此时也发生了幻读现象。

时序图如下:

事务 A事务 B
Begin;
SELECT * FROM t_test WHERE id > 100; // 输出 3 条数据
Begin;
insert into t_test values(200,’梦塔世界’,25);
Commit;
SELECT * FROM t_test WHERE id > 100 FOR UPDATE; // 输出 4 条数据

从本质上讲,情况一和情况二都是既用了快照读又用了当前读。具体来看,情况一中对 id = 5 这条数据首次查询时用的快照读,然后又进行更新 UPDATE 操作时用的当前读,与情况二中先进行范围快照读,然后再进行范围当前读(FOR UPDATE 也是先 UPDATE)相同。总结来说就是,可重复读隔离级别下,一旦快照读和当前读混合使用,就会出现幻读。

解决方法:要避免这种幻读现象,最好的解决方法就是在开启事务时,要么只执行快照读,要么只执行当前读,不要混合起来使用。如果一定要使用 Update 操作,就保证查询只用当前查 SELECT … FOR UPDATE.

当前读(Current Read)中 Next-Key Lock 锁定的是哪些范围?

从朋友那里学到的,在这里要感谢我的同学,谢谢他的分享。

当前读 Next-Key Lock 加锁规则里,包含了两个“原则”、两个“优化”和一个“bug”。

  • 原则1:加锁的基本单位是 Next-Key Lock。Next-Key Lock 是左开右闭区间。
  • 原则2:查找过程中对访问过的对象才会加 Next-Key Lock 。
  • 优化1:索引上的等值查询,给唯一索引加锁时,Next-Key Lock 退化为行锁。
  • 优化2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,Next-Key Lock 退化为间隙锁。
  • 一个bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

下面例子展示关于覆盖索引上的锁:

表 t_test 中一共有 id,c,d 三个字段,其中 id 是主键索引,c 和 d 是覆盖索引。同时数据库里只有 id = 5 和 id = 10 这两条数据。

时序图如下:

事务 A事务 B事务 C
Begin;
select id from t_test where c = 5
for update;
Begin;
update t_test set d=d+1 where id = 5;(执行成功
commit;

insert into t_test values(7, 7, 7);
执行失败

分析:事务 A 要对 c = 5 这一行数据进行当前读(c 是普通索引)

1.根据原则1,加锁单位是 Next-Key Lock,因此会给(0,5] 加上 Next-Key Lock。

2.因为 c 是覆盖索引,不是唯一索引,因此仅访问 c = 5 这一条记录是不能马上停下来的,需要向右遍历,查到最后一个不满足等值条件的值为 c = 10 才放弃。根据原则2,访问到的数据都要加锁,因此要给(5,10] 加 Next-Key Lock。

3.但是同时这个符合优化2:等值判断,向右遍历,最后一个值不满足 c = 5 这个等值条件,因此退化成间隙锁(5,10)。

4.根据原则2,只有访问到的对象才会加锁,事务 A 查询使用的是覆盖索引 c,并不需要访问主键索引,所以主键索引上没有加任何锁,因此事务 B 才会执行成功。

5.事务 C 是想要插入 c = 7 ,但是在(5,10)区间内加入了 Next-Key Lock,所以插入执行失败。

解决幻读的方法

虽然在可重复读隔离级别下,幻读还是有可能会发生,但是发生的场景太少见了(可以说非常少见),所以总的来说可重复读还是一定程度上解决了幻读。

解决幻读的方式有很多,核心思想就是要求一个事务在操作某张表数据的时候,另一个事务不允许新增或者删除这张表中的数据。解决幻读的方式主要有以下几种:

  • 将事务隔离级别调整为 SERIALIZABLE;
  • 在可重复读的事务级别下,只使用快照读(从效果上看就是给这张表加表锁);
  • 在可重复读的事务级别下,只是用当前读(给这张表加 Next-Key Lock)。

相关阅读:

MySQL事务隔离级别详解 | JavaGuide

(18 封私信 / 22 条消息) MySQL 可重复读隔离级别,彻底解决幻读了吗? – 知乎

Categories:

Tags:

No responses yet

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注