引言
昨天又碰到了一個死鎖的問題。
印象中也是在去年一月份,一個月的時間里連續(xù)碰到好幾次死鎖,當時還花了不少時間向DBA取經(jīng)學習,然后接下來一年的時間相安無事,直到昨天,所以似乎一月與死鎖更配?
好,先看看當時拿到的死鎖日志:
LATEST DETECTED DEADLOCK
------------------------
2020-01-17 10:54:43 0x7e65e2a9a700
*** (1) TRANSACTION:
TRANSACTION 2680591430, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 7 lock struct(s), heap size 1136, 6 row lock(s), undo log entries 2
MySQL thread id 5886047, OS thread handle 138974565865216, query id 899537643 db_user update
INSERT INTO t VALUES (1,12,' aaaaaa',1,1579229683,1579229683)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2528547 page no 15403 n bits 792 index idx_a_b of table ` db`.`t` trx id 2680591430 lock_mode X locks gap before rec insert intention waiting
*** (2) TRANSACTION:
TRANSACTION 2680591431, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
7 lock struct(s), heap size 1136, 6 row lock(s), undo log entries 2
MySQL thread id 5886128, OS thread handle 138976059565824, query id 899537680 db_user update
INSERT INTO t VALUES (2, 23,' aaaaaa',1,1579229683,1579229683)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 2528547 page no 15403 n bits 792 index idx_a_b of table ` db`.`t` trx id 2680591431 lock_mode X locks gap before rec
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2528547 page no 15403 n bits 792 index idx_a_b of table ` db`.`t` trx id 2680591431 lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (2)
分析死鎖日志,不難發(fā)現(xiàn)死鎖的發(fā)生是由于并發(fā)insert引起的,兩個事務都持有gap鎖,然后同時申請插入意向鎖導致,因此這是一個典型的由gap鎖和插入意向鎖導致的死鎖案例。
不過前司DBA同學曾經(jīng)跟我說過一句話:99.99%的死鎖場景都可以通過構造數(shù)據(jù)來復現(xiàn),如果復現(xiàn)不了,大概率是你分析錯了。
因此為了驗證自己的猜測,我們不妨就根據(jù)當時死鎖發(fā)生的業(yè)務場景來復現(xiàn)一下。不過在此之前,我們先簡單了解下gap鎖和插入意向鎖的基本概念。
Gap鎖
MySQL官網(wǎng)對gap鎖的介紹是這樣的Gap Locks:
A gap lock is a lock on a gap between index records, or a lock on the gap before the first or after the last index record.
簡單來說,gap鎖是加在索引區(qū)間的鎖,或者是第一條記錄之前、最后一條記錄之后,不包括記錄本身。它的主要目的就是用來防止幻讀的發(fā)生。
gap鎖+行鎖就構成了所謂的Next-key鎖,這也是在RR隔離級別下的基本加鎖單位。
插入意向鎖
同樣,先看下MySQL官網(wǎng)對插入意向鎖的介紹Insert Intention Locks:
An insert intention lock is a type of gap lock set by
INSERToperations prior to row insertion. This lock signals the intent to insert in such a way that multiple transactions inserting into the same index gap need not wait for each other if they are not inserting at the same position within the gap.
也就是說,插入意向鎖其實也是一種gap鎖,在執(zhí)行insert操作時產(chǎn)生。它的主要目的就是讓多個事務在同一區(qū)間內(nèi)插入不同索引值時不用互相等待,提高插入效率。
這里多說一句:我們知道還有一種意向鎖,意向鎖是表鎖。但是插入意向鎖本質(zhì)上是gap鎖而不是意向鎖,所以插入意向鎖是行鎖而不是表鎖,千萬別被這個名字所誤導了。
場景復現(xiàn)
版本:MySQL 5.7
隔離級別:RR
建表語句:
CREATE TABLE `t1` (
`id` int(10) NOT NULL AUTO_INCREMENT,
`a` int(10) DEFAULT NULL,
`b` int(10) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_a_b` (`a`,`b`)
) ENGINE=InnoDB AUTO_INCREMENT=52 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
insert into t1(a, b) values (1, 1);
insert into t1(a, b) values (10, 1);
業(yè)務場景簡化后大致如下:
| T1 | T2 | |
|---|---|---|
| t1 | begin; | begin; |
| t2 | update t1 set b = 0 where a = 5; | |
| t3 | update t1 set b = 0 where a = 6; | |
| t4 | insert into t1(a, b) values (5, 1); blocked | |
| t5 | insert into t1(a, b) values (6, 1); dead lock | |
| t6 | commit; | commit; |
按上圖時序執(zhí)行后,意料之中發(fā)生了死鎖:

查看死鎖日志如下:
------------------------
LATEST DETECTED DEADLOCK
------------------------
2020-01-18 11:57:45 0x70000ca6a000
*** (1) TRANSACTION:
TRANSACTION 430276, ACTIVE 56 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 25, OS thread handle 123145515114496, query id 439 localhost root update
insert into t1(a, b) values (5, 1)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 27 page no 4 n bits 880 index idx_a_b of table `test`.`t1` trx id 430276 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 583 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 4; hex 80000001; asc ;;
2: len 4; hex 80000029; asc );;
*** (2) TRANSACTION:
TRANSACTION 430277, ACTIVE 51 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 26, OS thread handle 123145514557440, query id 440 localhost root update
insert into t1(a, b) values (6, 1)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 27 page no 4 n bits 880 index idx_a_b of table `test`.`t1` trx id 430277 lock_mode X locks gap before rec
Record lock, heap no 583 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 4; hex 80000001; asc ;;
2: len 4; hex 80000029; asc );;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 27 page no 4 n bits 880 index idx_a_b of table `test`.`t1` trx id 430277 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 583 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 4; hex 80000001; asc ;;
2: len 4; hex 80000029; asc );;
*** WE ROLL BACK TRANSACTION (2)
不難發(fā)現(xiàn),我們復現(xiàn)場景的死鎖日志與線上環(huán)境看到的死鎖日志完全一樣,這說明我們之前對死鎖原因的猜測是正確的。按照事務執(zhí)行順序我們逐條分析下:
t2時刻:事務T1視圖更新一條不存在的記錄。之前我們說過,在RR隔離級別下,加鎖的基本單位是Next-key鎖,因此,t2時刻執(zhí)行完,事務T1持有gap鎖(1, 10]。
t3時刻:因為gap之間是不會相互阻塞的,因此t3時刻執(zhí)行完,事務T2同樣持有了gap鎖(1, 10]。
t4時刻:事務T1嘗試插入a=5的記錄,插入操作在相應的行記錄a=5上加上插入意向鎖,但是因為事務T2持有了gap鎖(1, 10],而gap是會阻塞插入意向鎖的,所以此時T1阻塞,等待事務T2釋放gap鎖(1, 10]。
t5時刻:同理,事務T2會嘗試對相應的行記錄a=6加插入意向鎖,但同樣的原因被阻塞,等待事務T1釋放gap鎖(1, 10]。
由此,兩個事務T1、T2都在等待對方釋放持有的gap鎖,循環(huán)等待發(fā)生,導致死鎖。
場景回歸
回到我們實際的業(yè)務場景,在t3、t4時刻實際上是大量的并發(fā)插入,每個事務可能都是十幾萬甚至幾十萬的插入操作。因此實際上這里我們就已經(jīng)違反了一條事務的最佳實踐:
- 盡量避免大事務
所以解決方案應該是盡量把這個超大事務拆小,減小每個事務持有鎖的時間。
當然,基于我們的業(yè)務場景,并行事務其實很少,而且事務還是處于一個異步操作內(nèi),所以還有一個更加簡單可行的方案是使用分布式鎖。當然這也只能說是一個次優(yōu)方案,畢竟單個事務里插入幾十萬條記錄,還是很不推薦的。