一例MySQL的insert死鎖

原文鏈接:https://fanyilun.me/2022/03/09/%E4%B8%80%E4%BE%8BMySQL%E7%9A%84insert%E6%AD%BB%E9%94%81/

 分享一個最近遇到的一例MySQL死鎖。關於MySQL的鎖,幾年前寫過一篇原理類的文章,基礎知識建議移步MySQL加鎖分析

背景

  我們使用MySQL實現了一個通用的分佈式DB鎖,建表語句如下:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `tbl_lock` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵',
`gmt_create` datetime NOT NULL COMMENT '創建時間',
`gmt_modified` datetime NOT NULL COMMENT '修改時間',
`biz_type` varchar(32) NOT NULL COMMENT '鎖的業務類型',
`biz_key` varchar(255) NOT NULL COMMENT '鎖的唯一key',
`host_name` varchar(64) DEFAULT NULL COMMENT '持有鎖的主機',
`expire_time` datetime DEFAULT NULL COMMENT '超時時間',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_biz_type_key` (`biz_key`,`biz_type`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='DB鎖表'

  每一次鎖的過程,就是先insert一條記錄,再執行業務邏輯,最後delete這條記錄。爲了實現悲觀鎖,我們把整個流程放入了事務裏,這樣可以保證其他會話鎖等待而不是報唯一鍵衝突的錯誤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
begin;
## 加鎖
INSERT INTO tbl_lock (
id,
gmt_create,
gmt_modified,
biz_type,
biz_key,
host_name,
expire_time
)
VALUES (
null, NOW(), NOW(),'FLOW_INSTANCE', 'LCG-16463618958170A24',
'MacBook-Pro-10.local', '2022-03-04 15:06:43.576'
);
## do something...
(...)
## 釋放鎖
DELETE
FROM tbl_lock
WHERE biz_key = 'LCG-16463618958170A24'
AND biz_type = 'FLOW_INSTANCE';
commit;

  單論分佈式鎖的實現,通常建議使用redis、zk、etcd之類的方案。之所以使用了這種db分佈式鎖的實現方式,主要是實現簡單,不需要引入其他組件。而且網上也有一些文章,這種方式實現db分佈式鎖還是比較常見的。

死鎖案例

  這種DB分佈式鎖使用了一段時間後,發現在併發高的情況下會出現死鎖。

com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction

  死鎖發生後,可以直接執行 show engine innodb status; 查看最近的死鎖日誌,爲了便於理解加了一些註釋:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
------------------------
LATEST DETECTED DEADLOCK
------------------------
2022-03-08 18:03:08 140288584578816
*** (1) TRANSACTION:
TRANSACTION 227612, ACTIVE 10 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 3 row lock(s), undo log entries 1
MySQL thread id 271, OS thread handle 140289841530624, query id 143790 140.205.147.81 zizhuo update
INSERT INTO tbl_lock (
id,
gmt_create,
gmt_modified,
biz_type,
biz_key,
host_name,
expire_time
)
VALUES (
null, NOW(), NOW(),'FLOW_INSTANCE', 'LCG-16463618958170A24',
'MacBook-Pro-10.local', '2022-03-04 15:06:43.576'
)

*** (1) HOLDS THE LOCK(S):
// 鎖加在名爲uk_biz_type_key的索引,也就是我們建的二級唯一索引
// lock mode S代表的是S NEXT-KEY LOCK,MySQL日誌裏沒有"but not gap"字樣就代表LOCK_ORDINARY類型的NEXT-KEY LOCK,鎖的是當前記錄+記錄之前一個Gap
RECORD LOCKS space id 6 page no 5 n bits 72 index uk_biz_type_key of table `zizhuo_test`.`tbl_lock` trx id 227612 lock mode S
// 間隙鎖的位置,由於表中沒有數據,鎖到無窮大的位置
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

// 行鎖的位置,就是插入的唯一索引的位置(FLOW_INSTANCE,LCG-16463618958170A24)
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
0: len 21; hex 4c43472d3136343633363138393538313730413234; asc LCG-16463618958170A24;;
1: len 13; hex 464c4f575f494e5354414e4345; asc FLOW_INSTANCE;;
2: len 8; hex 000000000000000d; asc ;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
// 等待獲取X insert intention lock,插入意向鎖,也是一種間隙鎖,這個間隙和上面的gap重疊了
RECORD LOCKS space id 6 page no 5 n bits 72 index uk_biz_type_key of table `zizhuo_test`.`tbl_lock` trx id 227612 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

// 事務2的鎖和事務1一模一樣,就不解釋了
*** (2) TRANSACTION:
TRANSACTION 227616, ACTIVE 7 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 3 row lock(s), undo log entries 1
MySQL thread id 272, OS thread handle 140288893617920, query id 143812 140.205.147.81 zizhuo update
INSERT INTO tbl_lock (
id,
gmt_create,
gmt_modified,
biz_type,
biz_key,
host_name,
expire_time
)
VALUES (
null, NOW(), NOW(),'FLOW_INSTANCE', 'LCG-16463618958170A24',
'MacBook-Pro-10.local', '2022-03-04 15:06:43.576'
)

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 6 page no 5 n bits 72 index uk_biz_type_key of table `zizhuo_test`.`tbl_lock` trx id 227616 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
0: len 21; hex 4c43472d3136343633363138393538313730413234; asc LCG-16463618958170A24;;
1: len 13; hex 464c4f575f494e5354414e4345; asc FLOW_INSTANCE;;
2: len 8; hex 000000000000000d; asc ;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 6 page no 5 n bits 72 index uk_biz_type_key of table `zizhuo_test`.`tbl_lock` trx id 227616 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

*** WE ROLL BACK TRANSACTION (2)

  兩條insert語句死鎖,單獨看上去也有點不可思議。兩個insert事務各自都持有唯一索引記錄的S Next-key鎖,並且都想要進一步獲取X insert intention lock的時候,因爲gap lock和insert intention lock互相沖突,造成死鎖。
  結合使用場景,我們嘗試了幾次,復現了死鎖發生的過程(其中insert和delete語句和上面的例子完全一致,每個事務都在插入相同的unique key):

trx1trx2trx3狀態
begin; begin; begin;  
INSERT INTO tbl_lock …;     trx1執行成功
  INSERT INTO tbl_lock …;   trx2等待trx1的鎖
    INSERT INTO tbl_lock …; trx3等待trx1的鎖
DELETE FROM tbl_lock …;     trx1執行成功
commit;     trx1提交成功;trx2和trx3形成死鎖
× ×  

  查了一下,這是一種經典的MySQL死鎖場景,MySQL 8.0 Reference Manual :: 15.7.3 Locks Set by Different SQL Statements in InnoDB[1]:三個事務同時insert,第一個事務回滾會造成後兩個事務死鎖。
  根據官方文檔的敘述,insert加鎖流程如下:

  • 先在插入的索引間隙裏,加insert intention gap lock
  • 如果存在重複key衝突,給衝突的索引加S鎖。
  • 如果不存在重複key衝突,給插入的記錄加X record lock

  額外解釋一下,insert intention gap lock(插入意向鎖)的作用。首先,插入意向鎖可以理解成一種特殊的gap lock,不要和intention lock搞混。爲了防止幻讀,MySQL使用了Gap Lock(Next-Key Lock也包含Gap Lock,以下均使用Gap Lock表述)來給索引的間隙加鎖,例如select * from my_table where id>7 for update;就會給(7,+∞)的聚簇索引加Gap Lock。Gap Lock的作用就是阻止其他事務向鎖住的gap裏插入數據,因此Gap Lock只和insert intention gap lock相沖突,這樣在Gap Lock存在期間,insert語句就會通過加insert intention gap lock這種方式,鎖等待來避免幻讀。同樣是加在間隙的鎖,爲什麼把Gap Lock和insert intention gap lock區分開?其實insert直接加Gap Lock也可以實現避免幻讀,但是鎖衝突就變大了,insert intention gap lock的區分設計就是爲了提高併發插入的性能,因爲insert intention gap lock之間相互不衝突,如innodb-insert-intention-locks文檔所述。

  之前也提過,MySQL在RR隔離級別會通過Gap Lock避免幻讀,RC隔離級別理論上不需要Gap Lock,但是其他場景如唯一索引校驗也會用到Gap Lock,所以在RC級別依然有insert intention gap lock,也就依然會出現本文中的死鎖場景。就比如上面提到的insert加鎖流程第二步,給衝突索引加的S鎖,實際上,如果是聚簇索引RC隔離級別,這個S鎖就是普通的record lock行鎖;聚簇索引RR隔離級別,加next-key lock;但是如果是二級唯一索引,無論是RC還是RR隔離級別,都是加next-key lock[2]。

  所以我也試了一下,如果衝突的不是二級索引,而是利用聚簇索引來做DB鎖的key會怎麼樣。其實MySQL官網舉的例子就是用的聚簇索引,一樣會出現死鎖,只不過鎖衝突就不是在s next-key lock和insert intention gap lock間隙鎖之間了,而是在S locks rec but not gap和X locks rec but not gap行鎖之間了。

  另外,這個insert加鎖流程也是爲了便於理解簡化過的,實際innodb實現過程要更復雜,在不存在鎖衝突的情況下,insert本身不會加鎖(或者叫隱式鎖)[3],具體就不深究了。

  最後再梳理一下這個死鎖的過程:

trx1trx2trx3
begin; begin; begin;
INSERT INTO tbl_lock …;    
二級索引持有X record lock(通過日誌查看此時實際並沒有加insert intention lock)    
  INSERT INTO tbl_lock …;  
  發現唯一鍵衝突,嘗試獲取S next-key lock  
    INSERT INTO tbl_lock …;
    發現唯一鍵衝突,嘗試獲取S next-key lock
DELETE FROM tbl_lock …;    
標記刪除記錄,並不釋放鎖    
commit;    
事務提交,釋放所有鎖    
  獲取到S next-key lock  
    獲取到S next-key lock,因爲S鎖是共享鎖,兩個trx都可以獲取
  嘗試獲取X insert intention lock,與trx3的next-key lock衝突  
    嘗試獲取X insert intention lock,與trx2的next-key lock衝突

  關於這個現象,早在2009年就有report:MySQL Bugs: #43210: Deadlock detected on concurrent insert into same table (InnoDB),但僅僅解釋了一下原因,然後修改了文檔說明,從此以後一直到MySQL8.0,這個死鎖案例始終出現在官方手冊裏,看起來官方並不認爲這是bug而是feature。對於我們開發者來說就比較棘手,只能避免此類寫法。例如本文中的分佈式鎖,即使不放在事務裏,悲觀鎖改成樂觀鎖,delete語句與兩個insert語句同時執行,依然會出現死鎖。看起來MySQL只適合根據不同的業務邏輯,採用select … for update的方式針對性加鎖。當然,從性能和其他角度考慮,最好不要用MySQL實現通用的分佈式鎖。

參考資料

[1] https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html
[2] https://www.bookstack.cn/read/aliyun-rds-core/4adfb6141be60032.md
[3] https://www.aneasystone.com/archives/2018/06/insert-locks-via-mysql-source-code.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章