MySQL事務死鎖問題排查 | 京東雲技術團隊

一、背景

在預發環境中,由消息驅動最終觸發執行事務來寫庫存,但是導致MySQL發生死鎖,寫庫存失敗。

com.mysql.jdbc.exceptions.jdbc4.MySQLTransactionRollbackException: rpc error: code = Aborted desc = Deadlock found when trying to get lock; try restarting transaction (errno 1213) (sqlstate 40001) (CallerID: ): Sql: "/* uag::omni_stock_rw;xx.xx.xx.xx:xxxxx;xx.xx.xx.xx:xxxxx;xx.xx.xx.xx:xxxxx;enable */  insert into stock_info(tenant_id, sku_id, store_id, available_num, actual_good_num, order_num, created, modified, SAVE_VERSION, stock_id) values (:vtg1, :vtg2, :_store_id0, :vtg4, :vtg5, :vtg6, now(), now(), :vtg7, :__seq0) /* vtgate:: keyspace_id:e267ed155be60efe */", BindVars: {__seq0: "type:INT64 value:\"29332459\" "_store_id0: "type:INT64 value:\"50650235\" "vtg1: "type:INT64 value:\"71\" "vtg2: "type:INT64 value:\"113817631\" "vtg3: "type:INT64 value:\"50650235\" "vtg4: "type:FLOAT64 value:\"1000.000\" "vtg5: "type:FLOAT64 value:\"1000.000\" "vtg6: "type:INT64 value:\"0\" "vtg7: "type:INT64 value:\"20937611645\" "}

初步排查,在同一時刻有兩條請求進行寫庫存的操作。

時間前後相差1s,但最終執行結果是,這兩個事務相互死鎖,均失敗。

事務定義非常簡單,僞代碼描述如下:

start transaction
// 1、查詢數據
data = select for update(tenantId, storeId, skuId);
if (data == null) {
    // 插入數據
    insert(tenantId, storeId, skuId);
} else {
    // 更新數據
    update(tenantId, storeId, skuId);
}
end transaction

該數據庫表的索引結構如下:

索引類型 索引組成列
PRIMARY KEY (`stock_id`)
UNIQUE KEY (`sku_id`,`store_id`)

 

所使用的數據庫引擎爲Innodb,隔離級別爲RR[Repeatable Read]可重複讀。

二、分析思路

首先了解下Innodb引擎中有關於鎖的內容

2.1 Innodb中的鎖

2.1.1 行級鎖

在Innodb引擎中,行級鎖的實現方式有以下三種:

名稱 描述
Record Lock 鎖定單行記錄,在隔離級別RC和RR下均支持。
Gap Lock 間隙鎖,鎖定索引記錄間隙(不包含查詢的記錄),鎖定區間爲左開右開,僅在RR隔離級別下支持。
Next-Key Lock 臨鍵鎖,鎖定查詢記錄所在行,同時鎖定前面的區間,故區間爲左開右閉,僅在RR隔離級別下支持。

 

同時,在Innodb中實現了標準的行鎖,按照鎖定類型又可分爲兩類:

名稱 符號 描述
共享鎖 S 允許事務讀一行數據,阻止其他事務獲得相同的數據集的排他鎖。
排他鎖 X 允許事務刪除或更新一行數據,阻止其他事務獲得相同數據集的共享鎖和排他鎖。

 

簡言之,當某個事物獲取了共享鎖後,其他事物只能獲取共享鎖,若想獲取排他鎖,必須要等待共享鎖釋放;若某個事物獲取了排他鎖,則其餘事物無論獲取共享鎖還是排他鎖,都需要等待排他鎖釋放。如下表所示:

將獲取的鎖(下)\已獲取的鎖(右) 共享鎖S 排他鎖X
共享鎖S 兼容 不兼容
排他鎖X 不兼容 不兼容



2.1.2 RR隔離級別下加鎖示例

假如現在有這樣一張表user,下面將針對不同的查詢請求逐一分析加鎖情況。user表定義如下:

CREATE TABLE `user` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
  `user_id` bigint(20) DEFAULT NULL COMMENT '用戶id',
  `mobile_num` bigint(20) NOT NULL COMMENT '手機號',
  PRIMARY KEY (`id`),
  UNIQUE KEY `IDX_USER_ID` (`user_id`),
  KEY `IDX_MOBILE_NUM` (`mobile_num`)  
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用戶信息表'

其中主鍵id與user_id爲唯一索引,user_name爲普通索引。

假設該表中現有數據如下所示:

id user_id mobile_num
1 1 3
5 5 6
8 8 7
9 9 9



下面將使用select ... for update 語句進行查詢,分別針對唯一索引、普通索引來進行舉例。

1、唯一索引等值查詢

select * from user
where id = 5 for update

select * from user
where user_id = 5 for update

在這兩條SQL中,Innodb執行查詢過程時,會如何加鎖呢?

我們都知道Innodb默認的索引數據結構爲B+樹,B+樹的葉子結點包含指向下一個葉子結點的指針。在查詢過程中,會按照B+樹的搜索方式來進行查找,其底層原理類似二分查找。故在加鎖過程中會按照以下兩條原則進行加鎖:

1. 只會對滿足查詢目標附近的區間加鎖,並不是對搜索路徑中的所有區間都加鎖。本例中對搜索id=5或者user_id=5時,最終可以定位到滿足該搜索條件的區域(1,5]。
2. 加鎖時,會以Next key Lock爲加鎖單位。那按照1滿足的區域進行加Next key Lock鎖(左開右閉),同時因爲id=5或者user_id=5存在,所以該Next key Lock會退化爲Record Lock,故只對id=5或user_id=5這個索引行加鎖。



如果查詢的id不存在,例如:

select * from user
where id = 6 for update

按照上面兩條原則,首先按照滿足查詢目標條件附近區域加鎖,所以最終會找到的區間爲(5,8]。因爲id=6這條記錄並不存在,所以Next key Lock(5, 8]最終會退化爲Gap Lock,即對索引(5,8)加間隙鎖。

2、唯一索引範圍查詢

select * from user
where id >= 4 and id <8 for update

同理,在範圍查詢中,會首先匹配左值id=4,此時會對區間(1,5]加Next key Lock,因爲id=4不存在,所以鎖退化爲 Gap Lock(1,5);接着會往後繼續查找id=8的記錄,直到找到第一個不滿足的區間,即Next key Lock(8, 9],因爲8不在範圍內,所以鎖退化爲Gap Lock(8, 9)。故該範圍查詢最終會鎖的區域爲(1, 9)

3、非唯一索引等值查詢

對非唯一索引查詢時,與上述的加鎖方式稍有區別。除了要對包含查詢值區間內加Next key Lock之外,還要對不滿足查詢條件的下一個區間加Gap Lock,也就是需要加兩把鎖。

select * from user
where mobile_num = 6 for update

需要對索引(3, 6]加Next key Lock,因爲此時是非唯一索引,那麼也就有可能有多個6存在,所以此時不會退化爲Record Lock;此外還要對不滿足該查詢條件的下一個區間加Gap Lock,也就是對索引(6,7)加鎖。故總體來看,對索引加了(3,6]Next key Lock和(6, 7) Gap Lock。

若非唯一索引不命中時,如下:

select * from user 
where mobile_num = 8 for update

那麼需要對索引(7, 9]加Next key Lock,又因爲8不存在,所以鎖退化爲Gap Lock (7, 9)

4、非唯一索引範圍查詢

select * from user
where mobile_num >= 6 and mobile_num < 8
for update 

首先先匹配mobile_num=6,此時會對索引(3, 6]加Next Key Lock,雖然此時非唯一索引存在,但是不會退化爲Record Lock;其次再看後半部分的查詢mobile_num=8,需要對索引(7, 9]加Next key Lock,又因爲8不存在,所以退化爲Gap Lock (7, 9)。最終,需要對索引行加Next key Lock(3, 6] 和 Gap Lock(7, 9)。

2.1.3 意向鎖(Intention Locks

Innodb爲了支持多粒度鎖定,引入了意向鎖。意向鎖是一種表級鎖,用於表明事務將要對某張表某行數據操作而進行的鎖定。同樣,意向鎖也分爲類:共享意向鎖(IS)和排他意向鎖(IX)。

名稱 符號 描述
共享意向鎖 IS 表明事務將要對錶的個別行設置共享鎖
排他意向鎖 IX 表明事務將要對錶的個別行設置排他鎖

例如select ... lock in shared mode會設置共享意向鎖IS;select ... for update會設置排他意向鎖IX

設置意向鎖時需要按照以下兩條原則進行設置:

1. 當事務需要申請共享鎖S時,必須先對申請共享意向IS鎖或更強的鎖
2. 當事務需要申請排他鎖X時,必須先對申請排他意向IX鎖

表級鎖兼容性矩陣如下表:

將獲取的鎖(下)/已獲取的鎖(右) X IX S IS
X 衝突 衝突 衝突 衝突
IX 衝突 兼容 衝突 兼容
S 衝突 衝突 兼容 兼容
IS 衝突 兼容 兼容 兼容

如果請求鎖的事務與現有鎖兼容,則會將鎖授予該事務,但如果與現有鎖衝突,則不會授予該事務。事務等待,直到衝突的現有鎖被釋放。

意向鎖的目的就是爲了說明事務正在對錶的一行進行鎖定,或將要對錶的一行進行鎖定。在意向鎖概念中,除了對全表加鎖會導致意向鎖阻塞外,其餘情況意向鎖均不會阻塞任何請求!

2.1.4 插入意向鎖

插入意向鎖是一種特殊的意向鎖,同時也是一種特殊的“Gap Lock”,是在Insert操作之前設置的Gap Lock。

如果此時有多個事務執行insert操作,恰好需要插入的位置都在同一個Gap Lock中,但是並不是在Gap Lock的同一個位置時,此時的插入意向鎖彼此之間不會阻塞。

2.2 過程分析

回到本文的問題上來,本文中有兩個事務執行同樣的動作,分別爲先執行select ... for update獲取排他鎖,其次判斷若爲空,則執行insert動作,否則執行update動作。僞代碼描述如下:

start transaction
// 1、查詢數據
data = select for update(tenantId, storeId, skuId);
if (data == null) {
    // 插入數據
    insert(tenantId, storeId, skuId);
} else {
    // 更新數據
    update(tenantId, storeId, skuId);
}
end transaction

現在對這兩個事務所執行的動作進行逐一分析,如下表所示:

時間點 事務A 事務B 潛在動作
1 開始事務 開始事務 
2 執行select ... for update操作  事務A申請到IX 事務A申請到X,Gap Lock
3  執行select ... for update操作 事務B申請到IX,與事務A的IX不衝突。 事務B申請到Gap Lock,Gap Lock可共存。
4 執行insert操作  事務A先申請插入意向鎖IX,與事務B的Gap Lock衝突,等待事務B的Gap Lock釋放。
5  執行insert操作 事務B先申請插入意向鎖IX,與事務A的Gap Lock衝突,等待事務A的Gap Lock釋放。
6   死鎖檢測器檢測到死鎖

詳細分析:

  • 時間點1,事務A與事務B開始執行事務
  • 時間點2,事務A執行select ... for update操作,執行該操作時首先需要申請意向排他鎖IX作用於表上,接着申請到了排他鎖X作用於區間,因爲查詢的值不存在,故Next key Lock退化爲Gap Lock。
  • 時間點3,事務B執行select ... for update操作,首先申請意向排他鎖IX,根據2.1.3節表級鎖兼容矩陣可以看到,意向鎖之間是相互兼容的,故申請IX成功。由於查詢值不存在,故可以申請X的Gap Lock,而Gap Lock之間是可以共存的,不論是共享還是排他。這一點可以參考Innodb關於Gap Lock的描述,關鍵描述本文粘貼至此:
Gap locks can co-exist. A gap lock taken by one transaction does not prevent another transaction from taking a gap lock on the same gap. There is no difference between shared and exclusive gap locks. They do not conflict with each other, and they perform the same function.

  • 時間點4,事務A執行insert操作前,首先會申請插入意向鎖,但此時事務B已經擁有了插入區間的排他鎖,根據2.1.3節表級鎖兼容矩陣可知,在已有X鎖情況下,再次申請IX鎖是衝突的,需要等待事務B對X Gap Lock釋放。
  • 時間點5,事務B執行insert操作前,也會首先申請插入意向鎖,此時事務A也對插入區間擁有X Gap Lock,因此需要等待事務A對X鎖進行釋放。
  • 時間點6,事務A與事務B均在等待對方釋放X鎖,後被MySQL的死鎖檢測器檢測到後,報Dead Lock錯誤。

思考:假如select ... for update 查詢的數據存在時,會是什麼樣的過程呢?過程如下表:

時間點 事務A 事務B 潛在動作
1 開始事務 開始事務 
2 執行select ... for update操作  事務A申請到IX 事務A申請到X行鎖,因數據存在故鎖退化爲Record Lock。
3  執行select ... for update操作 事務B申請到IX,與事務A的IX不衝突。 事務B想申請目標行的Record Lock,此時需要等待事務A釋放該鎖資源。
4 執行update操作  事務A先申請插入意向鎖IX,此時事務B僅僅擁有IX鎖資源,兼容,不衝突。然後事務A擁有X的Record Lock,故執行更新。
5 commit  事務A提交,釋放IX與X鎖資源。
6  執行select ... for update操作 事務B事務B此時獲取到X Record Lock。
7  執行update操作 事務B擁有X Record Lock執行更新
8  commit 事務B釋放IX與X鎖資源

也就是當查詢數據存在時,不會出現死鎖問題。

三、解決方法

1、在事務開始之前,採用CAS+分佈式鎖來控制併發寫請求。分佈式鎖key可以設置爲store_skuId_version

2、事務過程可以改寫爲:

start transaction
// RR級別下,讀視圖
data = select from table(tenantId, storeId, skuId)
if (data == null) {
    // 可能出現寫併發
    insert
} else {
    data = select for update(tenantId, storeId, skuId)
    update
}
end transaction

雖然解決了插入數據不存在時會出現的死鎖問題,但是可能存在併發寫的問題,第一個事務獲得鎖會首先插入成功,第二個事務等待第一個事務提交後,插入數據,因爲數據存在了所以報錯回滾。

3、調整事務隔離級別爲RC,在RC下沒有next key lock(注意,此處並不準確,RC會有少部分情況加Next key lock),故此時僅僅會有record lock,所以事務2進行select for update時需要等待事務1提交。

參考文獻

[1] Innodb鎖官方文檔:https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html

[2] https://blog.csdn.net/qq_43684538/article/details/131450395

[3] https://www.jianshu.com/p/027afd6345d5

[4] https://www.cnblogs.com/micrari/p/8029710.html

若有錯誤,還望批評指正

作者:京東零售  劉哲

來源:京東雲開發者社區 轉載請註明來源

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