MySQL「10」幻读

97 阅读8分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 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 讲》中对幻读的问题从两方面进行了分析:

  1. 从语义上讲,幻读破坏了语句的语义。 结合上节中的例子,SELECT * FROM phantom_demo WHERE f2 < 20 FOR UPDATE; 的语义是锁住所有 f2 < 20 的行,不准别的事务对它们进行修改。 可是事务 T2 中的插入语句,破坏了这个语义。 为了更明显,可以在 T2 中增加如下语句,UPDATE phantom_demo SET f1 = f1 + 1 WHERE f2 = 8;。 T2 修改了 T1 想要锁住的(f2 < 20)的列,破坏了它的语义、它的加锁声明。 我们来初步分析下原因,语义被破坏的主要原因是,声明锁时,这条记录尚不存在,行锁可以锁住现有的行,却锁不住将来要插入的行。 所以,参考之前 中介绍的,InnoDB 引入了间隙锁来解决幻读。

  2. 从数据一致性上讲,幻读破坏了数据一致性。 数据库中设计锁机制的目的,除了为要保证数据一致性外,还为了要保证数据与日志之间的一致性。 继续沿用上节的例子。 假设我们在 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 INTO phantom_demo VALUES (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)

03-如何解决幻读?

先说结论,InnoDB 通过 MVCC(多版本并发控制)和间隙锁的方式,在很大程度上解决了可重复读(RR)隔离级别时幻读问题。

  1. 针对快照读(普通的 SELECT 语句),通过 MVCC 方式解决了幻读。事务执行过程中,看到的快照内容是一致的。
  2. 针对当前读(SELECT ... FOR UPDATE 语句),通过间隙锁的方式(准确点说是 next-key lock)解决幻读问题。

注:在可重复读级别下,幻读只是尽量避免了,并不是完全不会发生。 这个后面再讨论,现在只是记住这个结论就好了。

03.1-一致性读视图

在可重复读的事务隔离级别下,事务开始时,会创建一个一致性读视图,后续事务中查询都基于此视图。 一致性读视图(ReadView)由四部分组成:

  1. m_ids,生成 ReadView 时,活跃的读写事务的事务 id 组成的列表。
  2. min_trx_id,上述 m_ids 中的最小值,可表示为 min(m_ids)。 如果某个事务的事务 id 比当前值还小,说明肯定是已提交的事务。 为什么会这样,仔细看下 m_ids 的定义。(注,需要了解的是,事务 id 的大小与事务创建的时间相关的,id 越小,创建的越早)
  3. max_trx_id,生成 ReadView 时,系统中应该分配给下一个事务的事务 id。
  4. 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);
-- 阻塞,直到死锁检测到死锁后,插入成功

我们来分析一下上面的过程。

  1. 当 t1 时刻,SELECT ... FOR UPDATE 语句查询不到任何数据,会在主键 id 上增加间隙锁 (5, 10),组织其他事务在此区间插入记录。
  2. 当 t2 时刻,事务 T2 同样查询并增加 (5, 10) 的间隙锁,组织其他的事务插入,然后插入记录 (7, 8, 9)。因为有 T1 的间隙锁,所以 T2 的插入动作被阻塞。
  3. t3 时刻,事务 T1 尝试插入记录 (7, 8, 9),由于 T2 的间隙锁,插入失败。此时 T1 等待 T2 释放间隙锁以完成插入,T2 等待 T1 释放间隙锁完成插入,形成死锁。
  4. 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 UPDATELOCK IN SHARE MODE 锁住表,这样其他事务 T2 在插入数据时会被阻塞。 这样可以解决上述问题,同样会带来并发度降低的问题,需谨慎使用。