隨着公司業務的發展,商品庫存從商品中心獨立出來成爲一個獨立的系統,承接主站商品庫存校驗、訂單庫存扣減、售後庫存釋放等業務。在上線以前咱們對於核心接口進行了壓測,壓測過程當中出現了 MySQL 5.6.35 死鎖現象,經過日誌發現引起死鎖的只是一條簡單的sql,死鎖是怎麼產生的?發揚技術人員刨根問底的優良傳統,對於此次死鎖緣由進行了細緻的排查和總結。本文既是這次過程的一個記錄。mysql
在深刻探究問題以前,咱們先了解一下 MySQL 的加鎖機制。sql
首先要明確的一點是 MySQL 加鎖其實是給索引加鎖,而非給數據加鎖。咱們先看下MySQL 索引的結構。併發
MySQL 索引分爲主鍵索引(或聚簇索引)和二級索引(或非主鍵索引、非聚簇索引、輔助索引,包括各類主鍵索引外的其餘全部索引)。不一樣存儲引擎對於數據的組織方式略有不一樣。ide
對InnoDB而言,主鍵索引和數據是存放在一塊兒的,構成一顆B+樹(稱爲索引組織表),主鍵位於非葉子節點,數據存放於葉子節點。示意圖以下:fetch
而MyISAM是堆組織表,主鍵索引和數據分開存放,葉子節點保存的只是數據的物理地址,示意圖以下:優化
二級索引的組織方式對於InnoDB和MyISAM是同樣的,保存了二級索引和主鍵索引的對應關係,二級索引列位於非葉子節點,主鍵值位於葉子節點,示意圖以下:編碼
那麼在MySQL 的這種索引結構下,咱們怎麼找到須要的數據呢?spa
以select * from t where name='aaa'爲例,MySQL Server對sql進行解析後發現name字段有索引可用,因而先在二級索引(圖2-2)上根據name='aaa'找到主鍵id=17,而後根據主鍵17到主鍵索引上(圖2-1)上找到須要的記錄。翻譯
瞭解 MySQL 利用索引對數據進行組織和檢索的原理後,接下來看下MySQL 如何給索引枷鎖。3d
須要瞭解的是索引如何加鎖和索引類型(主鍵、惟1、非惟1、沒有索引)以及隔離級別(RC、RR等)有關。本例中限定隔離級別爲RC,RR狀況下和RC加鎖基本一致,不一樣的是RC爲了防止幻讀會額外加上間隙鎖。
update t set name='xxx' where id=29;只須要將主鍵上id=29的記錄加上X鎖便可(X鎖稱爲互斥鎖,加鎖後本事務能夠讀和寫,其餘事務讀和寫會被阻塞)。以下:
update t set name='xxx' where name='ddd';這裏假設name是惟一的。InnoDB如今name索引上找到name='ddd'的索引項(id=29)並加上加上X鎖,而後根據id=29再到主鍵索引上找到對應的葉子節點並加上X鎖。
一共兩把鎖,一把加在惟一索引上,一把加在主鍵索引上。這裏須要說明的是加鎖是一步步加的,不會同時給惟一索引和主鍵索引加鎖。這種分步加鎖的機制實際上也是致使死鎖的誘因之一。示意以下:
update t set name='xxx' where name='ddd';這裏假設name不惟一,即根據name能夠查到多條記錄(id不一樣)。和上面惟一索引加鎖相似,不一樣的是會給全部符合條件的索引項加鎖。示意以下:
這裏一共四把鎖,加鎖步驟以下:
從上面步驟能夠看出,InnoDB對於每一個符合條件的記錄是分步加鎖的,即先加二級索引再加主鍵索引;其次是按記錄逐條加鎖的,即加完一條記錄後,再加另一條記錄,直到全部符合條件的記錄都加完鎖。那麼鎖何時釋放呢?答案是事務結束時會釋放全部的鎖。
小結:MySQL 加鎖和索引類型有關,加鎖是按記錄逐條加,另外加鎖也和隔離級別有關。
瞭解MySQL 如何給索引加鎖後,下面步入正題,看看實際場景下的死鎖現象及其成因分析。
本次發生死鎖的是庫存扣減接口,該接口的主要邏輯是用戶下單後,扣減訂單商品在某個倉庫的庫存量。好比用戶一個在vivo官網下單買了1臺X50手機和1臺X30耳機,那麼下單後,首先根據用戶收貨地址肯定發貨倉庫,而後從該倉庫裏面分別減去一個X50庫存和一個X30庫存。分析死鎖sql以前,先看下商品庫存表的定義(爲方便理解,只保留主要字段):
CREATE TABLE `store` ( `id` int(10) AUTO_INCREMENT COMMENT '主鍵', `sku_code` varchar(45) COMMENT '商品編碼', `ws_code` varchar(32) COMMENT '倉庫編碼', `store` int(10) COMMENT '庫存量', PRIMARY KEY (`id`), KEY `idx_skucode` (`sku_code`), KEY `idx_wscode` (`ws_code`) ) ENGINE=InnoDB COMMENT='商品庫存表'
注意這裏分別給sku_code和ws_code兩個字段單獨定義了索引:idx_skucode, idx_wscode。這樣作的緣由主要是業務上有根據單個字段查詢的要求。
再看下庫存扣減update語句:
update store set store = store-#{store} where sku_code=#{skuCode} and ws_code = #{wsCode} and (store-#{store}) >= 0
這個sql的業務含義就是對某個商品(skuCode)從某個倉庫(wsCode)中扣減store個庫存量,同時上面的where條件同時出現了sku_code和ws_code字段,壓測數據中 sku_code的選擇度要比ws_code高,理論上這條sql應該會走idx_skucode索引,那麼真實狀況是怎樣的呢?
好,接下來對庫存扣減接口卡進行壓測,50的併發,每一個訂單5個商品,剛壓不到半分鐘就出現了死鎖,再壓,問題依舊,說明是必現的問題,必現解決後才能繼續。在MySQL 終端執行 show engine innodb status 命令查看最後一次死鎖日誌,主要關注日誌中的 LATEST DETECTED DEADLOCK 部分:
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2020-xx-xx 21:09:05 7f9b22008700 *** (1) TRANSACTION: TRANSACTION 4219870943, ACTIVE 0 sec fetching rows mysql tables in use 3, locked 3 LOCK WAIT 10 lock struct(s), heap size 2936, 3 row lock(s) MySQL thread id 301903552, OS thread handle 0x7f9b21a7b700, query id 5373393954 10.101.22.135 root updating update store set update_time = now(), store = store-1 where sku_code='5468754' and ws_code = 'NO_001' and (store-1) >= 0 *** (1) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 3331 page no 16 n bits 904 index `idx_wscode` of table `store` trx id 4219870943 lock_mode X locks rec but not gap waiting Record lock, heap no 415 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 0: len 5; hex 5730303735; asc NO_001;; 1: len 8; hex 00000000000025a7; asc % ;; *** (2) TRANSACTION: TRANSACTION 4219870941, ACTIVE 0 sec fetching rows, thread declared inside InnoDB 1 mysql tables in use 3, locked 3 9 lock struct(s), heap size 2936, 4 row lock(s) MySQL thread id 301939956, OS thread handle 0x7f9b22008700, query id 5373393941 10.101.22.135 root updating update store set update_time = now(), store = store-1 where sku_code='5655620' and ws_code = 'NO_001' and (store-1) >= 0 *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 3331 page no 16 n bits 904 index `idx_wscode` of table `store` trx id 4219870941 lock_mode X locks rec but not gap Record lock, heap no 415 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 0: len 5; hex 5730303735; asc NO_001;; 1: len 8; hex 00000000000025a7; asc % ;; *** (2) WAITING FOR THIS LOCK TO BE GRANTED: RECORD LOCKS space id 3331 page no 7 n bits 328 index `PRIMARY` of table `store` trx id 4219870941 lock_mode X locks rec but not gap waiting Record lock, heap no 72 PHYSICAL RECORD: n_fields 9; compact format; info bits 0 0: len 8; hex 00000000000025a7; asc % ;; 1: len 6; hex 0000fb85fdf7; asc ;; 2: len 7; hex 1a00001d3b21d4; asc ;! ;; 3: len 7; hex 35343638373534; asc 5468754;; 4: len 5; hex 5730303735; asc NO_001;; 5: len 8; hex 8000000000018690; asc ;; 6: len 5; hex 99a76b2b97; asc k+ ;; 7: len 5; hex 99a7e35244; asc RD;; 8: len 1; hex 01; asc ;;
從上面日誌能夠看出,存在兩個事務,分別在執行這兩條sql時發生了死鎖:
update store set update_time = now(), store = store-1 where sku_code='5468754' and ws_code = 'NO_001' and (store-1) >= 0 update store set update_time = now(), store = store-1 where sku_code='5655620' and ws_code = 'NO_001' and (store-1) >= 0
看一下實際數據:
圖3-1 庫存表數據
就是說,這兩個事務在更新同一張表的不一樣行時發生了死鎖。在咱們直觀印象裏,innodb使用的是行鎖,不一樣的行鎖之間應該是互不干擾的?那這是怎麼一回事呢?
咱們再看一下update的執行計劃:
圖3-2 update語句執行計劃
和咱們想象的不一樣,InnoDB既沒有使用idx_skucode索引,也沒有使用idx_wscode索引,而是使用了index_merge。index_merge和這兩個索引是什麼關係呢?
查詢資料得知index_merge是MySQL 5.1後引入的一項索引合併優化技術,它容許對同一個表同時使用多個索引進行查詢,並對多個索引的查詢結果進行合併(取交集(intersect)、並集(union)等)後返回。
回到上面的update語句:where sku_code='5468754' and ws_code = 'NO_001' ;若是沒有index_merge,要麼走idx_skucode索引,要麼走idx_wscode索引,不會出現兩個索引一塊兒使用的狀況。而在使用index_merge技術後,會同時執行兩個索引,分別查到結果後再進行合併(where條件是and,因此會作交集運算)。再結合第二部分對加鎖機制(分步按記錄加鎖)的理解,是否隱約以爲兩個索引的同時加鎖是致使死鎖的緣由呢?
咱們再深刻死鎖日誌看一下,日誌比較複雜,翻譯過來大意以下:
1)事務一 4219870943 在執行update語句時,在等待索引idx_wscode上的行鎖(編號space id 3331 page no 16 n bits 904 )。
2)事務二 4219870941 在執行update語句時,已經持有idx_wscode上的行鎖(編號space id 3331 page no 16 n bits 904 ),從鎖編號來看,就是事務一須要的鎖。
3)事務二 4219870941 同時也在等待主鍵索引上的一把鎖,這把鎖誰在持有呢?從這行日誌(3: len 7; hex 35343638373534; asc 5468754;;)能夠看出,正是事務一要更新的那行記錄,說明這把鎖被事務一霸佔着。
好了,死鎖條件已經很清楚了:事務一在等待事務二持有的索引 idx_wscode上的行鎖(編號space id 3331 page no 16 n bits 904 ),而事務二同時也在等待事務一持有的主鍵索引(5468754)上的鎖,你們各執己見,只能僵在那裏死鎖嘍^_^
用一張圖來講明一下這個狀況:
上圖描述的只是發生死鎖的一條可能路徑,實際上仔細梳理的話還有其餘路徑也會致使死鎖,你們感興趣能夠本身探索。上圖解釋以下:
1)事務一(where sku_code='5468754' and ws_code = 'NO_001' )首先走idx_skucode索引,分別對二級索引和主鍵索引加鎖成功(1-1和1-2)。
2)此時事務二開始執行( where sku_code='5655620' and ws_code = 'NO_001' ),首先也是走idx_skucode(左上)索引,由於和事務一所加鎖的記錄不衝突,因此也順利加鎖成功(2-1和2-2)。
3)事務二繼續執行,這時走的是idx_wscode(右上)索引,先對二級索引加鎖成功(2-3,此時事務一尚未開始在idx_wscode上加鎖),可是在對主鍵索引加索引時,發現id=9639的主鍵索引已經被事務一上鎖,所以只能等待(2-4),同時在2-4完成加鎖前,對其餘記錄的加鎖也會暫停(2-5和2-6,由於InnoDB是逐條記錄加鎖的,前一條未完成則後面的不會執行)。
4)此時事務一繼續執行,這時走的是idx_wscode索引,可是加鎖的時候發現(NO_001,9639)這條索引項已經被事務二上鎖,因此也只能等待。同理,後面的1-4也沒法執行。
到此就出現了「兩個事務,反向加鎖"致使的死鎖現象。
死鎖的本質緣由仍是由加鎖順序不一樣所致使,本例中是因爲Index Merge同時使用2個索引方向加鎖所致使,解決方法也比較簡單,就是消除因index merge帶來的多個索引同時執行的狀況。
1)利用force index(idx_skucode)強制走某個索引,這樣InnoDB就會忽略index merge,避免多個索引同時加鎖的狀況。
圖4-1 使用Force Index強制指定索引
2)禁用Index Merge,這樣InnoDB只會使用idx_skucode和idx_wscode中的一個,全部事物加鎖順序都同樣,不會形成死鎖。
用命令禁用Index Merge:SET GLOBAL optimizer_switch='index_merge=off,index_merge_union=off,index_merge_sort_union=off,index_merge_intersection=off';
圖4-2 關閉Index Merge特性
從新登陸終端後再看下執行計劃:
圖4-3 關閉Index Merge後索引狀況
3)既然Index Merge同時使用了2個獨立索引,咱們不妨新建一個包含這兩個索引全部字段的聯合索引,這樣InnoDB就只會走這個單獨的聯合索引,這其實和禁用index merge是一個道理。
新增聯合索引:
alter table store add index
idx_skucode_wscode(sku_code,ws_code);
再看下執行計劃,type=range說明沒有使用index merge,另外key=idx_skucode_wscode說明走的是剛剛建立的聯合索引:
圖4-4 利用聯合索引來避免Index Merge優化
4)最後推薦另一種繞過index merge限制的方式。即去除死鎖產生的條件,具體方法是先利用idx_skucode和idx_wscode查詢到主鍵id,再拿主鍵id進行update操做。這種方式避免了由update引入X鎖,因爲最終更新的條件是惟一固定的,因此不存在加鎖順序的問題,避免了死鎖的產生。
本文經過一個實際案例描述了因爲Index Merge優化致使的死鎖,詳細描述了死鎖產生的緣由以及解決方案,並順便介紹了 MySQL 索引結構及加鎖機制。經過本文,你們能夠掌握死鎖分析的基本理論和通常方法,但願能爲你們工做中快速解決實際出現的死鎖問題提供思路。
做者:vivo 官網商城開發團隊