MySQL 可重复读隔离级别,完全解决幻读了吗?
MySQL 可重复读隔离级别,完全解决幻读了吗?
MySQL InnoDB引擎的可重复读隔离级别虽然在大多数情况下能有效避免幻读,但仍然存在特定场景下无法完全避免幻读的问题。本文将通过具体场景和代码示例,深入探讨MySQL在可重复读隔离级别下处理幻读的机制及其局限性。
前言
MySQL InnoDB引擎的默认隔离级别是「可重复读」,它通过两种方式来解决幻读问题:
针对快照读(普通 select 语句),通过MVCC(多版本并发控制)方式解决了幻读。在可重复读隔离级别下,事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,即使中途有其他事务插入了一条数据,也是查询不出来的。
针对当前读(select … for update 等语句),通过 next-key lock(记录锁+间隙锁)方式解决了幻读。当执行 select … for update 语句的时候,会加上 next-key lock,如果有其他事务在 next-key lock 锁范围内插入了一条记录,那么这个插入语句就会被阻塞,无法成功插入。
尽管如此,这两种解决方案还是无法完全避免所有幻读场景。
第一个发生幻读现象的场景
以一张空表作为例子:
事务 A 执行查询 id = 5 的记录,此时表中是没有该记录的,所以查询不出来。
# 事务 A
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t_stu where id = 5;
Empty set (0.01 sec)
然后事务 B 插入一条 id = 5 的记录,并且提交了事务。
# 事务 B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into t_stu values(5, '小美', 18);
Query OK, 1 row affected (0.00 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)
此时,事务 A 更新 id = 5 这条记录,虽然事务 A 看不到 id = 5 这条记录,但更新操作仍然可以执行。之后再次查询 id = 5 的记录,事务 A 就能看到事务 B 插入的纪录了,这就是幻读现象。
在可重复读隔离级别下,事务 A 第一次执行普通的 select 语句时生成了一个 ReadView,之后事务 B 向表中新插入了一条 id = 5 的记录并提交。接着,事务 A 对 id = 5 这条记录进行了更新操作,在这个时刻,这条新记录的 trx_id 隐藏列的值就变成了事务 A 的事务 id,之后事务 A 再使用普通 select 语句去查询这条记录时就可以看到这条记录了,于是就发生了幻读。
这种特殊现象的存在说明,MySQL Innodb 中的 MVCC 并不能完全避免幻读现象。
第二个发生幻读现象的场景
除了上述场景,还有另一种情况也会导致幻读:
- T1 时刻:事务 A 先执行「快照读语句」:select * from t_test where id > 100 得到了 3 条记录。
- T2 时刻:事务 B 往插入一个 id= 200 的记录并提交;
- T3 时刻:事务 A 再执行「当前读语句」 select * from t_test where id > 100 for update 就会得到 4 条记录,此时也发生了幻读现象。
要避免这类特殊场景下发生幻读的现象,建议在开启事务之后,马上执行 select … for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。
总结
MySQL InnoDB 引擎的可重复读隔离级别(默认隔离级),根据不同的查询方式,分别提出了避免幻读的方案:
- 针对快照读(普通 select 语句),是通过 MVCC 方式解决了幻读。
- 针对当前读(select … for update 等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了幻读。
但是,这两种方案仍然存在局限性:
- 对于快照读, MVCC 并不能完全避免幻读现象。当事务 A 更新了一条事务 B 插入的记录,那么事务 A 前后两次查询的记录条目就不一样了,所以就发生幻读。
- 对于当前读,如果事务开启后,并没有执行当前读,而是先快照读,然后这期间如果其他事务插入了一条记录,那么事务后续使用当前读进行查询的时候,就会发现两次查询的记录条目就不一样了,所以就发生幻读。
因此,MySQL 可重复读隔离级别并没有彻底解决幻读,只是很大程度上避免了幻读现象的发生。要避免这类特殊场景下发生幻读的现象,建议在开启事务之后,马上执行 select … for update 这类当前读的语句,因为它会对记录加 next-key lock,从而避免其他事务插入一条新记录。