跳轉到

MySQL死鎖處理筆記

起因

最近公司的線上POS系統的Log裡,不斷出現下面這個錯誤訊息

1213 - Deadlock found when trying to get lock; try restarting transaction
一直以來,我並沒有太過於在意這個問題,加上這陣子比較忙,這種情況出現的次數也不是很頻繁,所以我一直以來都是仰賴著MySQL 的死鎖檢測功能,讓MySQL自己去釋放鎖,而沒有繼續深入研究下去,今天剛好事情告一個段落,自己稍微研究一下看看能不能解決這個問題,順便做個簡單的筆記。

什麼是死鎖

以白話的角度來說

你正在一家餐廳用餐,突然發現隔壁桌的客人也想要你手中的那道菜。你們互相看著對方,誰也不願意放下筷子。這時,服務員過來說,這道菜只能有一個人享用,你們必須決出勝負。

在 MySQL 資料庫中,死鎖就是這樣發生的。兩個或多個事務,就像是餐廳裡的客人,都想爭奪同一份資料。當一個事務獲得了資料的鎖,其他事務就只能等待。如果兩個事務互相等待,就形成了死鎖。

MySQL死鎖的具體表現

  • 互斥條件
    • 在MySQL中,死鎖通常涉及到對資料庫表中行的鎖。事務為了保證資料的一致性和完整性,會對其所操作的資料行進行加鎖。當事務A鎖定了一行資料進行更新或刪除操作時,
  • 持有並等待
    • 事務A持有某些資源的鎖,並且請求獲取事務B持有的資源的鎖,但事務B同時也在請求獲取事務A或其他事務持有的資源的鎖。
  • 非剝奪
    • 在MySQL中,一旦事務獲得了對某行資料的鎖,該鎖在事務提交或回滾之前不會被強制剝奪。也就是說,只有事務自己才能釋放它所持有的鎖。
  • 迴圈等待
    • 存在一個事務等待鏈,其中每個事務都在等待另一個事務釋放資源。如果這種鏈條形成閉環,則形成了死鎖。

MySQL 如何處理死鎖

MySQL 採用以下兩種主要方式來處理死鎖:

自動死鎖檢測

  1. 自動死鎖檢測
    • InnoDB 引擎具有自動檢測死鎖並回滾其中一個事務以解鎖死局的能力。系統選擇犧牲的事務通常是基於以下兩個因素:
      • 事務完成的工作量:系統會優先回滾完成的工作量較小的事務。
      • 事務的優先級:如果事務設定了優先級,則系統會優先回滾優先級較低的事務。
      • MySQL 的自動死鎖檢測(innodb_deadlock_detect)是預設開啟的,若要確認可以使用SELECT @@innodb_deadlock_detect進行確認
      • 死鎖檢測預設是打開的,但是在高併發的場景下,死鎖檢測會影響效能(參考網址),因為需要不斷的檢查死鎖是否存在
  2. 鎖等待超時
    • MySQL 提供了一個名為 innodb_lock_wait_timeout 的配置參數(預設50秒),用於設定事務等待鎖資源的超時時間。如果一個事務等待鎖資源的時間超過了超時時間,則系統會自動回滾該事務,避免高併發場景時的資源浪費。

這邊有一個坑

在說明我踩得坑之前,先說說innodb_rollback_on_timeout這個參數在MySQL官方說明裡的描述。

MySQL官方說明

InnoDB rolls back only the last statement on a transaction timeout by default. If –innodb-rollback-on-timeout is specified, a transaction timeout causes InnoDB to abort and roll back the entire transaction.

InnoDB 在交易超時時,預設只會回滾最後一個語句。如果指定了 –innodb-rollback-on-timeout,交易超時會導致 InnoDB 中止並回滾整個交易。

innodb_rollback_on_timeout預設是off,因為僅會rollback最後一個語句,這會導致失去了MySQL的原子性(ACID) 舉一個例子來說,假設我們要進行一個轉帳操作,從 A 帳戶轉 100 元到 B 帳戶。這個操作可以拆分成兩個步驟:

從 A 帳戶扣除 100 元。 將 100 元加到 B 帳戶。 如果這兩個步驟都成功了,那麼轉帳操作就成功了,但是,如果其中一個步驟失敗了,那麼整個轉帳操作就應該失敗。

但是innodb_rollback_on_timeout=off的情況,變得有可能是100元沒有加到 B 帳戶,但是A帳戶卻扣了100元,除了這個問題外,由於只rollback最後一個語句(退回最後一個鎖申請),但是實際上你整個事務還是在執行的狀態,這將會導致下面這個狀況。

首先先建立一張表,以及初始化資料
CREATE TABLE my_table (
    id INT PRIMARY KEY,
    val VARCHAR(10)
);

INSERT INTO my_table (id, val) VALUES (1, 'init1'), (2, 'init2');
開啟兩個Session

Session 1 執行
START TRANSACTION;
UPDATE my_table SET val = 'xxx' WHERE id = 1;
Session 2 執行
START TRANSACTION;
UPDATE my_table SET val = 'yyy' WHERE id = 2;
接著返回 Session 1 執行
UPDATE my_table SET val = 'xxx' WHERE id = 2;

當死鎖檢測關閉的的情況下,當達到innodb_lock_wait_timeout所設定的超時時間時,因為innodb_rollback_on_timeout=off的緣故,導致僅會rollback最後一個語句,這造成這兩個Session無法釋放自己事務佔用的鎖,並且會持續執行下去。

手動解鎖

至於上面的問題,只能透過手動強制解鎖。

使用下面命令查詢當前運行的事務
select trx_state, trx_started, trx_mysql_thread_id, trx_query,trx_id from information_schema.innodb_trx;

能看見類似下面的內容

+-----------+---------------------+---------------------+-----------+---------+
| trx_state | trx_started         | trx_mysql_thread_id | trx_query | trx_id  |
+-----------+---------------------+---------------------+-----------+---------+
| RUNNING   | 2019-01-11 17:31:19 |                  15 | NULL      | 1294243 |
| RUNNING   | 2019-01-11 17:30:27 |                  14 | NULL      | 1294242 |
+-----------+---------------------+---------------------+-----------+---------+
2 rows in set (0.04 sec)
根據trx_mysql_thread_id,kill掉那個線程
kill 15;
kill 14;

死鎖Log查看

開始前,先用一個簡單的死鎖範例,重現死鎖場景,

最小重現MySQL死鎖範例

先建立兩張表,分別是table1table2,並且分別對這兩張表新增一筆資料。

建立table1和table2的資料表
CREATE TABLE table1 (
    id INT PRIMARY KEY,
    value VARCHAR(100)
);

CREATE TABLE table2 (
    id INT PRIMARY KEY,
    value VARCHAR(100)
);

INSERT INTO table1 (id, value) VALUES (1, 'A');
INSERT INTO table2 (id, value) VALUES (1, 'B');
開啟兩個命令列,用來模擬兩個連線,分別為Session 1Session 2

Session 1開啟事務
START TRANSACTION;
更新table1的表
UPDATE table1 SET value = 'A1' WHERE id = 1;

切換到 Session 2

Session 2開啟事務
START TRANSACTION;
更新table2的表
UPDATE table2 SET value = 'B1' WHERE id = 1;
在Session 2裡更新table1的表
UPDATE table1 SET value = 'A2' WHERE id = 1;

這邊會等待Session 1 釋放table1表的鎖

切回到 Session 1

Session 1上更新table2
UPDATE table2 SET value = 'B2' WHERE id = 1;

Session 1等待Session 2釋放table2上的鎖,但Session 2也在等待Session 1釋放table1上的鎖

Log閱讀

當發生死鎖後,可以透過SHOW ENGINE INNODB STATUS;指令查看LATEST DETECTED DEADLOCK查看最後一筆死鎖的原因,透過上面的範例,可以看到類似下面的資訊。

------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-01-11 16:20:59 0x2b11fbf89700
*** (1) TRANSACTION:
TRANSACTION 1290791, ACTIVE 31 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 5, OS thread handle 47356242081536, query id 70 localhost ::1 root updating
UPDATE table1 SET value = 'A2' WHERE id = 1
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 11541 page no 3 n bits 72 index PRIMARY of table `fb_db`.`table1` trx id 1290791 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 4; hex 80000001; asc     ;;
 1: len 6; hex 00000013af9b; asc       ;;
 2: len 7; hex 3b000002c70110; asc ;      ;;
 3: len 2; hex 4131; asc A1;;

*** (2) TRANSACTION:
TRANSACTION 1290139, ACTIVE 217 sec starting index read
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 2, OS thread handle 47356241811200, query id 71 localhost ::1 root updating
UPDATE table2 SET value = 'B2' WHERE id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 11541 page no 3 n bits 72 index PRIMARY of table `fb_db`.`table1` trx id 1290139 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 4; hex 80000001; asc     ;;
 1: len 6; hex 00000013af9b; asc       ;;
 2: len 7; hex 3b000002c70110; asc ;      ;;
 3: len 2; hex 4131; asc A1;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 11542 page no 3 n bits 72 index PRIMARY of table `fb_db`.`table2` trx id 1290139 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 4; hex 80000001; asc     ;;
 1: len 6; hex 00000013b227; asc      ';;
 2: len 7; hex 27000003721740; asc '   r @;;
 3: len 2; hex 4231; asc B1;;

*** WE ROLL BACK TRANSACTION (2)
------------
TRANSACTIONS
------------
Trx id counter 1290794
Purge done for trx's n:o < 1290794 undo n:o < 0 state: running but idle
History list length 0
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 328831001832160, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 328831001833984, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 328831001833072, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 328831001831248, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
--------
從Log裡可以看到

  • Transaction 1 嘗試更新 table1id = 1 的記錄,但它需要等待一個鎖的釋放,這個鎖當前被Transaction 2持有。
  • Transaction 2 同時嘗試更新 table2id = 1 的記錄,但它也需要等待一個鎖的釋放,這個鎖當前被Transaction 1持有。
  • *** WE ROLL BACK TRANSACTION (2),MySQL 自動選擇 RollBack Transaction 2

補充

另外,由於SHOW ENGINE INNODB STATUS;只能看到最後一次的死鎖資訊以及需要手動執行才行,如果要以Log的方式進行保存,可以開啟這個參數innodb_print_all_deadlocks,將錯誤訊息打印到Mysql的錯誤日誌中,可通過修改my.ini文件或者使用執行下面指令

--使用這個方式,如果資料庫進行重啟的話,會恢復原本的設定唷
set global innodb_print_all_deadlocks = 1;
因為my.ini的任何修改都需要再重新啟動才行,在生產環境都盡可避免資料庫重啟,所以我通常都是使用上面的方式開啟設定

最後

該怎麼說呢,雖然可以透過Log去查每個發生死鎖的SQL敘述,由於Log只會打印發生死鎖的SQL描述,而不是打印整個事務裡包含的所有SQL,所以可能或多或少還是去自己的寫的程式中去排查可能的問題。

另外,回到一開始的問題,公司的線上POS系統的Log裡出現死鎖的錯誤,主要是某個UPDATE語句上的WHERE條件有個條件式的索引沒有建立,當建立完後,就沒有發生死鎖的問題了。