一、背景
在預(yù)發(fā)環(huán)境中,由消息驅(qū)動(dòng)最終觸發(fā)執(zhí)行事務(wù)來(lái)寫(xiě)庫(kù)存,但是導(dǎo)致 MySQL 發(fā)生死鎖,寫(xiě)庫(kù)存失敗。
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" "}
初步排查,在同一時(shí)刻有兩條請(qǐng)求進(jìn)行寫(xiě)庫(kù)存的操作。
??時(shí)間前后相差 1s,但最終執(zhí)行結(jié)果是,這兩個(gè)事務(wù)相互死鎖,均失敗。
事務(wù)定義非常簡(jiǎn)單,偽代碼描述如下:
start transaction
// 1、查詢數(shù)據(jù)
data = select for update(tenantId, storeId, skuId);
if (data == null) {
// 插入數(shù)據(jù)
insert(tenantId, storeId, skuId);
} else {
// 更新數(shù)據(jù)
update(tenantId, storeId, skuId);
}
end transaction
該數(shù)據(jù)庫(kù)表的索引結(jié)構(gòu)如下:
索引類型索引組成列PRIMARY KEY(`stock_id`)UNIQUE KEY(`sku_id`,`store_id`)
所使用的數(shù)據(jù)庫(kù)引擎為 Innodb,隔離級(jí)別為 RR [Repeatable Read] 可重復(fù)讀。?
二、分析思路
首先了解下 Innodb 引擎中有關(guān)于鎖的內(nèi)容
2.1 Innodb 中的鎖
2.1.1 行級(jí)鎖
在 Innodb 引擎中,行級(jí)鎖的實(shí)現(xiàn)方式有以下三種:
名稱描述Record Lock鎖定單行記錄,在隔離級(jí)別 RC 和 RR 下均支持。Gap Lock間隙鎖,鎖定索引記錄間隙(不包含查詢的記錄),鎖定區(qū)間為左開(kāi)右開(kāi),僅在 RR 隔離級(jí)別下支持。Next-Key Lock臨鍵鎖,鎖定查詢記錄所在行,同時(shí)鎖定前面的區(qū)間,故區(qū)間為左開(kāi)右閉,僅在 RR 隔離級(jí)別下支持。
同時(shí),在 Innodb 中實(shí)現(xiàn)了標(biāo)準(zhǔn)的行鎖,按照鎖定類型又可分為兩類:
名稱符號(hào)描述共享鎖S允許事務(wù)讀一行數(shù)據(jù),阻止其他事務(wù)獲得相同的數(shù)據(jù)集的排他鎖。排他鎖X允許事務(wù)刪除或更新一行數(shù)據(jù),阻止其他事務(wù)獲得相同數(shù)據(jù)集的共享鎖和排他鎖。
簡(jiǎn)言之,當(dāng)某個(gè)事物獲取了共享鎖后,其他事物只能獲取共享鎖,若想獲取排他鎖,必須要等待共享鎖釋放;若某個(gè)事物獲取了排他鎖,則其余事物無(wú)論獲取共享鎖還是排他鎖,都需要等待排他鎖釋放。如下表所示:
將獲取的鎖(下) 已獲取的鎖(右)共享鎖 S排他鎖 X共享鎖 S兼容不兼容排他鎖 X不兼容不兼容
2.1.2 RR 隔離級(jí)別下加鎖示例
假如現(xiàn)在有這樣一張表 user,下面將針對(duì)不同的查詢請(qǐng)求逐一分析加鎖情況。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 '手機(jī)號(hào)',
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 為普通索引。
假設(shè)該表中現(xiàn)有數(shù)據(jù)如下所示:
iduser_idmobile_num113556887999
下面將使用 select ... for update 語(yǔ)句進(jìn)行查詢,分別針對(duì)唯一索引、普通索引來(lái)進(jìn)行舉例。
1、唯一索引等值查詢
select * from user
where id = 5 for update
select * from user
where user_id = 5 for update
在這兩條 SQL 中,Innodb 執(zhí)行查詢過(guò)程時(shí),會(huì)如何加鎖呢?
?我們都知道 Innodb 默認(rèn)的索引數(shù)據(jù)結(jié)構(gòu)為 B + 樹(shù),B + 樹(shù)的葉子結(jié)點(diǎn)包含指向下一個(gè)葉子結(jié)點(diǎn)的指針。在查詢過(guò)程中,會(huì)按照 B + 樹(shù)的搜索方式來(lái)進(jìn)行查找,其底層原理類似二分查找。故在加鎖過(guò)程中會(huì)按照以下兩條原則進(jìn)行加鎖:
1. 只會(huì)對(duì)滿足查詢目標(biāo)附近的區(qū)間加鎖,并不是對(duì)搜索路徑中的所有區(qū)間都加鎖。本例中對(duì)搜索 id=5 或者 user_id=5 時(shí),最終可以定位到滿足該搜索條件的區(qū)域 (1,5]。
2. 加鎖時(shí),會(huì)以 Next key Lock 為加鎖單位。那按照 1 滿足的區(qū)域進(jìn)行加 Next key Lock 鎖(左開(kāi)右閉),同時(shí)因?yàn)?id=5 或者 user_id=5 存在,所以該 Next key Lock 會(huì)退化為 Record Lock,故只對(duì) id=5 或 user_id=5 這個(gè)索引行加鎖。
如果查詢的 id 不存在,例如:
select * from user
where id = 6 for update
按照上面兩條原則,首先按照滿足查詢目標(biāo)條件附近區(qū)域加鎖,所以最終會(huì)找到的區(qū)間為 (5,8]。因?yàn)?id=6 這條記錄并不存在,所以 Next key Lock (5, 8] 最終會(huì)退化為 Gap Lock,即對(duì)索引 (5,8) 加間隙鎖。
2、唯一索引范圍查詢
select * from user
where id >= 4 and id <8 for update
同理,在范圍查詢中,會(huì)首先匹配左值 id=4,此時(shí)會(huì)對(duì)區(qū)間 (1,5] 加 Next key Lock,因?yàn)?id=4 不存在,所以鎖退化為 Gap Lock (1,5);接著會(huì)往后繼續(xù)查找 id=8 的記錄,直到找到第一個(gè)不滿足的區(qū)間,即 Next key Lock (8, 9],因?yàn)?8 不在范圍內(nèi),所以鎖退化為 Gap Lock (8, 9)。故該范圍查詢最終會(huì)鎖的區(qū)域?yàn)?(1, 9)
3、非唯一索引等值查詢
對(duì)非唯一索引查詢時(shí),與上述的加鎖方式稍有區(qū)別。除了要對(duì)包含查詢值區(qū)間內(nèi)加 Next key Lock 之外,還要對(duì)不滿足查詢條件的下一個(gè)區(qū)間加 Gap Lock,也就是需要加兩把鎖。
select * from user
where mobile_num = 6 for update
需要對(duì)索引 (3, 6] 加 Next key Lock,因?yàn)榇藭r(shí)是非唯一索引,那么也就有可能有多個(gè) 6 存在,所以此時(shí)不會(huì)退化為 Record Lock;此外還要對(duì)不滿足該查詢條件的下一個(gè)區(qū)間加 Gap Lock,也就是對(duì)索引 (6,7) 加鎖。故總體來(lái)看,對(duì)索引加了 (3,6] Next key Lock 和 (6, 7) Gap Lock。
若非唯一索引不命中時(shí),如下:
select * from user
where mobile_num = 8 for update
那么需要對(duì)索引 (7, 9] 加 Next key Lock,又因?yàn)?8 不存在,所以鎖退化為 Gap Lock (7, 9)
4、非唯一索引范圍查詢
select * from user
where mobile_num >= 6 and mobile_num < 8
for update
首先先匹配 mobile_num=6,此時(shí)會(huì)對(duì)索引 (3, 6] 加 Next Key Lock,雖然此時(shí)非唯一索引存在,但是不會(huì)退化為 Record Lock;其次再看后半部分的查詢 mobile_num=8,需要對(duì)索引 (7, 9] 加 Next key Lock,又因?yàn)?8 不存在,所以退化為 Gap Lock (7, 9)。最終,需要對(duì)索引行加 Next key Lock (3, 6] 和 Gap Lock (7, 9)。
2.1.3 意向鎖(Intention Locks)
Innodb 為了支持多粒度鎖定,引入了意向鎖。意向鎖是一種表級(jí)鎖,用于表明事務(wù)將要對(duì)某張表某行數(shù)據(jù)操作而進(jìn)行的鎖定。同樣,意向鎖也分為類:共享意向鎖(IS)和排他意向鎖(IX)。
名稱符號(hào)描述共享意向鎖IS表明事務(wù)將要對(duì)表的個(gè)別行設(shè)置共享鎖排他意向鎖IX表明事務(wù)將要對(duì)表的個(gè)別行設(shè)置排他鎖
例如 select ... lock in shared mode 會(huì)設(shè)置共享意向鎖 IS;select ... for update 會(huì)設(shè)置排他意向鎖 IX
設(shè)置意向鎖時(shí)需要按照以下兩條原則進(jìn)行設(shè)置:
1. 當(dāng)事務(wù)需要申請(qǐng)行的共享鎖 S 時(shí),必須先對(duì)表申請(qǐng)共享意向 IS 鎖或更強(qiáng)的鎖
2. 當(dāng)事務(wù)需要申請(qǐng)行的排他鎖 X 時(shí),必須先對(duì)表申請(qǐng)排他意向 IX 鎖
?表級(jí)鎖兼容性矩陣如下表:
將獲取的鎖(下)/ 已獲取的鎖(右)XIXSISX沖突沖突沖突沖突IX沖突兼容沖突兼容S沖突沖突兼容兼容IS沖突兼容兼容兼容
如果請(qǐng)求鎖的事務(wù)與現(xiàn)有鎖兼容,則會(huì)將鎖授予該事務(wù),但如果與現(xiàn)有鎖沖突,則不會(huì)授予該事務(wù)。事務(wù)等待,直到?jīng)_突的現(xiàn)有鎖被釋放。
意向鎖的目的就是為了說(shuō)明事務(wù)正在對(duì)表的一行進(jìn)行鎖定,或?qū)⒁獙?duì)表的一行進(jìn)行鎖定。在意向鎖概念中,除了對(duì)全表加鎖會(huì)導(dǎo)致意向鎖阻塞外,其余情況意向鎖均不會(huì)阻塞任何請(qǐng)求!
2.1.4 插入意向鎖
插入意向鎖是一種特殊的意向鎖,同時(shí)也是一種特殊的 “Gap Lock”,是在 Insert 操作之前設(shè)置的 Gap Lock。
如果此時(shí)有多個(gè)事務(wù)執(zhí)行 insert 操作,恰好需要插入的位置都在同一個(gè) Gap Lock 中,但是并不是在 Gap Lock 的同一個(gè)位置時(shí),此時(shí)的插入意向鎖彼此之間不會(huì)阻塞。
2.2 過(guò)程分析
回到本文的問(wèn)題上來(lái),本文中有兩個(gè)事務(wù)執(zhí)行同樣的動(dòng)作,分別為先執(zhí)行 select ... for update 獲取排他鎖,其次判斷若為空,則執(zhí)行 insert 動(dòng)作,否則執(zhí)行 update 動(dòng)作。偽代碼描述如下:
start transaction
// 1、查詢數(shù)據(jù)
data = select for update(tenantId, storeId, skuId);
if (data == null) {
// 插入數(shù)據(jù)
insert(tenantId, storeId, skuId);
} else {
// 更新數(shù)據(jù)
update(tenantId, storeId, skuId);
}
end transaction
?現(xiàn)在對(duì)這兩個(gè)事務(wù)所執(zhí)行的動(dòng)作進(jìn)行逐一分析,如下表所示:
時(shí)間點(diǎn)事務(wù) A事務(wù) B潛在動(dòng)作1開(kāi)始事務(wù)開(kāi)始事務(wù)?2執(zhí)行 select ... for update 操作?事務(wù) A 申請(qǐng)到 IX 事務(wù) A 申請(qǐng)到 X,Gap Lock3?執(zhí)行 select ... for update 操作事務(wù) B 申請(qǐng)到 IX,與事務(wù) A 的 IX 不沖突。 事務(wù) B 申請(qǐng)到 Gap Lock,Gap Lock 可共存。4執(zhí)行 insert 操作?事務(wù) A 先申請(qǐng)插入意向鎖 IX,與事務(wù) B 的 Gap Lock 沖突,等待事務(wù) B 的 Gap Lock 釋放。5?執(zhí)行 insert 操作事務(wù) B 先申請(qǐng)插入意向鎖 IX,與事務(wù) A 的 Gap Lock 沖突,等待事務(wù) A 的 Gap Lock 釋放。6??死鎖檢測(cè)器檢測(cè)到死鎖
詳細(xì)分析:
- 時(shí)間點(diǎn) 1,事務(wù) A 與事務(wù) B 開(kāi)始執(zhí)行事務(wù)
- 時(shí)間點(diǎn) 2,事務(wù) A 執(zhí)行 select ... for update 操作,執(zhí)行該操作時(shí)首先需要申請(qǐng)意向排他鎖 IX 作用于表上,接著申請(qǐng)到了排他鎖 X 作用于區(qū)間,因?yàn)椴樵兊闹挡淮嬖冢?Next key Lock 退化為 Gap Lock。
- 時(shí)間點(diǎn) 3,事務(wù) B 執(zhí)行 select ... for update 操作,首先申請(qǐng)意向排他鎖 IX,根據(jù) 2.1.3 節(jié)表級(jí)鎖兼容矩陣可以看到,意向鎖之間是相互兼容的,故申請(qǐng) IX 成功。由于查詢值不存在,故可以申請(qǐng) X 的 Gap Lock,而 Gap Lock 之間是可以共存的,不論是共享還是排他。這一點(diǎn)可以參考 Innodb 關(guān)于 Gap Lock 的描述,關(guān)鍵描述本文粘貼至此:
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.
- 時(shí)間點(diǎn) 4,事務(wù) A 執(zhí)行 insert 操作前,首先會(huì)申請(qǐng)插入意向鎖,但此時(shí)事務(wù) B 已經(jīng)擁有了插入?yún)^(qū)間的排他鎖,根據(jù) 2.1.3 節(jié)表級(jí)鎖兼容矩陣可知,在已有 X 鎖情況下,再次申請(qǐng) IX 鎖是沖突的,需要等待事務(wù) B 對(duì) X Gap Lock 釋放。
- 時(shí)間點(diǎn) 5,事務(wù) B 執(zhí)行 insert 操作前,也會(huì)首先申請(qǐng)插入意向鎖,此時(shí)事務(wù) A 也對(duì)插入?yún)^(qū)間擁有 X Gap Lock,因此需要等待事務(wù) A 對(duì) X 鎖進(jìn)行釋放。
- 時(shí)間點(diǎn) 6,事務(wù) A 與事務(wù) B 均在等待對(duì)方釋放 X 鎖,后被 MySQL 的死鎖檢測(cè)器檢測(cè)到后,報(bào) Dead Lock 錯(cuò)誤。
?思考:假如 select ... for update 查詢的數(shù)據(jù)存在時(shí),會(huì)是什么樣的過(guò)程呢?過(guò)程如下表:
時(shí)間點(diǎn)事務(wù) A事務(wù) B潛在動(dòng)作1開(kāi)始事務(wù)開(kāi)始事務(wù)?2執(zhí)行 select ... for update 操作?事務(wù) A 申請(qǐng)到 IX 事務(wù) A 申請(qǐng)到 X 行鎖,因數(shù)據(jù)存在故鎖退化為 Record Lock。3?執(zhí)行 select ... for update 操作事務(wù) B 申請(qǐng)到 IX,與事務(wù) A 的 IX 不沖突。 事務(wù) B 想申請(qǐng)目標(biāo)行的 Record Lock,此時(shí)需要等待事務(wù) A 釋放該鎖資源。4執(zhí)行 update 操作?事務(wù) A 先申請(qǐng)插入意向鎖 IX,此時(shí)事務(wù) B 僅僅擁有 IX 鎖資源,兼容,不沖突。然后事務(wù) A 擁有 X 的 Record Lock,故執(zhí)行更新。5commit?事務(wù) A 提交,釋放 IX 與 X 鎖資源。6?執(zhí)行 select ... for update 操作事務(wù) B 事務(wù) B 此時(shí)獲取到 X Record Lock。7?執(zhí)行 update 操作事務(wù) B 擁有 X Record Lock 執(zhí)行更新8?commit事務(wù) B 釋放 IX 與 X 鎖資源
也就是當(dāng)查詢數(shù)據(jù)存在時(shí),不會(huì)出現(xiàn)死鎖問(wèn)題。?
三、解決方法
1、在事務(wù)開(kāi)始之前,采用 CAS + 分布式鎖來(lái)控制并發(fā)寫(xiě)請(qǐng)求。分布式鎖 key 可以設(shè)置為 store_skuId_version
2、事務(wù)過(guò)程可以改寫(xiě)為:
start transaction
// RR級(jí)別下,讀視圖
data = select from table(tenantId, storeId, skuId)
if (data == null) {
// 可能出現(xiàn)寫(xiě)并發(fā)
insert
} else {
data = select for update(tenantId, storeId, skuId)
update
}
end transaction
雖然解決了插入數(shù)據(jù)不存在時(shí)會(huì)出現(xiàn)的死鎖問(wèn)題,但是可能存在并發(fā)寫(xiě)的問(wèn)題,第一個(gè)事務(wù)獲得鎖會(huì)首先插入成功,第二個(gè)事務(wù)等待第一個(gè)事務(wù)提交后,插入數(shù)據(jù),因?yàn)閿?shù)據(jù)存在了所以報(bào)錯(cuò)回滾。
3、調(diào)整事務(wù)隔離級(jí)別為 RC,在 RC 下沒(méi)有 next key lock(注意,此處并不準(zhǔn)確,RC 會(huì)有少部分情況加 Next key lock),故此時(shí)僅僅會(huì)有 record lock,所以事務(wù) 2 進(jìn)行 select for update 時(shí)需要等待事務(wù) 1 提交。
參考文獻(xiàn)
[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?
若有錯(cuò)誤,還望批評(píng)指正
作者:京東零售 劉哲
來(lái)源:京東云開(kāi)發(fā)者社區(qū) 轉(zhuǎn)載請(qǐng)注明來(lái)源