MySQL 死鎖案例解析一則

原文鏈接:https://www.modb.pro/db/448666

一、問題背景
某業務模塊反饋數據庫最近出現過幾次死鎖告警的情況,本文總結了這次死鎖排查的全過程,並分析了導致死鎖的原因及解決方案。
希望給大家提供一個死鎖的排查及解決思路。
基礎環境:

  • 主機類型:x3850 X6
  • 操作系統:DB:CentOS Linux release 7.4.1708、APP:CentOS Linux release 7.2.1511 (Core)
  • 存儲:IBM存儲,2TB,MULTIPATH
  • 內存:64 G
  • CPU型號:E7-4830 v3 @ 2.10GHz ( 4 U * 12 core)
  • CPU核數:32CORE
  • 數據庫環境:5.7.27
  • 事務隔離級別:READ-COMMITED

問題現象:MySQL 死鎖告警
告警日誌:
{"errorCode":"SYSTEM_ERROR","errorMsg":"nested exception is org. apache. ibatis.exceptions.PersistenceException:  
Error updating database. Cause: ERR-CODE: [TDDL-4614 [ERR_EXECUTE_ON_MYSQL]
Deadlock found when trying to get lock;
The error occurred while setting parameters\n### SQL:
update A xxx   

二、分析說明
  • 通過分析日誌定位、分析死鎖原因;
  • 追溯歷史數據,分析關鍵指標的歷史波動,這些關鍵指標可以用來做爲數據庫健康度參考指標。
  • 用實際數據來驗證推斷,排除掉其它干擾因素,定位數據庫問題的根本原因,幫助快速修復。

三、MySQL加鎖機制
在深入探究問題之前,我們先了解一下 MySQL 的加鎖機制。
首先要明確的一點是 MySQL 加鎖實際上是給索引加鎖,而非給數據加鎖。我們先看下MySQL 索引的結構。
MySQL 索引分爲主鍵索引(或聚簇索引)和二級索引(或非主鍵索引、非聚簇索引、輔助索引,包括各種主鍵索引外的其他所有索引)。不同存儲引擎對於數據的組織方式略有不同。
對InnoDB而言,主鍵索引和數據是存放在一起的,構成一顆B+樹(稱爲索引組織表),主鍵位於非葉子節點,數據存放於葉子節點。示意圖如下:

而MyISAM是堆組織表,主鍵索引和數據分開存放,葉子節點保存的只是數據的物理地址,示意圖如下:

二級索引的組織方式對於InnoDB和MyISAM是一樣的,保存了二級索引和主鍵索引的對應關係,二級索引列位於非葉子節點,主鍵值位於葉子節點,示意圖如下:

那麼在MySQL 的這種索引結構下,我們怎麼找到需要的數據呢?
以select * from t where name='aaa'爲例,MySQL Server對sql進行解析後發現name字段有索引可用,於是先在二級索引(圖2-2)上根據name='aaa'找到主鍵id=17,然後根據主鍵17到主鍵索引上(圖2-1)上找到需要的記錄。
瞭解 MySQL 利用索引對數據進行組織和檢索的原理後,接下來看下MySQL 如何給索引枷鎖。
需要了解的是索引如何加鎖和索引類型(主鍵、唯一、非唯一、沒有索引)以及隔離級別(RC、RR等)有關。本例中限定隔離級別爲RC,RR情況下和RC加鎖基本一致,不同的是RC爲了防止幻讀會額外加上間隙鎖。
2.1  根據主鍵進行更新
update t set name='xxx' where id=29;只需要將主鍵上id=29的記錄加上X鎖即可(X鎖稱爲互斥鎖,加鎖後本事務可以讀和寫,其他事務讀和寫會被阻塞)。如下:

2.2  根據唯一索引進行更新
update t set name='xxx' where name='ddd';這裏假設name是唯一的。InnoDB現在name索引上找到name='ddd'的索引項(id=29)並加上加上X鎖,然後根據id=29再到主鍵索引上找到對應的葉子節點並加上X鎖。
一共兩把鎖,一把加在唯一索引上,一把加在主鍵索引上。這裏需要說明的是加鎖是一步步加的,不會同時給唯一索引和主鍵索引加鎖。這種分步加鎖的機制實際上也是導致死鎖的誘因之一。示意如下:

2.3 根據非唯一索引進行更新
update t set name='xxx' where name='ddd';這裏假設name不唯一,即根據name可以查到多條記錄(id不同)。和上面唯一索引加鎖類似,不同的是會給所有符合條件的索引項加鎖。示意如下:

這裏一共四把鎖,加鎖步驟如下:

1、在非唯一索引(name)上找到(ddd,29)的索引項,加上X鎖;

2、根據(ddd,29)找到主鍵索引的(29,ddd)記錄,加X鎖;

3、在非唯一索引(name)上找到(ddd,37)的索引項,加上X鎖;

4、根據(ddd,29)找到主鍵索引的(37,ddd)記錄,加X鎖;


從上面步驟可以看出,InnoDB對於每個符合條件的記錄是分步加鎖的,即先加二級索引再加主鍵索引;其次是按記錄逐條加鎖的,即加完一條記錄後,再加另外一條記錄,直到所有符合條件的記錄都加完鎖。那麼鎖什麼時候釋放呢?答案是事務結束時會釋放所有的鎖。

小結:MySQL 加鎖和索引類型有關,加鎖是按記錄逐條加,另外加鎖也和隔離級別有關。
四、疑問點排查及分析思路
1、發生死鎖的表結構及索引情況(隱去了部分無關字段和索引):
如下:
CREATE TABLE `A` (  
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵', 
  `create_date` datetime NOT NULL , 
  `modified_date` datetime NOT NULL ,  
  `pay_name` varchar(256) NOT NULL ,  
  `pay_version` varchar(256) DEFAULT NULL , 
`identifier` varchar(256) NOT NULL , 
  `seller_id` varchar(64) NOT NULL , 
`state` varchar(64) DEFAULT NULL , 
  `fund_transfer_ order_ no` varchar(256) DEFAULT NULL,  
  PRIMARY KEY (`id`),UNIQUE KEY `uk_scene_identifier`
  (KEY `idx_seller` (`seller_id`), 
KEY `idx_seller_transNo` (`seller_id`,`fund_transfer_order_no`(20)) 
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ; 
該表共有三個索引,1個主鍵索引,2個普通索引。
2、分析死鎖日誌
發生死鎖,第一時間查看死鎖日誌,內容如下:
Transactions deadlock detected, dumping detailed information. 
2021-05-19T21:44:23.516263+08:00 5877341 [Note] InnoDB:  
*** (1) TRANSACTION: 
TRANSACTION 173268495, ACTIVE 0 sec fetching rows 
mysql tables in use 1, locked 1
LOCK WAIT 304 lock struct(s), heap size 41168, 6 row lock(s), undo log entries 1
MySQL thread id 5877358, OS thread handle 47356539049728, query id 557970181 11.183.244.150 fin_instant_app updating 
update 死鎖語句
2021-05-19T21:44:23.516321+08:00 5877341 [Note] InnoDB:  
*** (1) HOLDS THE LOCK(S): 
RECORD LOCKS space id 173 page no 13726 n bits 248 index idx_seller_transNo of table `xxx`.`fund_transfer_stream` trx id 173268495 lock_mode X locks rec but not gap 
Record lock, heap no 168 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
2021-05-19T21:44:23.516565+08:00 5877341 [Note] InnoDB:  
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: 
RECORD LOCKS space id 173 page no 12416 n bits 128 index PRIMARY of table `xxx`.`fund_transfer_stream` trx id 173268495 lock_mode X locks rec but not gap waiting 
Record lock, heap no 56 PHYSICAL RECORD: n_fields 17; compact format; info bits 0
2021-05-19T21:44:23.517793+08:00 5877341 [Note] InnoDB:  
*** (2) TRANSACTION: 
TRANSACTION 173268500, ACTIVE 0 sec fetching rows, thread declared inside InnoDB 81
mysql tables in use 1, locked 1
302 lock struct(s), heap size 41168, 2 row lock(s), undo log entries 1
MySQL thread id 5877341, OS thread handle 47362313119488, query id 557970189 11.131.81.107 fin_instant_app updating 
update 死鎖語句
分析下死鎖日誌,可以得到以下信息:
  1. 導致死鎖的兩條SQL語句。
  2. 事務1,持有索引idx_seller_transNo的鎖,在等待獲取PRIMARY的鎖。
  3. 事務2,持有PRIMARY的鎖,在等待獲取idx_seller_transNo的鎖。
  4. 因事務1和事務2之間發生循環等待,故發生死鎖。
  5. 事務1和事務2當前持有的鎖均爲:lock_mode X locks rec but not gap

兩個事務對記錄加的都是X 鎖,No Gap鎖,即對當行記錄加鎖(Record Lock),並未加間隙鎖。
3、常見鎖類型
X鎖:排他鎖、又稱寫鎖。若事務T對數據對象A加上X鎖,事務T可以讀A也可以修改A,其他事務不能再對A加任何鎖,直到T釋放A上的鎖。這保證了其他事務在T釋放A上的鎖之前不能再讀取和修改A。
與之對應的是S鎖:共享鎖,又稱讀鎖,若事務T對數據對象A加上S鎖,則事務T可以讀A但不能修改A,其他事務只能再對A加S鎖,而不能加X鎖,直到T釋放A上的S鎖。這保證了其他事務可以讀A,但在T釋放A上的S鎖之前不能對A做任何修改。
Gap Lock:間隙鎖,鎖定一個範圍,但不包括記錄本身。GAP鎖的目的,是爲了防止同一事務的兩次當前讀,出現幻讀的情況。
Next-Key Lock:1+2,鎖定一個範圍,並且鎖定記錄本身。對於行的查詢,都是採用該方法,主要目的是解決幻讀的問題。
根據目前掌握的信息,可以做一些簡單的推斷。
首先,此次死鎖一定是和Gap鎖以及Next-Key Lock沒有關係的。因爲數據庫隔離級別是RC(READ-COMMITED)的,這種隔離級別是不會添加Gap鎖的。前面的死鎖日誌也提到這一點。
然後,就要翻代碼了,看看代碼中事務到底是怎麼做的。核心代碼及SQL如下:
@Transactional(rollbackFor = Exception.class)
public int doProcessing(String sellerId, Long id, String fundTransferOrderNo) { 
    fundTreansferStreamDAO.updateFundStreamId(sellerId, id, fundTransferOrderNo); 
return fundTreansferStreamDAO.updateStatus(sellerId, fundTransferOrderNo,"PROCESSING"); 
}
該代碼的目的是先後修改同一條記錄的兩個不同字段,同一個事務中執行了兩條Update語句,再分別查看下兩條SQL的執行計劃:分別用到了PRIMARY索引和idx_seller_transNo索引。
有了以上這些已知信息,就可以開始排查死鎖原因及其背後的原理了。
通過分析死鎖日誌,再結合代碼以及建表語句,發現主要問題出在idx_seller_transNo索引上面:
KEY `idx_seller_transNo` (`seller_id`,`fund_transfer_order_no`(20))
索引創建語句中,使用了前綴索引,爲了節約索引空間,提高索引效率,只選擇了fund_transfer_order_no字段的前20位作爲索引值。
因爲fund_transfer_order_no只是普通索引,而非唯一性索引。又因爲在一種特殊情況下,會有同一個用戶的兩個fund_transfer_order_no的前20位相同,這就導致兩條不同的記錄的索引值一樣(因爲seller_id 和fund_transfer_order_no(20)都相同 )。
那麼爲什麼fund_transfer_order_no的前20位相同會導致死鎖呢?
我們知道,在MySQL中,行級鎖並不是直接鎖記錄,而是鎖索引。索引分爲主鍵索引和非主鍵索引兩種,如果一條sql語句操作了主鍵索引,MySQL就會鎖定這條主鍵索引;如果一條語句操作了非主鍵索引,MySQL會先鎖定該非主鍵索引,再鎖定相關的主鍵索引。
  • 主鍵索引的葉子節點存的是整行數據。在InnoDB中,主鍵索引也被稱爲聚簇索引(clustered index)。
  • 非主鍵索引的葉子節點的內容是主鍵的值,在InnoDB中,非主鍵索引也被稱爲非聚簇索引(secondary index)。

死鎖的發生與否,並不在於事務中有多少條SQL語句,死鎖的關鍵在於:兩個(或以上)的Session加鎖的順序不一致。
事務在以非主鍵索引爲where條件進行Update的時候,會先對該非主鍵索引加鎖,然後再查詢該非主鍵索引對應的主鍵索引都有哪些,再對這些主鍵索引進行加鎖。
五、解決方案解決方案至此,我們分析清楚了導致死鎖的根本原理以及其背後的原理。那麼這個問題解決起來就不難了。
可以從兩方面入手,分別是修改索引和修改代碼(包含SQL語句)。
修改索引:只要我們把前綴索引 idx_seller_transNo中fund_transfer_order_no的前綴長度修改下就可以了。比如改成50。即可避免死鎖。
但是,改了idx_seller_transNo的前綴長度後,可以解決死鎖的前提條件是update語句真正執行的時候,會用到fund_transfer_order_no索引。如果MySQL查詢優化器在代價分析之後,決定使用索引 KEY idx_seller(seller_id),那麼還是會存在死鎖問題。原理和本文類似。
所以,根本解決辦法就是改代碼:
  • 所有update都通過主鍵ID進行。
  • 在同一個事務中,避免出現多條update語句修改同一條記錄。

其他思考

在死鎖發生之後的一週內,前前後後做過很多中種推斷及假設,最終還是要靠實踐來驗證自己的想法。遇到問題,不要想當然,親手復現下問題,然後再來分析。
通過本文,大家可以掌握死鎖分析的基本理論和一般方法,希望能爲大家工作中快速解決實際出現的死鎖問題提供思路。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章