MySQL 死鎖:從 “業務卡頓” 到 “根因定位” 的實戰指南
后端開發必看:MySQL死鎖排查與預防全攻略
線上系統突然報出Deadlock found when trying to get lock; try restarting transaction,用戶操作卡頓甚至超時,排查時卻對著一堆日志無從下手?這篇文章幫你徹底搞定MySQL死鎖問題!
一、先搞懂:死鎖的"本質"和"必要條件"
很多人把"鎖等待"當成死鎖,其實二者完全不同:
- 鎖等待是"一個事務等另一個事務釋放鎖"(比如事務A沒提交,事務B等著改同一行數據),等事務A提交后,事務B就能繼續
- 死鎖是"多個事務互相等對方釋放鎖",陷入無限循環,最后MySQL只能"犧牲"一個事務來打破僵局
死鎖的"4個必要條件"
MySQL死鎖的產生,必須同時滿足4個條件(少一個都不行),搞懂這4點,就能明白死鎖的"軟肋"在哪里:
- 互斥條件:鎖資源是"獨占的",一個事務拿了鎖,其他事務只能等(比如行鎖,事務A鎖住一行后,事務B不能再拿同一行的寫鎖)
- 持有并等待條件:事務已經拿了至少一個鎖,又在等其他事務持有的鎖(比如事務A拿了行1的鎖,又等著拿行2的鎖,同時事務B拿了行2的鎖,等著拿行1的鎖)
- 不可剝奪條件:事務拿到的鎖不能被"搶"走,只能自己釋放(比如事務A拿了鎖,除非它主動提交/回滾,否則其他事務不能強制讓它釋放)
- 循環等待條件:多個事務形成"環形等待鏈"(比如A等B的鎖,B等C的鎖,C等A的鎖)
既然死鎖需要4個條件同時成立,那預防死鎖的核心思路就很明確:打破其中一個條件(比如避免循環等待、減少"持有并等待"的時間)。
死鎖和"鎖等待超時"的區別
很多同學會把Lock wait timeout exceeded
(鎖等待超時)和死鎖搞混,其實二者的觸發機制完全不同:
- 鎖等待超時:事務A持有鎖,事務B一直等A釋放,等了超過
innodb_lock_wait_timeout
(默認50秒)還沒等到,就會報這個錯(是"單方面等待超時") - 死鎖:多個事務互相等待,MySQL的"死鎖檢測器"(每隔一段時間運行)發現了循環等待,就會主動選擇一個"代價小"的事務回滾,報
Deadlock found...
(是"雙向循環等待,MySQL主動干預")
簡單說:鎖等待是"一個等一個,等不及了";死鎖是"互相等,誰也走不了,MySQL強行拆局"。
二、3個真實場景:死鎖是怎么"造"出來的?
死鎖不是憑空產生的,幾乎都是"業務邏輯 + SQL執行順序"共同導致的。下面3個場景你可能或多或少遇見過,我們逐一拆解死鎖的產生過程。
場景1:"交叉更新"引發的死鎖(最常見)
業務背景:電商訂單系統,用戶支付后需要"扣減庫存"和"更新訂單狀態",兩個事務分別操作兩行數據,但更新順序相反。
事務1(用戶A支付):
-- 步驟1:扣減商品1庫存
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1;
-- 步驟2:更新訂單1001狀態為"已支付"
UPDATE orders SET status = 'PAID' WHERE order_id = 1001;
事務2(用戶B支付):
-- 步驟1:更新訂單1001狀態為"已支付"
UPDATE orders SET status = 'PAID' WHERE order_id = 1001;
-- 步驟2:扣減商品1庫存
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1;
死鎖產生過程:
- 事務1執行"扣減庫存",拿到
product_stock
表中product_id=1
的行鎖 - 同時,事務2執行"更新訂單",拿到
orders
表中order_id=1001
的行鎖 - 事務1繼續執行"更新訂單",需要
orders
表中order_id=1001
的行鎖,但這把鎖被事務2拿著,于是事務1開始等 - 事務2繼續執行"扣減庫存",需要
product_stock
表中product_id=1
的行鎖,但這把鎖被事務1拿著,于是事務2開始等 - 此時,事務1等事務2的鎖,事務2等事務1的鎖 —— 形成循環等待,死鎖產生
為什么會這樣:兩個事務操作的是"同一批資源"(庫存行 + 訂單行),但更新的順序相反,滿足了"持有并等待"和"循環等待"兩個條件。
場景2:"間隙鎖"引發的死鎖(容易被忽略)
業務背景:用戶表user
有age
字段(無索引),兩個事務同時按age
范圍更新數據,InnoDB的"間隙鎖"可能導致死鎖。
事務1:
-- 更新age在10-20之間的用戶狀態
UPDATE user SET status = 1 WHERE age BETWEEN 10 AND 20;
事務2:
-- 更新age在15-25之間的用戶狀態
UPDATE user SET status = 1 WHERE age BETWEEN 15 AND 25;
死鎖產生過程:
- InnoDB中,若查詢條件是"范圍"且字段無索引,會觸發"間隙鎖"(鎖定一個范圍的"間隙",防止幻讀)
- 事務1執行時,
age BETWEEN 10 AND 20
會鎖定(10,20)
之間的間隙(包括邊界) - 事務2執行時,
age BETWEEN 15 AND 25
會鎖定(15,25)
之間的間隙,這兩個范圍有重疊(15-20) - 此時,事務1可能需要訪問事務2鎖定的間隙(比如15-20),事務2也需要訪問事務1鎖定的間隙,雙方互相等待,觸發死鎖
為什么會這樣:間隙鎖的"范圍重疊"導致了循環等待,而且很多人容易忽略"無索引時范圍查詢會加間隙鎖"這個特性,排查時往往想不到是鎖的范圍出了問題。
場景3:"事務長 + 鎖競爭"引發的死鎖(高并發下常見)
業務背景:秒殺系統,高并發下多個事務同時"創建訂單→扣減庫存→記錄日志",事務執行時間長,持有鎖的時間也長。
事務流程(每個秒殺請求):
-- 步驟1:創建訂單(寫orders表)
INSERT INTO orders (order_id, user_id, goods_id) VALUES (...);
-- 步驟2:扣減庫存(更新goods表)
UPDATE goods SET stock = stock - 1 WHERE goods_id = 100;
-- 步驟3:記錄秒殺日志(寫seckill_log表)
INSERT INTO seckill_log (log_id, order_id) VALUES (...);
-- 步驟4:(業務邏輯處理,比如調用其他服務,耗時1-2秒)
-- 步驟5:提交事務
COMMIT;
死鎖產生過程:
- 高并發下,事務A執行到步驟2(扣減庫存,持有goods表行鎖),但因為步驟4有業務邏輯,沒及時提交
- 事務B也執行到步驟2,需要goods表的行鎖,開始等事務A釋放
- 事務C執行步驟1(創建訂單),持有orders表的行鎖,繼續執行步驟2時,也開始等事務A釋放goods表的鎖
- 此時,事務A可能因為某種原因(比如需要查詢訂單狀態),嘗試訪問事務C持有的orders表行鎖,而事務C在等事務A的goods表行鎖 —— 形成循環等待,死鎖產生
為什么會這樣:事務太長(步驟4耗時)導致鎖持有時間久,高并發下鎖競爭激烈,原本"單一方向"的等待可能因為"事務回查資源"變成循環等待。
三、死鎖排查:3個工具 + 1個核心日志
遇到死鎖別慌,MySQL自帶了排查工具,關鍵是要找到"死鎖時各事務持有了什么鎖、在等什么鎖"。掌握下面3個工具和1個核心日志,就能快速定位死鎖根因。
1. 實時查看死鎖:show engine innodb status
這是排查死鎖的"第一手資料",執行后能看到最近一次死鎖的詳細信息(包括事務ID、SQL語句、鎖信息等)。
操作步驟:在MySQL客戶端執行:
show engine innodb status\G;
關鍵信息解讀(以場景1的交叉更新為例):
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-05-20 10:00:00
*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 10, OS thread handle 1234, query id 5678 localhost root updating
UPDATE orders SET status = 'PAID' WHERE order_id = 1001*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`orders` trx id 12345 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 11, OS thread handle 5678, query id 5679 localhost root updating
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1*** (2) HOLDING THE LOCK(S):
RECORD LOCKS space id 10 page no 3 n bits 72 index PRIMARY of table `test`.`orders` trx id 12346 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 11 page no 4 n bits 72 index PRIMARY of table `test`.`product_stock` trx id 12346 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0*** WE ROLL BACK TRANSACTION (1)
重點看這幾點:
TRANSACTION 12345
和TRANSACTION 12346
:兩個死鎖的事務ID- 每個事務下的
UPDATE
語句:能看到事務在執行什么操作 WAITING FOR THIS LOCK TO BE GRANTED
:事務在等什么鎖(比如事務12345在等orders表的X鎖)HOLDING THE LOCK(S)
:事務已經持有什么鎖(比如事務12346已經持有orders表的X鎖)- 最后一行:MySQL選擇回滾哪個事務(這里回滾了事務12345)
通過這些信息,能快速還原"誰持有什么鎖、在等什么鎖",進而定位到業務代碼中的SQL順序問題。
2. 監控鎖等待:information_schema.innodb_locks和innodb_lock_waits
如果死鎖不是"實時發生",而是偶爾出現,可以通過這兩個系統表查看當前的"鎖持有情況"和"鎖等待情況"。
常用查詢語句:
-- 查看當前持有鎖的情況
SELECT * FROM information_schema.innodb_locks;-- 查看當前鎖等待的情況(誰在等誰的鎖)
SELECT r.trx_id waiting_trx_id, -- 等待鎖的事務IDr.trx_mysql_thread_id waiting_thread, -- 等待鎖的線程IDr.trx_query waiting_query, -- 等待鎖的SQLb.trx_id blocking_trx_id, -- 持有鎖的事務IDb.trx_mysql_thread_id blocking_thread, -- 持有鎖的線程IDb.trx_query blocking_query -- 持有鎖的SQL
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx b ON w.blocking_trx_id = b.trx_id
JOIN information_schema.innodb_trx r ON w.requesting_trx_id = r.trx_id;
結果解讀:innodb_lock_waits
的結果會清晰顯示"等待事務ID、等待的SQL、持有鎖的事務ID、持有鎖的SQL",比如場景1中,會看到"事務12345在等事務12346的鎖,事務12346在等事務12345的鎖",直接印證循環等待。
3. 開啟死鎖日志:innodb_print_all_deadlocks(推薦線上開啟)
show engine innodb status
只能看到"最近一次死鎖",如果死鎖頻繁發生,可能會覆蓋之前的記錄。開啟innodb_print_all_deadlocks
后,MySQL會把所有死鎖信息寫入錯誤日志(error log),方便后續分析。
開啟方式:
-
臨時開啟(重啟MySQL后失效):
set global innodb_print_all_deadlocks = 1;
-
永久開啟(修改my.cnf):
[mysqld] innodb_print_all_deadlocks = 1
日志位置:錯誤日志的位置可以通過show variables like 'log_error';
查看,通常在/var/log/mysql/error.log
(Linux)或C:\ProgramData\MySQL\MySQL Server 8.0\Data\XXX.err
(Windows)。
日志中會記錄每次死鎖的詳細信息(和show engine innodb status
的死鎖部分一致),方便追溯歷史死鎖。
四、死鎖預防:5個實戰方案(從代碼到配置)
排查死鎖是"事后補救",預防死鎖才是"治本之策"。結合前面的死鎖產生原因和條件,這5個方案能有效減少死鎖發生的概率。
1. 統一資源訪問順序(打破循環等待)
這是預防"交叉更新"死鎖最有效的方法 —— 讓所有事務操作同一批資源時,按固定的順序訪問。
比如場景1中,兩個事務都需要操作"庫存行"和"訂單行",可以約定"先操作庫存行,再操作訂單行",無論業務邏輯如何,都嚴格遵守這個順序:
事務1(用戶A支付):
-- 步驟1:先扣減庫存(固定順序第1步)
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1;
-- 步驟2:再更新訂單(固定順序第2步)
UPDATE orders SET status = 'PAID' WHERE order_id = 1001;
事務2(用戶B支付):
-- 步驟1:先扣減庫存(和事務1順序一致)
UPDATE product_stock SET stock = stock - 1 WHERE product_id = 1;
-- 步驟2:再更新訂單(和事務1順序一致)
UPDATE orders SET status = 'PAID' WHERE order_id = 1001;
這樣一來,事務2執行步驟1時,如果事務1已經持有庫存行的鎖,事務2會等待;但事務1執行步驟2時,不會再等事務2的鎖(因為事務2還在等步驟1的鎖),不會形成循環等待,死鎖就不會產生。
實操技巧:可以給資源(比如訂單ID、商品ID)排序,按"從小到大"或"從大到小"的順序訪問,比如"先操作ID小的行,再操作ID大的行"。
2. 減少鎖持有時間(打破"持有并等待")
事務持有鎖的時間越短,鎖競爭的概率就越低,死鎖也越難發生。核心是"讓事務盡快提交",避免在事務中做"非必要操作"。
優化方案:
- 把"耗時的業務邏輯"放到事務外執行(比如調用第三方接口、復雜計算)
- 事務中只做"必要的數據庫操作"(增刪改查),執行完立即提交
- 避免在事務中使用
SELECT ... FOR UPDATE
(行級鎖)做"預查詢",除非確實需要
反例→正例:
-- 反例:事務中包含耗時業務邏輯,鎖持有時間長
START TRANSACTION;
-- 步驟1:數據庫操作(拿鎖)
UPDATE goods SET stock = stock - 1 WHERE goods_id = 100;
-- 步驟2:耗時操作(調用第三方支付接口,耗時1秒)
-- 步驟3:數據庫操作
INSERT INTO orders (...) VALUES (...);
COMMIT;-- 正例:事務只包含數據庫操作,耗時操作放外面
-- 步驟1:先執行耗時操作(無鎖)
call third_party_pay(...);
-- 步驟2:事務中只做數據庫操作,快速提交
START TRANSACTION;
UPDATE goods SET stock = stock - 1 WHERE goods_id = 100;
INSERT INTO orders (...) VALUES (...);
COMMIT; -- 立即提交,釋放鎖
3. 避免間隙鎖(針對范圍查詢)
間隙鎖是InnoDB為了防止"幻讀"引入的,但在某些場景下(比如字段無索引、范圍查詢)會導致鎖范圍擴大,引發死鎖。可以通過以下方式減少間隙鎖:
- 給查詢字段加索引:InnoDB對有索引的字段做范圍查詢時,只會鎖定"存在的行",不會鎖定間隙(除非使用
FOR UPDATE
且是RR隔離級別) - 使用RC隔離級別:InnoDB在
READ COMMITTED
(RC)隔離級別下,會關閉間隙鎖(除了外鍵約束和唯一性檢查),減少鎖范圍 - 避免用"范圍條件"更新數據:如果能明確"主鍵/唯一索引條件",就別用
BETWEEN
、IN
等范圍條件
示例:
-- 反例:age無索引,范圍更新觸發間隙鎖
UPDATE user SET status = 1 WHERE age BETWEEN 10 AND 20;-- 正例1:給age加索引(減少間隙鎖范圍)
ALTER TABLE user ADD INDEX idx_age (age);-- 正例2:用RC隔離級別(關閉間隙鎖)
SET tx_isolation = 'READ-COMMITTED';-- 正例3:用具體條件代替范圍(如果業務允許)
UPDATE user SET status = 1 WHERE age = 10 OR age = 11 OR ...; -- 明確值
4. 合理設置事務隔離級別
InnoDB的默認隔離級別是REPEATABLE READ
(RR),會啟用間隙鎖;而READ COMMITTED
(RC)隔離級別下會關閉間隙鎖,雖然可能出現"不可重復讀",但在大部分業務場景下是可接受的,且能減少死鎖。
修改隔離級別:
-
臨時修改(當前會話):
set session transaction isolation level read committed;
-
永久修改(my.cnf):
[mysqld] transaction-isolation = READ-COMMITTED
注意:RC隔離級別下,SELECT ... FOR UPDATE
仍會鎖行,但不會鎖間隙,適合對"幻讀"要求不高的業務(如電商訂單、用戶管理)。
5. 主動重試死鎖事務(最后一道防線)
即使做了前面的預防措施,高并發下仍可能出現死鎖。此時可以在業務代碼中"捕獲死鎖異常",主動重試事務,減少對用戶的影響。
Java代碼示例:
// 重試次數
private static final int MAX_RETRY = 3;public void processOrder() {int retryCount = 0;while (retryCount < MAX_RETRY) {try {// 執行事務操作transactionTemplate.execute(status -> {updateStock(); // 扣減庫存updateOrderStatus(); // 更新訂單return null;});break; // 成功則跳出循環} catch (Exception e) {// 判斷是否是死鎖異常if (e.getMessage().contains("Deadlock found when trying to get lock")) {retryCount++;log.warn("發生死鎖,第{}次重試", retryCount);// 可選:短暫休眠,避免立即重試再次沖突Thread.sleep(100);} else {// 其他異常,直接拋出throw e;}}}
}
注意:重試次數不宜過多(建議3-5次),且重試間隔可以加個"隨機值"(比如50-200ms),避免多個事務同時重試再次沖突。
五、總結:死鎖處理的"黃金流程"
遇到死鎖不用慌,記住這個"黃金流程",從排查到解決一步到位:
- 查日志:用
show engine innodb status
或錯誤日志(開啟innodb_print_all_deadlocks
)獲取死鎖詳情,明確"各事務持有什么鎖、在等什么鎖" - 找原因:根據鎖信息和SQL語句,判斷是"交叉更新"(順序問題)、“間隙鎖”(范圍查詢問題)還是"長事務"(鎖持有時間問題)
- 做優化:按場景選方案 —— 交叉更新就統一訪問順序,間隙鎖就加索引或降隔離級別,長事務就減少鎖持有時間
- 留后手:代碼中捕獲死鎖異常,主動重試,降低用戶感知
死鎖的本質是"資源競爭下的循環等待",只要能打破死鎖的4個必要條件中的一個,就能有效減少死鎖。實際開發中,最優先做的是"統一資源訪問順序"和"減少鎖持有時間"—— 這兩個方案成本低、效果好,大部分死鎖都能通過這兩點解決。