誰還沒經歷過死鎖呢
本文轉載自微信公眾號「小林coding」,作者小林coding。轉載本文請聯系小林coding公眾號。
大家好,我是小林。
說個很早之前自己遇到過數據庫死鎖問題。
有個業務主要邏輯就是新增訂單、修改訂單、查詢訂單等操作。然后因為訂單是不能重復的,所以當時在新增訂單的時候做了冪等性校驗,做法就是在新增訂單記錄之前,先通過 select ... for update 語句查詢訂單是否存在,如果不存在才插入訂單記錄。
而正是因為這樣的操作,當業務量很大的時候,就可能會出現死鎖。
接下來跟大家聊下為什么會發生死鎖,以及怎么避免死鎖。
死鎖的發生
本次案例使用存儲引擎 Innodb,隔離級別不可重復讀(RR)。
接下來,我用實戰的方式來帶大家看看死鎖是怎么發生的。
我建了一張訂單表,其中 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 表里現在已經有了 6 條記錄:
假設這時有兩事務,一個事務要插入訂單 1007 ,另外一個事務要插入訂單 1008,因為需要對訂單做冪等性校驗,所以兩個事務先要查詢該訂單是否存在,不存在才插入記錄,過程如下:
可以看到,兩個事務都陷入了等待狀態(前提沒有打開死鎖檢測),也就是發生了死鎖,因為都在相互等待對方釋放鎖。
這里在查詢記錄是否存在的時候,使用了 select ... for update 語句,目的為了防止事務執行的過程中,有其他事務插入了記錄,而出現幻讀的問題。
如果沒有使用 select ... for update 語句,而使用了單純的 select 語句,如果是兩個訂單號一樣的請求同時進來,就會出現兩個重復的訂單,有可能出現幻讀,如下圖:
為什么會產生死鎖?
可重復讀隔離級別下,是存在幻讀的問題。
Innodb 引擎為了解決「可重復讀」隔離級別下的幻讀問題,就引出了 next-key 鎖,它是記錄鎖和間隙鎖的組合。
- Record Loc,記錄鎖,鎖的是記錄本身;
- Gap Lock,間隙鎖,鎖的就是兩個值之間的空隙,以防止其他事務在這個空隙間插入新的數據,從而避免幻讀現象。
普通的 select 語句是不會對記錄加鎖的,因為它是通過 MVCC 的機制實現的快照讀,如果要在查詢時對記錄加行鎖,可以使用下面這兩個方式:
- begin;
- //對讀取的記錄加共享鎖
- select ... lock in share mode;
- commit; //鎖釋放
- begin;
- //對讀取的記錄加排他鎖
- select ... for update;
- commit; //鎖釋放
行鎖的釋放時機是在事務提交(commit)后,鎖就會被釋放,并不是一條語句執行完就釋放行鎖。
比如,下面事務 A 查詢語句會鎖住(2, +∞]范圍的記錄,然后期間如果有其他事務在這個鎖住的范圍插入數據就會被阻塞。
next-key 鎖的加鎖規則其實挺復雜的,在一些場景下會退化成記錄鎖或間隙鎖,我之前也寫一篇加鎖規則,詳細可以看這篇「我做了一天的實驗!」
需要注意的是,next-key lock 鎖的是索引,而不是數據本身,所以如果 update 語句的 where 條件沒有用到索引列,那么就會全表掃描,在一行行掃描的過程中,不僅給行加上了行鎖,還給行兩邊的空隙也加上了間隙鎖,相當于鎖住整個表,然后直到事務結束才會釋放鎖。
所以在線上千萬不要執行沒有帶索引條件的 update 語句,不然會造成業務停滯,我有個讀者就因為干了這個事情,然后被老板教育了一波,詳細可以看這篇「完蛋,公司被一條 update 語句干趴了!」
回到前面死鎖的例子,在執行下面這條語句的時候:
- select id from t_order where order_no = 1008 for update;
因為 order_no 不是唯一索引,所以行鎖的類型是間隙鎖,于是間隙鎖的范圍是(1006, +∞)。那么,當事務 B 往間隙鎖里插入 id = 1008 的記錄就會被鎖住。
因為當我們執行以下插入語句時,會在插入間隙上再次獲取插入意向鎖。
- insert into t_order (order_no, create_date) values (1008, now());
插入意向鎖與間隙鎖是沖突的,所以當其它事務持有該間隙的間隙鎖時,需要等待其它事務釋放間隙鎖之后,才能獲取到插入意向鎖。而間隙鎖與間隙鎖之間是兼容的,所以所以兩個事務中 select ... for update 語句并不會相互影響。
案例中的事務 A 和事務 B 在執行完后 select ... for update 語句后都持有范圍為(1006,+∞)的間隙鎖,而接下來的插入操作為了獲取到插入意向鎖,都在等待對方事務的間隙鎖釋放,于是就造成了循環等待,導致死鎖。
如何避免死鎖?
死鎖的四個必要條件:互斥、占有且等待、不可強占用、循環等待。只要系統發生死鎖,這些條件必然成立,但是只要破壞任意一個條件就死鎖就不會成立。
在數據庫層面,有兩種策略通過「打破循環等待條件」來解除死鎖狀態:
- 設置事務等待鎖的超時時間。當一個事務的等待時間超過該值后,就對這個事務進行回滾,于是鎖就釋放了,另一個事務就可以繼續執行了。在 InnoDB 中,參數 innodb_lock_wait_timeout 是用來設置超時時間的,默認值時 50 秒。
當發生超時后,就出現下面這個提示:
- 開啟主動死鎖檢測。主動死鎖檢測在發現死鎖后,主動回滾死鎖鏈條中的某一個事務,讓其他事務得以繼續執行。將參數 innodb_deadlock_detect 設置為 on,表示開啟這個邏輯,默認就開啟。
當檢測到死鎖后,就會出現下面這個提示:
上面這個兩種策略是「當有死鎖發生時」的避免方式。
我們可以回歸業務的角度來預防死鎖,對訂單做冪等性校驗的目的是為了保證不會出現重復的訂單,那我們可以直接將 order_no 字段設置為唯一索引列,利用它的唯一下來保證訂單表不會出現重復的訂單,不過有一點不好的地方就是在我們插入一個已經存在的訂單記錄時就會拋出異常。