之前有一個同事問到我,為什么多個線程同時去做刪除同一行數據的操作,老是報死鎖,在線上已經出現好多次了,我問了他幾個問題:
CREATE TABLE `abcdefg` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`abc` varchar(30),
`def` varchar(30) ,
`ghi` date,
`jkl` date,
`mnp` tinyint(4),
PRIMARY KEY (`id`),
UNIQUE KEY `uniqdefghijkl` (`def`,`ghi`,`jkl`)
);
1. 是不是在一個事務中做了好幾件事情?
答:不是,只做一個刪除操作,自動提交
2. 有多少個線程在做刪除?
答:差不多10個
3. 是什么隔離級別?
答:可重復讀
當時覺得不可思議,按說自動提交的話行鎖,如果已經有事務加鎖了,則會等待,等提交之后再去做,發現已經刪除了,就會返回,刪除0條,為什么會死鎖?
但事情已經出了,必須研究一下,不然終究是心頭之苦啊。
然后想到既然線上有這么簡單的操作就可以死鎖,那么應該寫個簡單的程序就可以重現,然后同事李潤傑兄弟咔嚓咔嚓沒多時就給我了,在這里謝謝他。
首先環境是這樣的:
CREATE TABLE `abcdefg` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`abc` varchar(30),
`def` varchar(30) ,
`ghi` date,
`jkl` date,
`mnp` tinyint(4),
PRIMARY KEY (`id`),
UNIQUE KEY `uniqdefghijkl` (`def`,`ghi`,`jkl`)
);
這個表包括2個索引,一個是聚簇索引,另一個是uniqdefghijkl的二級唯一索引。
事先插入很多數據,然后3個線程同時做對同一條記錄的刪除,這里只做刪除操作,並且都是自動提交,為了得到一批要刪除的數據,事先查詢很多條出來備用。
刪除語句是這樣的:
delete from abcdefg WHERE abc= '我是變量' and def= '我是變量' and ghi= '2013-12-19 00:00:00' and jkl= '2013-12-20 00:00:00';
那么現在就開始重現。。。
果然很快,死鎖真的出現了,下面是執行show engine innodb status的結果:
===================================================
LATEST DETECTED DEADLOCK
------------------------
140123 12:20:50
*** (1) TRANSACTION:
TRANSACTION 2E10, ACTIVE 4917 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 376, 1 row lock(s)
MySQL thread id 3, OS thread handle 0x1008, query id 43 192.168.xx.x username upd
ating
delete from abcdefg WHERE abc= '我是變量' and def= '我是變量' and ghi= '2013-12-19 00:00:00' and jkl= '2013-12-20 00:00:00';
------------------------
140123 12:20:50
*** (1) TRANSACTION:
TRANSACTION 2E10, ACTIVE 4917 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 376, 1 row lock(s)
MySQL thread id 3, OS thread handle 0x1008, query id 43 192.168.xx.x username upd
ating
delete from abcdefg WHERE abc= '我是變量' and def= '我是變量' and ghi= '2013-12-19 00:00:00' and jkl= '2013-12-20 00:00:00';
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 12295 n bits 528 index `uniqdefghijkl` of table
RECORD LOCKS space id 0 page no 12295 n bits 528 index `uniqdefghijkl` of table
`deadlock`.`abcdefg` trx id 2E10 lock_mode X locks rec but not gap waiti
ng
Record lock, heap no 167 PHYSICAL RECORD: n_fields 4; compact format;
*** (2) TRANSACTION:
TRANSACTION 2E0E, ACTIVE 4917 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1248, 2 row lock(s)
MySQL thread id 1, OS thread handle 0x1190, query id 41 192.168.xx.xx username upd
Record lock, heap no 167 PHYSICAL RECORD: n_fields 4; compact format;
*** (2) TRANSACTION:
TRANSACTION 2E0E, ACTIVE 4917 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1248, 2 row lock(s)
MySQL thread id 1, OS thread handle 0x1190, query id 41 192.168.xx.xx username upd
ating
RECORD LOCKS space id 0 page no 12295 n bits 528 index `uniqdefghijkl` of table
delete from abcdefg WHERE abc= '我是變量' and def= '我是變量' and ghi= '2013-12-19 00:00:00' and jkl= '2013-12-20 00:00:00';
*** (2) HOLDS THE LOCK(S):RECORD LOCKS space id 0 page no 12295 n bits 528 index `uniqdefghijkl` of table
`deadlock`.`abcdefg` trx id 2E0E lock_mode X locks rec but not gap
Record lock, heap no 167 PHYSICAL RECORD: n_fields 4; compact format;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 12295 n bits 528 index `uniqdefghijkl` of table
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 0 page no 12295 n bits 528 index `uniqdefghijkl` of table
`deadlock`.`abcdefg` trx id 2E0E lock_mode X waiting
Record lock, heap no 167 PHYSICAL RECORD: n_fields 4; compact format;
*** WE ROLL BACK TRANSACTION (1)
*** WE ROLL BACK TRANSACTION (1)
===================================================
這是在三個線程的情況下是可以重現死鎖的,但是為了更容易調試,試了一下在2個線程的情況下如何,最終發現重現不了。
這下壞了,多線程調試很麻煩,有時候這個走那個不走的,如果凍結某個線程,有可能導致線程之間死鎖,或者自然執行,那又不能出現死鎖的情況,因為這個死鎖也是偶然性的,所以最終只有一種方法,那就是在mysql代碼中打印log信息,將鎖、記錄與事務這塊的函數中具有分歧點的地方都加了注釋,並且將有用的信息打印出來,最終分析log文件,才發現了真正死鎖的貓膩。
現在將三個導致死鎖的事務的時序圖畫出來:
事務A | 事務B | 事務C |
開始 | ||
表的IX鎖 17 @1 | ||
二級索引行鎖X REC NOTGAP 1059 @2
檢查死鎖 沒事
|
||
表IX鎖 17 @3 | ||
二級索引記錄行鎖 REC NOTGAP X WAIT 1315 @4
檢查死鎖,沒事
|
||
表IX鎖 17 @5 | ||
二級索引記錄行鎖 REC NOTGAP X WAIT 1315 @6
檢查死鎖 沒事
|
||
聚簇索引行鎖X REC NOTGAP 1059 @7 | ||
wait.... suspend.... | wait.... suspend.... | |
commit | ||
wakeup this trx 將@4的WAIT去掉,成為1059 |
||
二級索引記錄行鎖 REC X WAIT 291 @8
檢查死鎖 發現死鎖
|
圖1
說明:
上面的數字都是源代碼中的關於各種鎖的位圖值:
LOCK_TABLE:16
LOCK_IX:1
LOCK_REC_NOT_GAP:1024
LOCK_WAIT:256
LOCK_REC:32
LOCK_X:3
所以鎖@6表示的是
LOCK_REC |
LOCK_REC_NOT_GAP |
LOCK_X |
LOCK_WAIT = 1315
依次類推
這里檢查死鎖的算法大體上說一下,無非是檢查有沒有形成等待環
事務B的鎖@8等待事務C的鎖@6,事務C的鎖@6在等待事務B的鎖@3,此時發現又繞回來了,那么產生死鎖。
到這里,死鎖的現象如何產生已經解釋清楚,但是,這是為什么呢?
這里的疑問是:
在事務A提交之后,將事務B喚醒了,此時事務B的鎖@4為REC NOTGAP X(1059),那么此時這個事務又去檢查鎖的情況,看看自己事務的鎖有沒有GRANT成功的,如果有則直接使用並且繼續執行,如果沒有則再加鎖,做這個檢查的函數為lock_rec_has_expl,它做的事情是下面的檢查:
===========================================================
lock = lock_rec_get_first(block, heap_no);
while (lock) {
if
(lock->trx == trx
&& !lock_is_wait_not_by_other(lock->type_mode)
&& lock_mode_stronger_or_eq(lock_get_mode(lock),
precise_mode & LOCK_MODE_MASK)
&& (!lock_rec_get_rec_not_gap(lock)
|| (precise_mode & LOCK_REC_NOT_GAP)
|| heap_no == PAGE_HEAP_NO_SUPREMUM)
&& (!lock_rec_get_gap(lock)
|| (precise_mode & LOCK_GAP)
|| heap_no == PAGE_HEAP_NO_SUPREMUM)
&& (!lock_rec_get_insert_intention(lock))) {
return(lock);
}
lock = lock_rec_get_next(heap_no, lock);
}
=============================================================
這里需要滿足6個條件:
- 首先這個鎖是自己事務的
- 這個鎖不是處於等待狀態
- 當前鎖的類型與precise_mode是兼容的,precise_mode值是X鎖,因為這里是要做刪除
- 當前鎖不是NOT GAP類型,或者要加的鎖類型是NOTGAP類型的,或者heapno為1
- 當前鎖不是GAP類型,或者要加的鎖類型是GAP類型的,或者heapno為1
- 當前鎖不是意向插入鎖
但此時發現1059(鎖@4)根本不滿足第4點啊,因為它首先是NOTGAP鎖,同時heapno不是1,所以沒有找到,所以在外面又重新創建一個鎖,因為此時這行已經有鎖了,那么它會創建一個REC WAIT X鎖(291),也就是鎖@8。
所以即使鎖@4不是處於等待狀態了,此時也不能直接執行呢,而是重新創建了一個鎖。此時導致了死鎖。
那么現在問題又來了,從上圖可以看到,這個時間序列沒有什么特別的,或者特殊的一個交叉過程,從而是不是我們可以很容易的重現呢?僅僅通過開啟三個會話,都設置為not autocommit的,因為需要將第一個事務A的提交放在事務B C的后面。
那么開始了,創建相同的表,刪除同一行記錄。
事務A | 事務B | 事務C |
begin | ||
delete
刪除行數返回為1
|
||
begin | ||
delete 阻塞 | ||
begin | ||
阻塞 | ||
commit | ||
觀察有沒有死鎖 其實並沒有死鎖 刪除行數返回為0 |
||
刪除行數返回為0 |
圖2
按說,上面這個圖與圖1沒有什么區別,但沒有死鎖?為什么?
其實沒有死鎖是正常的,如果這樣就死鎖了,那mysql簡直不能用了!!!
看來還是有區別的
正常模式下再做一次log分析,從log中看出了大問題......
再將上面詳細的加鎖圖在無死鎖模式下的情況貼出來:
事務A | 事務B | 事務C |
開始 | ||
表的IX鎖 17 @1 | ||
二級索引行鎖X REC NOTGAP 1059 @2
檢查死鎖 沒事
|
||
聚簇索引行鎖X REC NOTGAP 1059 @7
檢查死鎖 沒事
|
||
表IX鎖 17 @3 | ||
二級索引記錄行鎖 REC X WAIT 291 @4
檢查死鎖,沒事
|
||
表IX鎖 17 @5 | ||
二級索引記錄行鎖 REC X WAIT
291 @6
檢查死鎖 沒事
|
||
wait.... suspend.... | wait.... suspend.... | |
commit | ||
wakeup this trx 將@4的WAIT去掉,成為35 |
||
執行完成,提交 | ||
執行完成 |
圖3
此時發現,圖3其實與圖1是一樣的,那為什么圖3可以正常執行完成,而圖1死鎖了呢?
但認真仔細看了之后,發現有很小的地方是不同的,圖3中的鎖@4加上的鎖是291(REC & X & WAIT),而圖1中加的鎖比它多了一個NOTGAP的鎖,鎖@6也是一樣的,圖3的事務A在提交並且喚醒了鎖@4之后,它的鎖類型為REC+X(35),而圖1中的值也是比它多了一個NOTGAP鎖。
現在已經基本定位了問題所在,應該是NOTGAP搞的鬼。但是為什么會有差別呢?
此時還需要回到代碼中查看,通過日志分析,發現2個在執行下面代碼時走了不同的路:
=======================================
if (prebuilt->select_lock_type != LOCK_NONE) {
ulint lock_type;
if (!set_also_gap_locks
|| srv_locks_unsafe_for_binlog
|| trx->isolation_level <= TRX_ISO_READ_COMMITTED
|| (unique_search
&& !UNIV_UNLIKELY(rec_get_deleted_flag(rec, comp)))) {
goto
no_gap_lock;//直接路到下面
lock_typ
e = LOCK_REC_NOT_GAP;處
} else {
lock_type = LOCK_ORDINARY;
}
、
if (index == clust_index
&& mode == PAGE_CUR_GE
&& direction == 0
&& dtuple_get_n_fields_cmp(search_tuple)
== dict_index_get_n_unique(index)
&& 0 == cmp_dtuple_rec(search_tuple, rec, offsets)) {
no_gap_lock://標記
lock_type = LOCK_REC_NOT_GAP;
}
=======================================
這里關鍵的分叉口就是在上面紅色字體部分,死鎖的時候走了
goto
no_gap_lock,而沒有出現死鎖的時候走的是
lock_type = LOCK_ORDINARY;,而
LOCK_ORDINARY表示的是0,什么都沒有,所以這2條路的不同就是差1024(NOTGAP鎖)。
那么從日志中發現,走了第一條路是因為條件
(unique_search
&& !UNIV_UNLIKELY(rec_get_deleted_flag(rec, comp))是符合的。
rec_get_deleted_flag函數的作用是判斷這條記錄是不是已經打了刪除標志。
現在豁然明白了,如果當前這條要加鎖的記錄還沒有打刪除標志,則加的鎖是NOTGAP類型的鎖,否則就不設置類型,那說明上面的圖1中事務A還是有一個細節沒有畫出來,正因為這個細節與事務B發生了交叉,導致了事務B在做的時候還沒有打了刪除標記,所以就加了NOTGAP鎖,所以導致后面的死鎖。
而正常情況下,也就是圖2的測試,因為事務A已經完成了所有的操作,只等待提交,此時肯定已經打了刪除標志,則在加鎖時不會加NOTGAP鎖,所以就不會出現死鎖。
哎,用一句同事常說的話:我這下真的了然了,原來問題這么復雜,mysql中的貓膩太多了。
那現在分析一下原因吧:
現在已經確定問題就是出現在上面代碼的判斷中,在上面代碼的上面還有一段注釋:
/* Try to place a lock on the index record; note that delete
marked records are a special case in a unique search. If there
is a non-delete marked record, then it is enough to lock its
existence with LOCK_REC_NOT_GAP. */
這說明了加NOTGAP鎖的意圖,說明上面代碼的判斷是專門做的,具體原因就無從查起了,但是注釋中說這是一種特殊情況,為什么呢?解決方式是把那2行直接去掉就可以了(測試過不會出現死鎖了),但這個會不會是解決問題的根本原因,還要等待官方人員的處理。
所以到這里,把完整的死鎖圖貼上來:
事務A | 事務B | 事務C |
開始 | ||
表的IX鎖 17 @1 | ||
二級索引行鎖X REC NOTGAP 1059 @2
檢查死鎖 沒事
|
||
表IX鎖 17 @3 | ||
二級索引記錄行鎖 REC NOTGAP X WAIT 1315 @4
檢查死鎖,沒事
|
||
表IX鎖 17 @5 | ||
二級索引記錄行鎖 REC NOTGAP X WAIT 1315 @6
檢查死鎖 沒事
|
||
對二級索引記錄加刪除標志(這個是最關鍵的) 這個交叉點就是:在鎖@2與@7之間,有事務B加了鎖@4,事務加了鎖@6 |
||
聚簇索引行鎖X REC NOTGAP 1059 @7 | ||
wait.... suspend.... | wait.... suspend.... | |
commit | ||
wakeup this trx 將@4的WAIT去掉,成為1059 |
||
二級索引記錄行鎖 REC X WAIT 291 @8
檢查死鎖 發現死鎖
|
思維發散:
1. 對於已經刪除的記錄(已經提交,但還沒有purge),如果再去做刪除操作,則此時還會加鎖么?加什么鎖?(這個問題,由於時間太緊,后面再給出驗證說明,如果有興趣,自己也可以做一下的)
2. 這個問題是在隔離級別是可重復讀的情況下存在的,但如果是其它情況下會出現么?
3. 如果是根據主鍵刪除,這個問題還會出現么?
總結:在mysql中,其實很多東西都不能按照常理來想的,這個問題本來在達夢與oracle中是根本不可想象的,根本不會出現的,所以才有一開始覺得不可能的感覺,最后才發現,原來是真的。
在這里感謝一下同事們的幫助與討論,感謝勇哥,傑哥