一次詭異的死鎖 —— 認識mysql間隙鎖機制


相信所有學習過計算機操作系統課程的同學對死鎖都不陌生,每逢考試&面試,死鎖都是作爲重點考覈的基礎知識,“死鎖的四大條件”已被無數IT人員爛熟於心,說倒背如流也不爲過。不過理論歸理論,實際開發過程中遇到死鎖時,更讓人頭大的是如何定位造成死鎖的原因。這裏筆者分享自己一次分析死鎖的過程,順便聊聊MySQL的間隙鎖機制。

情景模擬

由於原始場景設計到航空方面的專業術語,爲方便敘述這裏用虛構的場景進行問題還原。
假設現在需要開發如下的菜單配置功能:
功能示例
並已經設計好了存儲菜單配置的關聯表menu_option_tbl以及表索引如下:
在這裏插入圖片描述
id字段爲自增主鍵,menu_id與option_id爲外鍵,同時在menu_id上建了普通索引。每個選項帶有序號屬性用於控制前端排序。

問題復現

假設開發同學寫了這麼一段菜單配置保存邏輯:

delete from menu_option_tbl where menu_id=menu.id;
insert into menu_option_tbl values (null, menu.id, option1.id, 1);
insert into menu_option_tbl values (null, menu.id, option2.id, 2);
insert into menu_option_tbl values (null, menu.id, option3.id, 3);

上述代碼實現效果是先嚐試根據菜單ID刪除已有的選項(若已配置),然後用新增的代碼邏輯來插入用戶最終保存的結果。
從功能效果上看,這段邏輯沒有太大問題,無論之前有沒有菜單配置,每次保存前都先執行數據清理,然後統一按照新增方式插入數據,不需要考慮哪些選項是新增、哪些選項被刪除、哪些選項序號發生了變化,在低併發場景下這段代碼幾乎不會出現異常。但我們這裏稍微加點限制,假設我們做的是通用門戶菜單配置,同一時間併發量在30左右,這時我們再看看會發生什麼。
在這裏插入圖片描述
Bingo,死鎖出現了。

問題分析

要解決問題首先就要知道爲什麼會出現問題,上面這段簡單的 刪除 - 插入 代碼邏輯爲什麼會出現死鎖?
先給出mysql中記錄到的死鎖信息:

------------------------ LATEST DETECTED DEADLOCK
------------------------ 2019-12-22 23:49:37 0x2100
*** (1) TRANSACTION: TRANSACTION 110266, ACTIVE 0 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 4 lock struct(s), heap size 1136,
3 row lock(s), undo log entries 2 MySQL thread id 1399, OS thread
handle 12900, query id 225347 localhost ::1 root update insert into
menu_option_tbl(id, menu_id, option_id, seq_no) values (null, 26, 662,
2)

*** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 119 page no 4 n bits 176 index menu_id of table
my_test_db.menu_option_tbl trx id 110266 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) TRANSACTION: TRANSACTION 110267, ACTIVE 0 sec inserting, thread declared inside InnoDB 5000 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 1400, OS thread handle 8448, query id 225348 localhost
::1 root update insert into menu_option_tbl(id, menu_id, option_id,
seq_no) values (null, 27, 74, 1)

*** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 119 page no 4 n bits 176 index menu_id of table my_test_db.menu_option_tbl trx id
110267 lock_mode X Record lock, heap no 1 PHYSICAL RECORD: n_fields 1;
compact format; info bits 0 0: len 8; hex 73757072656d756d; asc
supremum;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 119 page no 4 n bits 176 index menu_id of table
my_test_db.menu_option_tbl trx id 110267 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;;

仔細觀察上面的信息,會發現詭異的現象:出現死鎖的是兩條insert語句。
我們在學操作系統的時候知道共享鎖(S鎖)和排他鎖(X鎖)的概念,例如讀者寫者模型就是一個典型例子。按直覺理解,insert語句對應的是X鎖,並且menu_id也不相同,他們之間不應該存在競爭關係,達不到構成死鎖的必要條件。
但事實上死鎖確實發生了,並且滿足四項條件,究其原因是因爲mysql引入了一種介於S鎖與X鎖之間的鎖機制——間隙鎖(Gap lock)。

間隙鎖

間隙鎖是加在索引鍵空隙上的鎖,與行鎖相互補充。例如數據庫當前有id爲1,3,5的三條記錄,那麼行鎖將鎖定這三條具體的記錄,控制同時只允許一個事務進行數據操作;而間隙鎖可以鎖定表中尚不存在的那些id區間,如[-∞,1),(1,3),(3,5),(5,+∞]。
間隙鎖設計的目的是解決幻讀的問題,例如事務一執行 SELECT c1 FROM t WHERE c1 BETWEEN 10 and 20 FOR UPDATE,將會在C1字段10和20之間的區間加上間隙鎖,無論當前是否有對應的記錄值,若此時事務二想新增一條C1=15的記錄,將會被間隙鎖攔截,從而保證事務一不出現幻讀。
不過間隙鎖有一個特殊的屬性,數據庫會自動判斷事務是否需要添加間隙鎖,並且不同事務間的間隙鎖並不互斥,意味着事務一和事務二可以同時對任意區間添加間隙鎖,無論鎖定的區間是否重疊。

回過頭來看模擬場景,爲什麼間隙鎖會對上述邏輯造成影響,明明代碼邏輯中並沒有範圍條件?問題關鍵在於第一條delete語句。
按照代碼邏輯,無論配置表中是否存在menu_id對應的記錄,都會執行delete操作,不過記錄存在與否將導致mysql採取不同的加鎖策略:

  1. 若menu_option_tbl中存在menu_id對應記錄,這時mysql將採用行鎖進行併發控制。這個場景下一切都將安好,不會出現死鎖。
    在這裏插入圖片描述
  2. 若menu_option_tbl中不存在menu_id對應記錄,此時爲避免事務幻讀,mysql將主動增加一條間隙鎖。與預期不同的是,所添加的間隙鎖範圍爲 (max(menu_id), +∞],並非delete語句所指定的單個menu_id。
    在這裏插入圖片描述

在第二個場景下,併發事務執行delete將分別對(max(menu_id), +∞]區間加上間隙鎖,由於間隙鎖的特性,兩個事務都將加鎖成功。隨後執行insert時,兩個事務都必須等待對方釋放間隙鎖後,才能獲得insert操作所需的X鎖,從而出現死鎖問題。

問題解決

知道死鎖的原因後,制定處理的方法就非常容易了。

  1. 刪除記錄時使用明確的主鍵ID,而不是menu_id;
  2. 表中不存在menu_id記錄時,不執行delete操作,避免產生間隙鎖;
  3. 設置事務隔離級別爲READ COMMITED,這樣可以隱式禁止間隙鎖,不過會帶來一些副作用;
  4. 在性能可以接受前提下,去除menu_id的索引;

解釋一下方法四,刪除索引爲什麼可以解決間隙鎖?因爲innodb的行鎖以及間隙鎖都是針對索引進行操作的,所謂的加鎖也只是對索引頁進行控制。因此過多的索引不單會降低數據庫性能,還會引入更多的死鎖風險。
最優的解決方案是方法一,通過menu_id查詢出記錄主鍵,再根據主鍵進行數據操作,而不是由mysql自己判斷是否需要加間隙鎖。各類持久層框架默認都是使用這樣的模式,不算優雅但實用。

小結

篇幅有限,本文只是簡單闡述了一次由於間隙鎖所引發的死鎖問題分析,更多關於mysql數據庫鎖機制請參考官方文檔MySQL 5.7用戶手冊。以前課文學習的理論總是剝離出了最簡化的模型,而實際使用過程中總會有各類豐富的擴展實現需要自己發掘體會。學習的路還很漫長,與各位共勉。

發佈了85 篇原創文章 · 獲贊 36 · 訪問量 8萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章