解決死鎖之路(終結篇)- 再見死鎖


解決死鎖之路(終結篇)- 再見死鎖

一、開啟鎖監控

在遇到線上死鎖問題時,我們應該第一時間獲取相關的死鎖日志。我們可以通過 show engine innodb status 命令來獲取死鎖信息,但是它有個限制,只能拿到最近一次的死鎖日志。MySQL 提供了一套 InnoDb 的監控機制,用於周期性(每隔 15 秒)輸出 InnoDb 的運行狀態到 mysqld 服務的標准錯誤輸出(stderr)。默認情況下監控是關閉的,只有當需要分析問題時再開啟,並且在分析問題之后,建議將監控關閉,因為它對數據庫的性能有一定影響,另外每 15 秒輸出一次日志,會使日志文件變得特別大。

InnoDb 的監控主要分為四種:標准監控(Standard InnoDB Monitor)、鎖監控(InnoDB Lock Monitor)、表空間監控(InnoDB Tablespace Monitor)和表監控(InnoDB Table Monitor)。后兩種監控已經基本上廢棄了,關於各種監控的作用可以參考 MySQL 的官方文檔 Enabling InnoDB Monitors 或者 這篇文章。

要獲取死鎖日志,我們需要開啟 InnoDb 的標准監控,我推薦將鎖監控也打開,它可以提供一些額外的鎖信息,在分析死鎖問題時會很有用。開啟監控的方法有兩種:

1. 基於系統表

MySQL 使用了幾個特殊的表名來作為監控的開關,比如在數據庫中創建一個表名為 innodb_monitor 的表開啟標准監控,創建一個表名為 innodb_lock_monitor 的表開啟鎖監控。MySQL 通過檢測是否存在這個表名來決定是否開啟監控,至於表的結構和表里的內容無所謂。相反的,如果要關閉監控,則將這兩個表刪除即可。這種方法有點奇怪,在 5.6.16 版本之后,推薦使用系統參數的形式開啟監控。

-- 開啟標准監控
CREATE TABLE innodb_monitor (a INT) ENGINE=INNODB; -- 關閉標准監控 DROP TABLE innodb_monitor; -- 開啟鎖監控 CREATE TABLE innodb_lock_monitor (a INT) ENGINE=INNODB; -- 關閉鎖監控 DROP TABLE innodb_lock_monitor;

2. 基於系統參數

在 MySQL 5.6.16 之后,可以通過設置系統參數來開啟鎖監控,如下:

-- 開啟標准監控
set GLOBAL innodb_status_output=ON; -- 關閉標准監控 set GLOBAL innodb_status_output=OFF; -- 開啟鎖監控 set GLOBAL innodb_status_output_locks=ON; -- 關閉鎖監控 set GLOBAL innodb_status_output_locks=OFF;

另外,MySQL 提供了一個系統參數 innodb_print_all_deadlocks 專門用於記錄死鎖日志,當發生死鎖時,死鎖日志會記錄到 MySQL 的錯誤日志文件中。

set GLOBAL innodb_print_all_deadlocks=ON;

除了 MySQL 自帶的監控機制,還有一些有趣的監控工具也很有用,比如 Innotop 和 Percona Toolkit 里的小工具 pt-deadlock-logger。

二、讀懂死鎖日志

一切准備就緒之后,我們從 DBA 那里拿到了死鎖日志(其中的SQL語句做了省略):

------------------------ LATEST DETECTED DEADLOCK ------------------------ 2017-09-06 11:58:16 7ff35f5dd700 *** (1) TRANSACTION: TRANSACTION 182335752, ACTIVE 0 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 11 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 15 MySQL thread id 12032077, OS thread handle 0x7ff35ebf6700, query id 196418265 10.40.191.57 RW_bok_db update INSERT INTO bok_task ( order_id ... *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task` trx id 182335752 lock_mode X insert intention waiting *** (2) TRANSACTION: TRANSACTION 182335756, ACTIVE 0 sec inserting mysql tables in use 1, locked 1 11 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 15 MySQL thread id 12032049, OS thread handle 0x7ff35f5dd700, query id 196418268 10.40.189.132 RW_bok_db update INSERT INTO bok_task ( order_id ... *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task` trx id 182335756 lock_mode X *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task` trx id 182335756 lock_mode X insert intention waiting *** WE ROLL BACK TRANSACTION (2)

日志中列出了死鎖發生的時間,以及導致死鎖的事務信息(只顯示兩個事務,如果由多個事務導致的死鎖也只顯示兩個),並顯示出每個事務正在執行的 SQL 語句、等待的鎖以及持有的鎖信息等。下面我們就來研究下這份死鎖日志,看看從這份死鎖日志中能不能發現死鎖的原因?

首先看事務一的信息:

* (1) TRANSACTION: TRANSACTION 182335752, ACTIVE 0 sec inserting

ACTIVE 0 sec 表示事務活動時間,inserting 為事務當前正在運行的狀態,可能的事務狀態有:fetching rows,updating,deleting,inserting 等。

mysql tables in use 1, locked 1 LOCK WAIT 11 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 15

tables in use 1 表示有一個表被使用,locked 1 表示有一個表鎖。LOCK WAIT 表示事務正在等待鎖,11 lock struct(s) 表示該事務的鎖鏈表的長度為 11,每個鏈表節點代表該事務持有的一個鎖結構,包括表鎖,記錄鎖以及 autoinc 鎖等。heap size 1184 為事務分配的鎖堆內存大小。 2 row lock(s) 表示當前事務持有的行鎖個數,通過遍歷上面提到的 11 個鎖結構,找出其中類型為 LOCK_REC 的記錄數。undo log entries 15 表示當前事務有 15 個 undo log 記錄,因為二級索引不記 undo log,說明該事務已經更新了 15 條聚集索引記錄。

MySQL thread id 12032077, OS thread handle 0x7ff35ebf6700, query id 196418265 10.40.191.57 RW_bok_db update

事務的線程信息,以及數據庫 IP 地址和數據庫名,對我們分析死鎖用處不大。

INSERT INTO bok_task

( order_id ...

這里顯示的是正在等待鎖的 SQL 語句,死鎖日志里每個事務都只顯示一條 SQL 語句,這對我們分析死鎖很不方便,我們必須要結合應用程序去具體分析這個 SQL 之前還執行了哪些其他的 SQL 語句,或者根據 binlog 也可以大致找到一個事務執行的 SQL 語句。

* (1) WAITING FOR THIS LOCK TO BE GRANTED:

RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task` trx id 182335752 lock_mode X insert intention waiting

這里顯示的是事務正在等待什么鎖。RECORD LOCKS 表示記錄鎖(並且可以看出要加鎖的索引為 order_id_un),space id 為 300,page no 為 5480,n bits 552 表示這個記錄鎖結構上留有 552 個 bit 位(該 page 上的記錄數 + 64)。 lock_mode X 表示該記錄鎖為排他鎖,insert intention waiting 表示要加的鎖為插入意向鎖,並處於鎖等待狀態。

在上面有提到 innodb_status_output_locks 這個系統變量可以開啟 InnoDb 的鎖監控,如果開啟了,這個地方還會顯示出鎖的一些額外信息,包括索引記錄的 info bits 和數據信息等:

Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 0: len 4; hex 80000002; asc ;; 1: len 4; hex 80000001; asc ;;

在 《了解常見的鎖類型》 中我們說過,一共有四種類型的行鎖:記錄鎖,間隙鎖,Next-key 鎖和插入意向鎖。這四種鎖對應的死鎖日志各不相同,如下:

  • 記錄鎖(LOCK_REC_NOT_GAP): lock_mode X locks rec but not gap
  • 間隙鎖(LOCK_GAP): lock_mode X locks gap before rec
  • Next-key 鎖(LOCK_ORNIDARY): lock_mode X
  • 插入意向鎖(LOCK_INSERT_INTENTION): lock_mode X locks gap before rec insert intention

這里有一點要注意的是,並不是在日志里看到 lock_mode X 就認為這是 Next-key 鎖,因為還有一個例外:如果在 supremum record 上加鎖,locks gap before rec 會省略掉,間隙鎖會顯示成 lock_mode X,插入意向鎖會顯示成 lock_mode X insert intention。譬如下面這個:

RECORD LOCKS space id 0 page no 307 n bits 72 index `PRIMARY` of table `test`.`test` trx id 50F lock_mode X Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0

看起來像是 Next-key 鎖,但是看下面的 heap no 1 表示這個記錄是 supremum record(另外,infimum record 的 heap no 為 0),所以這個鎖應該看作是一個間隙鎖。

看完第一個事務,再來看看第二個事務:

* (2) TRANSACTION:

TRANSACTION 182335756, ACTIVE 0 sec inserting mysql tables in use 1, locked 1 11 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 15 MySQL thread id 12032049, OS thread handle 0x7ff35f5dd700, query id 196418268 10.40.189.132 RW_bok_db update INSERT INTO bok_task

( order_id ...

* (2) HOLDS THE LOCK(S): RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task` trx id 182335756 lock_mode X * (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 300 page no 5480 n bits 552 index `order_id_un` of table `bok_db`.`bok_task` trx id 182335756 lock_mode X insert intention waiting

事務二和事務一的日志基本類似,不過它多了一部分 HOLDS THE LOCK(S),表示事務二持有什么鎖,這個鎖往往就是事務一處於鎖等待的原因。這里可以看到事務二正在等待索引 order_id_un 上的插入意向鎖,並且它已經持有了一個 X 鎖(Next-key 鎖,也有可能是 supremum 上的間隙鎖)。

到這里為止,我們得到了很多關鍵信息,此時我們可以逆推出死鎖發生的原因嗎?這可能也是每個開發人員和 DBA 最關心的問題,如何通過死鎖日志來診斷死鎖的成因?實際上這是非常困難的。

如果每個事務都只有一條 SQL 語句,這種情況的死鎖成因還算比較好分析,因為我們可以從死鎖日志里找到每個事務執行的 SQL 語句,只要對這兩條 SQL 語句的加鎖過程有一定的了解,死鎖原因一般不難定位。但也有可能死鎖的成因非常隱蔽,這時需要我們對這兩條 SQL 語句的加鎖流程做非常深入的研究才有可能分析出死鎖的根源。

不過大多數情況下,每個事務都不止一條 SQL 語句,譬如上面的死鎖日志里顯示的 undo log entries 15,說明執行 INSERT 語句之前肯定還執行了其他的 SQL 語句,但是具體是什么,我們不得而知,我們只能根據 HOLDS THE LOCK(S) 部分知道有某個 SQL 語句對 order_id_un 索引加了 Next-key 鎖(或間隙鎖)。另外事務二在 WAITING FOR 插入意向鎖,至於它和事務一的哪個鎖沖突也不得而知,因為事務一的死鎖日志里並沒有 HOLDS THE LOCK(S) 部分。

所以,對死鎖的診斷不能僅僅靠死鎖日志,還應該結合應用程序的代碼來進行分析,如果實在接觸不到應用代碼,還可以通過數據庫的 binlog 來分析(只要你的死鎖不是 100% 必現,那么 binlog 日志里肯定能找到一份完整的事務一和事務二的 SQL 語句)。通過應用代碼或 binlog 理出每個事務的 SQL 執行順序,這樣分析死鎖時就會容易很多。

三、常見死鎖分析

盡管上面說通過死鎖日志來推斷死鎖原因非常困難,但我想也不是完全不可能。我在 Github 上新建了一個項目 mysql-deadlocks,這個項目收集了一些常見的 MySQL 死鎖案例,大多數案例都來源於網絡,並對它們進行分類匯總,試圖通過死鎖日志分析出每種死鎖的原因,還原出死鎖現場。這雖然有點痴人說夢的感覺,但還是希望能給后面的開發人員在定位死鎖問題時帶來一些便利。

我將這些死鎖按事務執行的語句和正在等待或已持有的鎖進行分類匯總(目前已經收集了十余種死鎖場景):

表中的語句雖然只列出了 delete 和 insert,但實際上絕大多數的 delete 語句和 update 或 select ... for update 加鎖機制是一樣的,所以為了避免重復,對於 update 語句就不在一起匯總了(當然也有例外,譬如使用 update 對索引進行更新時加鎖機制和 delete 是有區別的,這種情況我會單獨列出)。

對每一個死鎖場景,我都會定義一個死鎖名稱(實際上就是事務等待和持有的鎖),每一篇分析,我都分成了 死鎖特征、死鎖日志、表結構、重現步驟、分析和參考 這幾個部分。

對於這種分類方法我感覺並不是很好,但也想不出什么其他更好的方案,如果你有更好的建議,歡迎討論。另外,如果你有新的死鎖案例,或者對某個死鎖的解釋有異議,歡迎 給我提 Issue 或 PR。

下面我們介紹幾種常見的死鎖場景,還是以前面提到的 students 表為例:

其中,id 為主鍵,no(學號)為二級唯一索引,name(姓名)和 age(年齡)為二級非唯一索引,score(學分)無索引。數據庫隔離級別為 RR。

3.1 死鎖案例一

死鎖的根本原因是有兩個或多個事務之間加鎖順序的不一致導致的,這個死鎖案例其實是最經典的死鎖場景。

首先,事務 A 獲取 id = 20 的鎖(lock_mode X locks rec but not gap),事務 B 獲取 id = 30 的鎖;然后,事務 A 試圖獲取 id = 30 的鎖,而該鎖已經被事務 B 持有,所以事務 A 等待事務 B 釋放該鎖,然后事務 B 又試圖獲取 id = 20 的鎖,這個鎖被事務 A 占有,於是兩個事務之間相互等待,導致死鎖。

3.2 死鎖案例二

首先事務 A 和事務 B 執行了兩條 UPDATE 語句,但是由於 id = 25 和 id = 26 記錄都不存在,事務 A 和 事務 B 並沒有更新任何記錄,但是由於數據庫隔離級別為 RR,所以會在 (20, 30) 之間加上間隙鎖(lock_mode X locks gap before rec),間隙鎖和間隙鎖並不沖突。之后事務 A 和事務 B 分別執行 INSERT 語句要插入記錄 id = 25 和 id = 26,需要在 (20, 30) 之間加插入意向鎖(lock_mode X locks gap before rec insert intention),插入意向鎖和間隙鎖沖突,所以兩個事務互相等待,最后形成死鎖。

要解決這個死鎖很簡單,顯然,前面兩條 UPDATE 語句是無效的,將其刪除即可。另外也可以將數據庫隔離級別改成 RC,這樣在 UPDATE 的時候就不會有間隙鎖了。這個案例正是文章開頭提到的死鎖日志中的死鎖場景,別看這個 UPDATE 語句是無效的,看起來很傻,但是確實是真實的場景,因為在真實的項目中代碼會非常復雜,比如采用了 ORM 框架,應用層和數據層代碼分離,一般開發人員寫代碼時都不知道會生成什么樣的 SQL 語句,我也是從 DBA 那里拿到了 binlog,然后從里面找到了事務執行的所有 SQL 語句,發現其中竟然有一行無效的 UPDATE 語句,最后追本溯源,找到對應的應用代碼,將其刪除,從而修復了這個死鎖。

3.3 死鎖案例三

別看這個案例里每個事務都只有一條 SQL 語句,但是卻實實在在可能會導致死鎖問題,其實說起來,這個死鎖和案例一並沒有什么區別,只不過理解起來要更深入一點。要知道在范圍查詢時,加鎖是一條記錄一條記錄挨個加鎖的,所以雖然只有一條 SQL 語句,如果兩條 SQL 語句的加鎖順序不一樣,也會導致死鎖。

在案例一中,事務 A 的加鎖順序為:id = 20 -> 30,事務 B 的加鎖順序為:id = 30 -> 20,正好相反,所以會導致死鎖。這里的情景也是一樣,事務 A 的范圍條件為 id < 30,加鎖順序為:id = 15 -> 18 -> 20,事務 B 走的是二級索引 age,加鎖順序為:(age, id) = (24, 18) -> (24, 20) -> (25, 15) -> (25, 49),其中,對 id 的加鎖順序為 id = 18 -> 20 -> 15 -> 49。可以看到事務 A 先鎖 15,再鎖 18,而事務 B 先鎖 18,再鎖 15,從而形成死鎖。

3.4 如何避免死鎖

在工作過程中偶爾會遇到死鎖問題,雖然這種問題遇到的概率不大,但每次遇到的時候要想徹底弄懂其原理並找到解決方案卻並不容易。其實,對於 MySQL 的 InnoDb 存儲引擎來說,死鎖問題是避免不了的,沒有哪種解決方案可以說完全解決死鎖問題,但是我們可以通過一些可控的手段,降低出現死鎖的概率。

  1. 如上面的案例一和案例三所示,對索引加鎖順序的不一致很可能會導致死鎖,所以如果可以,盡量以相同的順序來訪問索引記錄和表。在程序以批量方式處理數據的時候,如果事先對數據排序,保證每個線程按固定的順序來處理記錄,也可以大大降低出現死鎖的可能;
  2. 如上面的案例二所示,Gap 鎖往往是程序中導致死鎖的真凶,由於默認情況下 MySQL 的隔離級別是 RR,所以如果能確定幻讀和不可重復讀對應用的影響不大,可以考慮將隔離級別改成 RC,可以避免 Gap 鎖導致的死鎖;
  3. 為表添加合理的索引,如果不走索引將會為表的每一行記錄加鎖,死鎖的概率就會大大增大;
  4. 我們知道 MyISAM 只支持表鎖,它采用一次封鎖技術來保證事務之間不會發生死鎖,所以,我們也可以使用同樣的思想,在事務中一次鎖定所需要的所有資源,減少死鎖概率;
  5. 避免大事務,盡量將大事務拆成多個小事務來處理;因為大事務占用資源多,耗時長,與其他事務沖突的概率也會變高;
  6. 避免在同一時間點運行多個對同一表進行讀寫的腳本,特別注意加鎖且操作數據量比較大的語句;我們經常會有一些定時腳本,避免它們在同一時間點運行;
  7. 設置鎖等待超時參數:innodb_lock_wait_timeout,這個參數並不是只用來解決死鎖問題,在並發訪問比較高的情況下,如果大量事務因無法立即獲得所需的鎖而掛起,會占用大量計算機資源,造成嚴重性能問題,甚至拖跨數據庫。我們通過設置合適的鎖等待超時閾值,可以避免這種情況發生。

總結

一開始是去年 9 月份的時候,線上某個系統遇到了一個死鎖問題,當時對這個死鎖百思不得其解,慢慢的從困惑到感興趣,雖然那時花了大概一個禮拜的時間研究后就已經把這個死鎖問題解決了,但是對死鎖的執念卻一直沒有放下,開始翻閱大量的文檔和資料,看 MySQL 官方文檔,買 MySQL 書籍,甚至去讀 MySQL 源碼,從事務、隔離級別、索引一直看到加鎖機制、死鎖分析等等。再到后來,突然覺得沒意思想放棄,感覺就算知道了什么語句加什么鎖也沒有多大意義,這個都是死記硬背的東西,花時間把這些規則背下來沒什么價值,還不如需要的時候自己去實驗一把也就知道了。中間歇了有兩個多月時間,后來想想不能半途而廢,逼自己一定要把這個系列寫完,最后一篇死鎖問題的分析肯定需要收集然后對這些死鎖問題進行大量的分析,於是去網上找了各種各樣的死鎖日志,然后一次一次的做實驗,這不收集還好,一收集便停不下來,這應該就是死鎖收藏癖吧。

對死鎖的研究前前后后燒了不少的腦細胞,特別是后期收集死鎖日志的時候,才發現死鎖場景各式各樣,有些死的很荒謬,有些死的很精妙,還有些死的不明不白,直到現在我還沒搞懂為什么。所有的這些死鎖案例都收集在 這里,如果你感興趣,歡迎來和我一起添磚加瓦,希望有一天,真的可以和死鎖說再見。

摘抄自:https://cloud.tencent.com/developer/article/1498086


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM