誰(shuí)還沒(méi)經(jīng)歷過(guò)死鎖呢
說(shuō)個(gè)很早之前自己遇到過(guò)數(shù)據(jù)庫(kù)死鎖問(wèn)題。
有個(gè)業(yè)務(wù)主要邏輯就是新增訂單、修改訂單、查詢(xún)訂單等操作。然后因?yàn)橛唵问遣荒苤貜?fù)的,所以當(dāng)時(shí)在新增訂單的時(shí)候做了冪等性校驗(yàn),做法就是在新增訂單記錄之前,先通過(guò) select ... for update 語(yǔ)句查詢(xún)訂單是否存在,如果不存在才插入訂單記錄。
而正是因?yàn)檫@樣的操作,當(dāng)業(yè)務(wù)量很大的時(shí)候,就可能會(huì)出現(xiàn)死鎖。
接下來(lái)跟大家聊下為什么會(huì)發(fā)生死鎖,以及怎么避免死鎖。
死鎖的發(fā)生
本次案例使用存儲(chǔ)引擎 Innodb,隔離級(jí)別不可重復(fù)讀(RR)。
接下來(lái),我用實(shí)戰(zhàn)的方式來(lái)帶大家看看死鎖是怎么發(fā)生的。
我建了一張訂單表,其中 id 字段為主鍵索引,order_no 字段普通索引,也就是非唯一索引:
CREATE?TABLE?`t_order`?(
??`id`?int?NOT?NULL?AUTO_INCREMENT,
??`order_no`?int?DEFAULT?NULL,
??`create_date`?datetime?DEFAULT?NULL,
??PRIMARY?KEY?(`id`),
??KEY?`index_order`?(`order_no`)?USING?BTREE
)?ENGINE=InnoDB?;
然后,先 t_order 表里現(xiàn)在已經(jīng)有了 6 條記錄:

假設(shè)這時(shí)有兩事務(wù),一個(gè)事務(wù)要插入訂單 1007 ,另外一個(gè)事務(wù)要插入訂單 1008,因?yàn)樾枰獙?duì)訂單做冪等性校驗(yàn),所以?xún)蓚€(gè)事務(wù)先要查詢(xún)?cè)撚唵问欠翊嬖?,不存在才插入記錄,過(guò)程如下:

可以看到,兩個(gè)事務(wù)都陷入了等待狀態(tài)(前提沒(méi)有打開(kāi)死鎖檢測(cè)),也就是發(fā)生了死鎖,因?yàn)槎荚谙嗷サ却龑?duì)方釋放鎖。
這里在查詢(xún)記錄是否存在的時(shí)候,使用了 select ... for update 語(yǔ)句,目的為了防止事務(wù)執(zhí)行的過(guò)程中,有其他事務(wù)插入了記錄,而出現(xiàn)幻讀的問(wèn)題。
如果沒(méi)有使用 select ... for update 語(yǔ)句,而使用了單純的 select 語(yǔ)句,如果是兩個(gè)訂單號(hào)一樣的請(qǐng)求同時(shí)進(jìn)來(lái),就會(huì)出現(xiàn)兩個(gè)重復(fù)的訂單,有可能出現(xiàn)幻讀,如下圖:

為什么會(huì)產(chǎn)生死鎖?
可重復(fù)讀隔離級(jí)別下,是存在幻讀的問(wèn)題。
Innodb 引擎為了解決「可重復(fù)讀」隔離級(jí)別下的幻讀問(wèn)題,就引出了 next-key 鎖,它是記錄鎖和間隙鎖的組合。
Record Loc,記錄鎖,鎖的是記錄本身;
Gap Lock,間隙鎖,鎖的就是兩個(gè)值之間的空隙,以防止其他事務(wù)在這個(gè)空隙間插入新的數(shù)據(jù),從而避免幻讀現(xiàn)象。
普通的 select 語(yǔ)句是不會(huì)對(duì)記錄加鎖的,因?yàn)樗峭ㄟ^(guò) MVCC 的機(jī)制實(shí)現(xiàn)的快照讀,如果要在查詢(xún)時(shí)對(duì)記錄加行鎖,可以使用下面這兩個(gè)方式:
begin;
//對(duì)讀取的記錄加共享鎖
select?...?lock?in?share?mode;
commit;?//鎖釋放
begin;
//對(duì)讀取的記錄加排他鎖
select?...?for?update;
commit;?//鎖釋放
行鎖的釋放時(shí)機(jī)是在事務(wù)提交(commit)后,鎖就會(huì)被釋放,并不是一條語(yǔ)句執(zhí)行完就釋放行鎖。
比如,下面事務(wù) A 查詢(xún)語(yǔ)句會(huì)鎖住(2, +∞]范圍的記錄,然后期間如果有其他事務(wù)在這個(gè)鎖住的范圍插入數(shù)據(jù)就會(huì)被阻塞。

next-key 鎖的加鎖規(guī)則其實(shí)挺復(fù)雜的,在一些場(chǎng)景下會(huì)退化成記錄鎖或間隙鎖,我之前也寫(xiě)一篇加鎖規(guī)則,詳細(xì)可以看這篇「我做了一天的實(shí)驗(yàn)!」
需要注意的是,next-key lock 鎖的是索引,而不是數(shù)據(jù)本身,所以如果 update 語(yǔ)句的 where 條件沒(méi)有用到索引列,那么就會(huì)全表掃描,在一行行掃描的過(guò)程中,不僅給行加上了行鎖,還給行兩邊的空隙也加上了間隙鎖,相當(dāng)于鎖住整個(gè)表,然后直到事務(wù)結(jié)束才會(huì)釋放鎖。
所以在線(xiàn)上千萬(wàn)不要執(zhí)行沒(méi)有帶索引條件的 update 語(yǔ)句,不然會(huì)造成業(yè)務(wù)停滯,我有個(gè)讀者就因?yàn)楦闪诉@個(gè)事情,然后被老板教育了一波,詳細(xì)可以看這篇「完蛋,公司被一條 update 語(yǔ)句干趴了!」
回到前面死鎖的例子,在執(zhí)行下面這條語(yǔ)句的時(shí)候:
select?id?from?t_order?where?order_no?=?1008?for?update;
因?yàn)?order_no 不是唯一索引,所以行鎖的類(lèi)型是間隙鎖,于是間隙鎖的范圍是(1006, +∞)。那么,當(dāng)事務(wù) B 往間隙鎖里插入 id = 1008 的記錄就會(huì)被鎖住。
因?yàn)楫?dāng)我們執(zhí)行以下插入語(yǔ)句時(shí),會(huì)在插入間隙上再次獲取插入意向鎖。
insert?into?t_order?(order_no,?create_date)?values?(1008,?now());
插入意向鎖與間隙鎖是沖突的,所以當(dāng)其它事務(wù)持有該間隙的間隙鎖時(shí),需要等待其它事務(wù)釋放間隙鎖之后,才能獲取到插入意向鎖。而間隙鎖與間隙鎖之間是兼容的,所以所以?xún)蓚€(gè)事務(wù)中 select ... for update 語(yǔ)句并不會(huì)相互影響。
案例中的事務(wù) A 和事務(wù) B 在執(zhí)行完后 select ... for update 語(yǔ)句后都持有范圍為(1006,+∞)的間隙鎖,而接下來(lái)的插入操作為了獲取到插入意向鎖,都在等待對(duì)方事務(wù)的間隙鎖釋放,于是就造成了循環(huán)等待,導(dǎo)致死鎖。
如何避免死鎖?
死鎖的四個(gè)必要條件:互斥、占有且等待、不可強(qiáng)占用、循環(huán)等待。只要系統(tǒng)發(fā)生死鎖,這些條件必然成立,但是只要破壞任意一個(gè)條件就死鎖就不會(huì)成立。
在數(shù)據(jù)庫(kù)層面,有兩種策略通過(guò)「打破循環(huán)等待條件」來(lái)解除死鎖狀態(tài):
設(shè)置事務(wù)等待鎖的超時(shí)時(shí)間。當(dāng)一個(gè)事務(wù)的等待時(shí)間超過(guò)該值后,就對(duì)這個(gè)事務(wù)進(jìn)行回滾,于是鎖就釋放了,另一個(gè)事務(wù)就可以繼續(xù)執(zhí)行了。在 InnoDB 中,參數(shù)
innodb_lock_wait_timeout是用來(lái)設(shè)置超時(shí)時(shí)間的,默認(rèn)值時(shí) 50 秒。當(dāng)發(fā)生超時(shí)后,就出現(xiàn)下面這個(gè)提示:

開(kāi)啟主動(dòng)死鎖檢測(cè)。主動(dòng)死鎖檢測(cè)在發(fā)現(xiàn)死鎖后,主動(dòng)回滾死鎖鏈條中的某一個(gè)事務(wù),讓其他事務(wù)得以繼續(xù)執(zhí)行。將參數(shù)
innodb_deadlock_detect設(shè)置為 on,表示開(kāi)啟這個(gè)邏輯,默認(rèn)就開(kāi)啟。當(dāng)檢測(cè)到死鎖后,就會(huì)出現(xiàn)下面這個(gè)提示:

上面這個(gè)兩種策略是「當(dāng)有死鎖發(fā)生時(shí)」的避免方式。
我們可以回歸業(yè)務(wù)的角度來(lái)預(yù)防死鎖,對(duì)訂單做冪等性校驗(yàn)的目的是為了保證不會(huì)出現(xiàn)重復(fù)的訂單,那我們可以直接將 order_no 字段設(shè)置為唯一索引列,利用它的唯一下來(lái)保證訂單表不會(huì)出現(xiàn)重復(fù)的訂單,不過(guò)有一點(diǎn)不好的地方就是在我們插入一個(gè)已經(jīng)存在的訂單記錄時(shí)就會(huì)拋出異常。
最后說(shuō)個(gè)段子:
面試官: 解釋下什么是死鎖?
應(yīng)聘者: 你錄用我,我就告訴你
面試官: 你告訴我,我就錄用你
應(yīng)聘者: 你錄用我,我就告訴你
面試官: 臥槽滾!
...........
今天分享就到這啦。
如果你也有遇到死鎖的經(jīng)歷,歡迎分享下,讓大家漲漲知識(shí)
