开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第10天,点击查看活动详情
幻读是多事务并发中常见的一种现象,也是面试中经常考查的一个问题。 今天我们就一块来学习一下 InnoDB 是如何避免幻度的。
01-幻读是什么
MySQL 官方文档中对幻读(Phantom Read)的定义如下:
The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. For example, if a SELECT is executed twice, but returns a row the second time that was not returned the first time, the row is a “phantom” row.
从上述定义中,我们可以总结一下幻读现象的几个关键点:
- “within a transaction”,指发生在某个事务中,假设其为 T。
- “same query”、“at different times”、“produces different sets”,指同一个语句,执行多次,产生了不同的结果。
在《MySQL 实战 45 讲》中有提到,幻读实际上专指“新插入的行”,也就是说在一个事务中,同样条件的查询语句执行多次,后面执行的结果中,包含了之前结果中不存在的行。 这些行被称为是 Phantom(幻影)行。
注:Phantom 这个词在许多编程语言中都有出现过,例如 Rust、Java 中。 该词一般翻译为幽灵、幻影等。
01.1-幻读示例
以 phantom_demo 表为例,我们来直观地感受下幻读是什么。
CREATE TABLE `phantom_demo` (
`id` int(11) NOT NULL,
`f1` int(11) DEFAULT NULL,
`f2` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_f1` (`f1`)
) ENGINE=InnoDB;
INSERT INTO `phantom_demo`
VALUES(0, 1, 2),
(5, 6, 7),
(10, 11, 12),
(15, 16, 17),
(20, 21, 22),
(25, 26, 27),
(30, 31, 32);
假设我们有一个事务 T1,在事务期间,执行的语句如下:
-- 事务 T1
BEGIN ;
-- t1
SELECT * FROM `phantom_demo` WHERE f2 < 20 FOR UPDATE;
-- 结果有 4 条记录
-- t3
SELECT * FROM `phantom_demo` WHERE f2 < 20 FOR UPDATE;
-- 结果有 5 条记录
COMMIT ;
同时,存在一个事务 T2,它在 t1 时刻之后,t3 时刻之前执行如下语句:
-- 事务 T2
-- t2,且 t1 < t2 < t3
INSERT INTO `phantom_demo` VALUES (6, 7, 8);
在事务 T1 中,t3 执行的结果中包含了 t1 时执行同样的语句而结果中不包含的行 6, 7, 8。
这行就被称为是幻影行。
02-幻读有什么问题
《MySQL 实战 45 讲》中对幻读的问题从两方面进行了分析:
-
从语义上讲,幻读破坏了语句的语义。 结合上节中的例子,
SELECT * FROMphantom_demoWHERE f2 < 20 FOR UPDATE;的语义是锁住所有f2 < 20的行,不准别的事务对它们进行修改。 可是事务 T2 中的插入语句,破坏了这个语义。 为了更明显,可以在 T2 中增加如下语句,UPDATE phantom_demo SET f1 = f1 + 1 WHERE f2 = 8;。 T2 修改了 T1 想要锁住的(f2 < 20)的列,破坏了它的语义、它的加锁声明。 我们来初步分析下原因,语义被破坏的主要原因是,声明锁时,这条记录尚不存在,行锁可以锁住现有的行,却锁不住将来要插入的行。 所以,参考之前 锁 中介绍的,InnoDB 引入了间隙锁来解决幻读。 -
从数据一致性上讲,幻读破坏了数据一致性。 数据库中设计锁机制的目的,除了为要保证数据一致性外,还为了要保证数据与日志之间的一致性。 继续沿用上节的例子。 假设我们在 T1 中的 t1 时刻增加一条语句
UPDATE phantom_demo set f1 = 30 WHERE f2 < 20;,这样做在语义上是合理的。 在它之前,我们先声明“帮我锁住所有 f2 < 20 的行,我要修改它们的值了”,然后用UPDATE语句修改它们。 我们来分析以下,修改后的 T1 和 T2 执行的结果:- 首先,T1 中的 t1 时刻,会在 binlog 中记录
UPDATE phantom_demo set f1 = 30 WHERE f2 < 20; - 然后,T2 执行,插入一条记录
INSERT INTOphantom_demoVALUES (6, 7, 8); - 最后,T1 中的 t2 时刻,查询完毕后,提交事务。这两个事务产生的 binlog 组合在一起是:
INSERT INTO `phantom_demo` VALUES (6, 7, 8); -- 先提交 UPDATE phantom_demo set f1 = 30 WHERE f2 < 20; -- t2 时刻后提交如果拿着上述的 binlog 去创建备库,那么就会导致主、备库不一致,主库为
(6, 7, 8),备库为(6, 30, 8)。 - 首先,T1 中的 t1 时刻,会在 binlog 中记录
03-如何解决幻读?
先说结论,InnoDB 通过 MVCC(多版本并发控制)和间隙锁的方式,在很大程度上解决了可重复读(RR)隔离级别时幻读问题。
- 针对快照读(普通的
SELECT语句),通过 MVCC 方式解决了幻读。事务执行过程中,看到的快照内容是一致的。 - 针对当前读(
SELECT ... FOR UPDATE语句),通过间隙锁的方式(准确点说是 next-key lock)解决幻读问题。
注:在可重复读级别下,幻读只是尽量避免了,并不是完全不会发生。 这个后面再讨论,现在只是记住这个结论就好了。
03.1-一致性读视图
在可重复读的事务隔离级别下,事务开始时,会创建一个一致性读视图,后续事务中查询都基于此视图。 一致性读视图(ReadView)由四部分组成:
- m_ids,生成 ReadView 时,活跃的读写事务的事务 id 组成的列表。
- min_trx_id,上述 m_ids 中的最小值,可表示为 min(m_ids)。 如果某个事务的事务 id 比当前值还小,说明肯定是已提交的事务。 为什么会这样,仔细看下 m_ids 的定义。(注,需要了解的是,事务 id 的大小与事务创建的时间相关的,id 越小,创建的越早)
- max_trx_id,生成 ReadView 时,系统中应该分配给下一个事务的事务 id。
- creator_trx_id,创建当前 ReadView 的事务的事务 id。
如何判断某条记录的版本链中,哪个版本对当前事务可见呢?
- 如果版本中的 DB_TRX_ID 与 creator_trx_id 相等,说明该版本是当前事务修改产生的,对当前事务可见;
- 如果 DB_TRX_ID < min_trx_id,说明该版本是一个已提交事务修改产生的,且在 ReadView 创建之前就已提交,当前事务可见。
- 如果 DB_TRX_ID >= max_trx_id,说明该版本是当前 ReadView 创建之后开启的事务修改产生的,对当前事务不可见。
- 如果 DB_TRX_ID 在 [min_trx_id, max_trx_id),则需要根据情况判断:
- 如果 DB_TRX_ID 在 m_ids 中,说明该版本是由创建当前 ReadView 时仍活跃的(未提交的)事务修改产生的,当前事务不可见;
- 否则,DB_TRX_ID 不在 m_ids 中,说明该版本是由创建当前 ReadView 时已提交的事务修改产生的,当前事务可见。
注:有些地方也称 max_trx_id、min_trx_id 为高、低水位
03.2-间隙锁
继续沿用之前的表 phantom_demo。
插入记录7条记录后,产生了8个间隙(Gap)、或区间,如下:
(-infimum, 0)|(0, 5)|(5, 10)|(10, 15)|(15, 20)|(20, 25)|(25, 30)|(30, +supremum)
间隙锁的语义就是,不允许向间隙中插入记录。
所以,当某个事务持有一个间隙锁时,其他事务尝试向间隙内插入记录时,会被阻塞,直到持有间隙锁的事务提交后,释放间隙锁。
与行锁冲突的是另外一个行锁,比如 X 锁与另一个 S 锁,或另一个 X 锁。 与间隙锁冲突的是向间隙内插入记录的操作,间隙锁之间不冲突,而且间隙锁无所谓 X 锁、S 锁。
间隙锁和行锁结合起来就是 next-key lock。
间隙锁、next-key 锁的引入,虽然很大程度上解决了幻读,但也带来了其他的问题,例如某些场景下会导致死锁。
继续沿用之前的 phantom_demo 表,考虑如下两个事务:
-- INSERT INTO `phantom_demo` VALUES(0, 1, 2), (5, 6, 7), (10, 11, 12), (15, 16, 17);
-- 事务 T1
BEGIN ;
-- t1 时刻
SELECT * FROM `phantom_demo` WHERE id = 7 FOR UPDATE ;
-- t3 时刻
INSERT INTO `phantom_demo` VALUES (7, 8, 9);
-- 死锁,触发死锁检测,
-- 事务 T2
-- 时刻 t2
BEGIN ;
SELECT * FROM `phantom_demo` WHERE id = 7 FOR UPDATE ;
INSERT INTO `phantom_demo` VALUES (7, 8, 9);
-- 阻塞,直到死锁检测到死锁后,插入成功
我们来分析一下上面的过程。
- 当 t1 时刻,
SELECT ... FOR UPDATE语句查询不到任何数据,会在主键 id 上增加间隙锁(5, 10),组织其他事务在此区间插入记录。 - 当 t2 时刻,事务 T2 同样查询并增加
(5, 10)的间隙锁,组织其他的事务插入,然后插入记录(7, 8, 9)。因为有 T1 的间隙锁,所以 T2 的插入动作被阻塞。 - t3 时刻,事务 T1 尝试插入记录
(7, 8, 9),由于 T2 的间隙锁,插入失败。此时 T1 等待 T2 释放间隙锁以完成插入,T2 等待 T1 释放间隙锁完成插入,形成死锁。 - T1 阻塞后,会发起死锁检测,然后 T1 失败,释放间隙锁,死锁解除,T2 能够插入成功。
间隙锁、next-key 锁的引入,虽然很大程度上解决了幻读,但是语句锁住的范围变大,会降低事务的并发度。
InnoDB 中可重复读、读提交这两个事务隔离级别都是基于 MVCC 实现的。 所以,系统中同时存在可重复读(一般用于备份)、读提交两个级别是没问题的。
03.3-查看语句加锁情况
performance_schema.data_locks 表中存储了当前系统中事务执行过程中的持锁信息。
在事务执行过程中,可以通过 select * from performance_schema.data_locks\G; 查询事务的锁信息。
例如:
*************************** 1. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 2052896135832:1076:2052863195672
ENGINE_TRANSACTION_ID: 114303
THREAD_ID: 53
EVENT_ID: 69
OBJECT_SCHEMA: mydb
OBJECT_NAME: t
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 2052863195672
LOCK_TYPE: TABLE
LOCK_MODE: IX
LOCK_STATUS: GRANTED
LOCK_DATA: NULL
*************************** 2. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 2052896135832:19:4:4:2052863192888
ENGINE_TRANSACTION_ID: 114303
THREAD_ID: 53
EVENT_ID: 69
OBJECT_SCHEMA: mydb
OBJECT_NAME: t
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 2052863192888
LOCK_TYPE: RECORD
LOCK_MODE: X,REC_NOT_GAP
LOCK_STATUS: GRANTED
LOCK_DATA: 10
2 rows in set (0.00 sec)
ERROR:
No query specified
其中,INDEX_NAME 指明锁加在哪个索引上; LOCK_TYPE 为锁类型,其值 RECORD 指行锁,TABLE 指表级锁; LOCK_MODE X 表示为独占锁,S 指共享锁,默认为 next-key lock,GAP 指间隙锁,REC_NOT_GAP 指记录锁; LOCK_DATA 是索引信息,如果 INDEX_NAME 为 PRIMARY 时,此处值为主键;如果 INDEX_NAME 为其他索引的,则此处值为索引列+主键。
04-幻读被彻底解决了吗
前面介绍了两种方式,MVCC 和 间隙锁、next-key 锁两种方式来尽量避免幻读。 但可重复读级别下,InnoDB 并没有彻底的消除幻读。
04.1-场景一
-- t1
BEGIN ; -- T1
SELECT * FROM `phantom_demo` WHERE id > 15;
/**
+----+------+------+
| id | f1 | f2 |
+----+------+------+
| 20 | 21 | 22 |
| 25 | 26 | 27 |
+----+------+------+
*/
-- t3
UPDATE `phantom_demo` SET f1 = f1 + 1 WHERE id = 30;
SELECT * FROM `phantom_demo` WHERE id > 15; -- 发生了幻读
/*
+----+------+------+
| id | f1 | f2 |
+----+------+------+
| 20 | 21 | 22 |
| 25 | 26 | 27 |
| 30 | 32 | 32 |
+----+------+------+
*/
-- t2
BEGIN ; -- T2
INSERT INTO phantom_demo VALUES (30, 31, 32);
COMMIT ;
- t1 时刻,查询
phantom_demo表格,只有两条记录; - t2 时刻,事务 T2 插入一条记录,并提交。按照 MVCC 一致性读视图,T1 不应该读取到这条记录。
- t3 时刻,事务 T1 直接根据 id 修改 T2 插入的记录,记录中的 trx_id 变为 T1。再次执行查询语句,可以看到 T2 插入的记录,发生幻读。
由此可见,MVCC 并不能完全杜绝幻读的发生。
04.2-情形二
注:这种场景我不确定算不算是幻读,在这里我也列一下,然后分析下背后的原因。
-- t1
BEGIN ; -- T1
SELECT * FROM `phantom_demo` WHERE id > 15;
/**
+----+------+------+
| id | f1 | f2 |
+----+------+------+
| 20 | 21 | 22 |
| 25 | 26 | 27 |
+----+------+------+
*/
-- t3
SELECT * FROM `phantom_demo` WHERE id > 15 for update;
/**
+----+------+------+
| id | f1 | f2 |
+----+------+------+
| 20 | 21 | 22 |
| 25 | 26 | 27 |
| 30 | 31 | 32 |
+----+------+------+
*/
-- t2
BEGIN ;
INSERT INTO `phantom_demo` VALUES (30, 31, 32);
COMMIT ;
- t1 时刻,事务 T1 通过快照读查询处2条记录。
- t2 时刻,事务 T2 插入一条数据,并提交。按照 MVCC 一致性读视图,T1 看不到 T2 插入的这条记录。
- t3 时刻,事务 T1 使用当前读,读到了 T1 插入的数据。
如何避免上述问题,这里有个解决思路:T1 事务开始时,就用 FOR UPDATE 或 LOCK IN SHARE MODE 锁住表,这样其他事务 T2 在插入数据时会被阻塞。
这样可以解决上述问题,同样会带来并发度降低的问题,需谨慎使用。