MySQL -- 幻讀
CREATE TABLE `t` ( `id` INT(11) NOT NULL, `c` INT(11) DEFAULT NULL, `d` INT(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) ) ENGINE=InnoDB; INSERT INTO t VALUES (0,0,0),(5,5,5),(10,10,10),(15,15,15),(20,20,20),(25,25,25);
定義與問題
定義
- 幻讀:在同一個事務內,前後兩次查詢 同一範圍 的時候,後一次查詢看到了前一次查詢沒有看到的行
- 幻讀專指 新插入的行
- 在 RR 隔離級別下, 普通查詢是快照讀 ,是看不到其他事務插入的資料的
- 幻讀僅在 當前讀 時才會出現
解決思路
只有行鎖
假設 SELECT * FROM t WHERE d=5 FOR UPDATE;
只會在 id=5
這一行上加 X Lock
,執行時序如下:
時刻 | session A | session B | session C |
---|---|---|---|
T1 | BEGIN; SELECT * FROM t WHERE d=5 FOR UPDATE; result:(5,5,5) |
||
T2 | UPDATE t SET d=5 WHERE id=0; UPDATE t SET c=5 WHERE id=0; |
||
T3 | SELECT * FROM t WHERE d=5 FOR UPDATE; result:(0,5,5),(5,5,5) |
||
T4 | INSERT INTO t VALUES (1,1,5); UPDATE t SET c=5 WHERE id=1; |
||
T5 | SELECT * FROM t WHERE d=5 FOR UPDATE; result:(0,5,5),(1,1,5),(5,5,5) |
||
T6 | COMMIT; |
-
T1
返回id=5
這1行 -
T3
返回id=0
和id=5
這2行-
id=0
不是幻讀,因為不是新插入的行
-
-
T5
返回id=0
、id=1
和id=5
的這三行-
id=1
是 幻讀 ,因為這是 新插入的行 - 顯然只有行鎖( RC )是無法解決幻讀問題的
-
幻讀的問題
破壞語義
- session A在
T1
時刻宣告:鎖住所有d=5
的行,不允許其他事務進行讀寫操作 - session B在
T2
時刻修改了id=0,d=5
這一行 - session C在
T4
時刻修改了id=1,d=5
這一行
破壞資料一致性
資料
時刻 | session A | session B | session C |
---|---|---|---|
T1 | BEGIN; SELECT * FROM t WHERE d=5 FOR UPDATE; UPDATE t SET d=100 WHERE d=5; |
||
T2 | UPDATE t SET d=5 WHERE id=0; UPDATE t SET c=5 WHERE id=0; |
||
T3 | SELECT * FROM t WHERE d=5 FOR UPDATE; | ||
T4 | INSERT INTO t VALUES (1,1,5); UPDATE t SET c=5 WHERE id=1; |
||
T5 | SELECT * FROM t WHERE d=5 FOR UPDATE; | ||
T6 | COMMIT; |
-
UPDATE
與SELECT...FOR UPDATE
的加鎖語義一致(X Lock
) -
T1
時刻,id=5
這一行變成了(5,5,100)
,在T6
時刻才正式提交 -
T2
時刻,id=0
這一行變成了(0,5,5)
-
T4
時刻,新插入了一行(1,5,5)
binlog
UPDATE t SET d=100 WHERE d=5;
UPDATE t SET d=5 WHERE id=0; -- (0,0,5) UPDATE t SET c=5 WHERE id=0; -- (0,5,5) INSERT INTO t VALUES (1,1,5); -- (1,1,5) UPDATE t SET c=5 WHERE id=1; -- (1,5,5) UPDATE t SET d=100 WHERE d=5; -- 所有d=5的行,d改成100
- 該binlog如果在備庫上執行,最終結果為
(0,5,100)
,(1,5,100)
,(5,5,100)
,id=0
和id=1
這兩行資料會與主庫不一致 - 原因:
SELECT * FROM t WHERE d=5 FOR UPDATE;
只給id=5
這一行X Lock
加強行鎖
增強為:掃描過程中 所有 碰到的行,都加上 X Lock
,執行序列如下
時刻 | session A | session B | session C |
---|---|---|---|
T1 | BEGIN; SELECT * FROM t WHERE d=5 FOR UPDATE; UPDATE t SET d=100 WHERE d=5; |
||
T2 | UPDATE t SET d=5 WHERE id=0;(blocked) UPDATE t SET c=5 WHERE id=0; |
||
T3 | SELECT * FROM t WHERE d=5 FOR UPDATE; | ||
T4 | INSERT INTO t VALUES (1,1,5); UPDATE t SET c=5 WHERE id=1; |
||
T5 | SELECT * FROM t WHERE d=5 FOR UPDATE; | ||
T6 | COMMIT; |
- session A把 所有的行 都加了
X Lock
,因此session B在執行第一個update語句時被鎖住了- 需要等到
T6
時刻,session A提交之後,session B才能繼續執行
- 需要等到
- 對於
id=0
這一行,在資料庫中的最終結果還是(0,5,5)
binlog
INSERT INTO t VALUES (1,1,5); -- (1,1,5) UPDATE t SET c=5 WHERE id=1; -- (1,5,5) UPDATE t SET d=100 WHERE d=5; -- 所有d=5的行,d改成100 UPDATE t SET d=5 WHERE id=0; -- (0,0,5) UPDATE t SET c=5 WHERE id=0; -- (0,5,5)
-
id=0
這一行的最終結果也是(0,5,5)
,因此id=0
這一行的資料是一致的 - 對於
id=1
這一行資料而言,在資料庫端的結果為(1,5,5)
,而根據binlog的執行結果是(1,5,100)
,資料不一致- 並且依然存在 幻讀
- 原因:只能給加鎖時存在的行加
X Lock
- 在
T3
時刻,在給所有的行加X Lock
時,此時id=1
這一行還不存在,因此也就加不上X Lock
了 - 即使在 所有的記錄 都加上了
X Lock
,依舊 阻止不了插入新紀錄
- 在
解決方案
Gap Lock
- 產生幻讀的原因:行鎖只能鎖住行,新插入記錄這個動作,要更新的是記錄之間的 間隙
- 為了解決幻讀,InnoDB引入了新的鎖: 間隙鎖 ( Gap Lock )
表初始化,插入了6個記錄,產生了7個間隙

-
SELECT * FROM t WHERE d=5 FOR UPDATE;
- 給已有的6個記錄加上
X Lock
,同時還會加上7個Gap Lock
,這樣就確保 無法再插入新紀錄
- 給已有的6個記錄加上
- 上鎖實體
- 資料行
- 資料行之間的間隙
衝突關係
行鎖
行鎖的衝突關係(跟行鎖有衝突關係的是 另一個行鎖 )
S Lock | X Lock | |
---|---|---|
S Lock | 相容 | 衝突 |
X Lock | 衝突 | 衝突 |
間隙鎖
跟 間隙鎖 存在衝突關係的是 往這個間隙插入一個記錄的操作 , 間隙鎖之間不會相互衝突
session A | session B |
---|---|
BEGIN; SELECT * FROM t WHERE c=7 LOCK IN SHARE MODE; |
|
BEGIN; SELECT * FROM t WHERE c=7 FOR UPDATE; |
- session B並不會被阻塞 ,因為表t裡面並沒有
c=7
的記錄- 因此session A加的是 間隙鎖
(5,10)
,而session B也是在這個間隙加間隙鎖 - 兩個session有共同的目標: 保護這個間隙,不允許插入值,但兩者之間不衝突
- 因此session A加的是 間隙鎖
Next-Key Lock
- 間隙鎖和行鎖合稱
Next-Key Lock
,每個Next-Key Lock
都是 左開右閉 區間 -
SELECT * FROM t WHERE d=5 FOR UPDATE;
形成了7個Next-Key Lock
,分別是-
(-∞,0],(0,5],(5,10],(10,15],(15,20],(20,25],(25,+supremum]
-
+supremum
:InnoDB給每一個索引加的一個 不存在的最大值supremum
-
- 約定:
Gap Lock
為 左開右開 區間,Next-Key Lock
為 左開右閉 區間
可能死鎖
-- 併發執行 -- 死鎖並不是大問題,回滾重試即可 BEGIN; SELECT * FROM t WHERE id=N FOR UPDATE; -- 如果行不存在 INSERT INTO t VALUES (N,N,N); -- 如果行存在 UPDATE t SET d=N SET id=N; COMMIT;
session A | session B |
---|---|
BEGIN; SELECT * FROM t WHERE id=9 FOR UPDATE; |
|
BEGIN; SELECT * FROM t WHERE id=9 FOR UPDATE; |
|
INSERT INTO t VALUES (9,9,9);(blocked) | |
INSERT INTO t VALUES (9,9,9);(Deadlock fund) |
- session A執行
SELECT * FROM t WHERE id=9 FOR UPDATE;
,id=9
這一行不存在,會加上 間隙鎖(5,10)
- session B執行
SELECT * FROM t WHERE id=9 FOR UPDATE;
,間隙鎖之間不衝突,同樣會加上 間隙鎖(5,10)
- session B試圖插入一行
(9,9,9)
,被session A的間隙鎖阻塞 - session A試圖插入一行
(9,9,9)
,被session B的間隙鎖阻塞,兩個session相互等待,形成 死鎖- InnoDB的 死鎖檢測 很快就會發現死鎖,並讓session A的insert語句 報錯返回
- 解決方案:假如 只有一個唯一索引 ,可以用
INSERT ... ON DUPLICATE KEY UPDATE
來替代
小結
- 引入
Gap Lock
,會導致同樣的語句 鎖住更大的範圍 , 影響併發度 -
Gap Lock
是在 RR 隔離級別下才生效的(在 RC 隔離級別是沒有Gap Lock
的) - 解決 資料與日誌不一致 的另一個方案:RC + binlog_format=row
- 如果 RC (沒有
Gap Lock
,鎖範圍更小)隔離級別夠用,業務並不需要可重複讀的保證,可以選擇RC
- 如果 RC (沒有
參考資料
《MySQL實戰45講》
轉載請註明出處:http://zhongmingmao.me/2019/02/14/mysql-phantom/
訪問原文「MySQL -- 幻讀」獲取最佳閱讀體驗並參與討論