1. 程式人生 > 其它 >mysql死鎖問題分析

mysql死鎖問題分析

https://blog.csdn.net/BaiHuaXiu123/article/details/54015279

SHOW GLOBAL VARIABLES LIKE 'innodb_deadlock_detect';

SHOW GLOBAL VARIABLES LIKE 'innodb_lock_wait_timeout';

SHOW GLOBAL VARIABLES LIKE 'innodb_rollback_on_timeout';

1 死鎖是怎麼被發現的?

1.1 死鎖成因&&檢測方法

左圖那兩輛車造成死鎖了嗎?不是!右圖四輛車造成死鎖了嗎?是!

圖2 死鎖描述

我們mysql用的儲存引擎是innodb,從日誌來看,innodb主動探知到死鎖,並回滾了某一苦苦等待的事務。問題來了,innodb是怎麼探知死鎖的?

直觀方法是在兩個事務相互等待時,當一個等待時間超過設定的某一閥值時,對其中一個事務進行回滾,另一個事務就能繼續執行。這種方法簡單有效,在innodb中,引數innodb_lock_wait_timeout用來設定超時時間。

僅用上述方法來檢測死鎖太過被動,innodb還提供了wait-for graph演算法來主動進行死鎖檢測,每當加鎖請求無法立即滿足需要並進入等待時,wait-for graph演算法都會被觸發。

1.2 wait-for graph原理

我們怎麼知道上圖中四輛車是死鎖的?他們相互等待對方的資源,而且形成環路!我們將每輛車看為一個節點,當節點1需要等待節點2的資源時,就生成一條有向邊指向節點2,最後形成一個有向圖。我們只要檢測這個有向圖是否出現環路即可,出現環路就是死鎖!這就是wait-for graph演算法。
圖3 wait for graph

innodb將各個事務看為一個個節點,資源就是各個事務佔用的鎖,當事務1需要等待事務2的鎖時,就生成一條有向邊從1指向2,最後行成一個有向圖。

1.2 innodb隔離級別、索引與鎖

死鎖檢測是死鎖發生時innodb給我們的救命稻草,我們需要它,但我們更需要的是避免死鎖發生的能力,如何儘可能避免?這需要了解innodb中的鎖。

1.2.1 鎖與索引的關係

假設我們有一張訊息表(msg),裡面有3個欄位。假設id是主鍵,token是非唯一索引,message沒有索引。

id: bigint

token: varchar(30)

message: varchar(4096)

innodb對於主鍵使用了聚簇索引,這是一種資料儲存方式,表資料是和主鍵一起儲存,主鍵索引的葉結點儲存行資料。對於普通索引,其葉子節點儲存的是主鍵值。

圖4 聚簇索引和二級索引
下面分析下索引和鎖的關係。
1)delete from msg where id=2;

由於id是主鍵,因此直接鎖住整行記錄即可。
圖5
2)delete from msg where token=’ cvs’;

由於token是二級索引,因此首先鎖住二級索引(兩行),接著會鎖住相應主鍵所對應的記錄;
圖6
3)delete from msg where message=訂單號是多少’;

message沒有索引,所以走的是全表掃描過濾。這時表上的各個記錄都將新增上X鎖。
圖7

1.2.2 鎖與隔離級別的關係

大學資料庫原理都學過,為了保證併發操作資料的正確性,資料庫都會有事務隔離級別的概念:1)未提交讀(Read uncommitted);2)已提交讀(Read committed(RC));3)可重複讀(Repeatable read(RR));4)可序列化(Serializable)。我們較常使用的是RC和RR。

提交讀(RC):只能讀取到已經提交的資料。

可重複讀(RR):在同一個事務內的查詢都是事務開始時刻一致的,InnoDB預設級別。

我們在1.2.1節談論的其實是RC隔離級別下的鎖,它可以防止不同事務版本的資料修改提交時造成資料衝突的情況,但當別的事務插入資料時可能會出現問題。

如下圖所示,事務A在第一次查詢時得到1條記錄,在第二次執行相同查詢時卻得到兩條記錄。從事務A角度上看是見鬼了!這就是幻讀,RC級別下儘管加了行鎖,但還是避免不了幻讀。

圖8

innodb的RR隔離級別可以避免幻讀發生,怎麼實現?當然需要藉助於鎖了!

為了解決幻讀問題,innodb引入了gap鎖。

在事務A執行:update msg set message=‘訂單’ where token=‘asd’;

innodb首先會和RC級別一樣,給索引上的記錄新增上X鎖,此外,還在非唯一索引’asd’與相鄰兩個索引的區間加上鎖。

這樣,當事務B在執行insert into msg values (null,‘asd',’hello’); commit;時,會首先檢查這個區間是否被鎖上,如果被鎖上,則不能立即執行,需要等待該gap鎖被釋放。這樣就能避免幻讀問題。
圖9

推薦一篇好文,可以深入理解鎖的原理:http://hedengcheng.com/?p=771#_Toc374698322

3 死鎖成因

瞭解了innodb鎖的基本原理後,下面分析下死鎖的成因。如前面所說,死鎖一般是事務相互等待對方資源,最後形成環路造成的。下面簡單講下造成相互等待最後形成環路的例子。

3.1不同表相同記錄行鎖衝突

這種情況很好理解,事務A和事務B操作兩張表,但出現迴圈等待鎖情況。

圖10

3.2相同表記錄行鎖衝突

這種情況比較常見,之前遇到兩個job在執行資料批量更新時,jobA處理的的id列表為[1,2,3,4],而job處理的id列表為[8,9,10,4,2],這樣就造成了死鎖。

圖11

3.3不同索引鎖衝突

這種情況比較隱晦,事務A在執行時,除了在二級索引加鎖外,還會在聚簇索引上加鎖,在聚簇索引上加鎖的順序是[1,4,2,3,5],而事務B執行時,只在聚簇索引上加鎖,加鎖順序是[1,2,3,4,5],這樣就造成了死鎖的可能性。

圖12

3.4 gap鎖衝突

innodb在RR級別下,如下的情況也會產生死鎖,比較隱晦。不清楚的同學可以自行根據上節的gap鎖原理分析下。
圖13

4 如何儘可能避免死鎖

1)以固定的順序訪問表和行。比如對第2節兩個job批量更新的情形,簡單方法是對id列表先排序,後執行,這樣就避免了交叉等待鎖的情形;又比如對於3.1節的情形,將兩個事務的sql順序調整為一致,也能避免死鎖。

2)大事務拆小。大事務更傾向於死鎖,如果業務允許,將大事務拆小。

3)在同一個事務中,儘可能做到一次鎖定所需要的所有資源,減少死鎖概率。

4)降低隔離級別。如果業務允許,將隔離級別調低也是較好的選擇,比如將隔離級別從RR調整為RC,可以避免掉很多因為gap鎖造成的死鎖。

5)為表新增合理的索引。可以看到如果不走索引將會為表的每一行記錄新增上鎖,死鎖的概率大大增大。

5 如何定位死鎖成因

下面以本文開頭的死鎖案例為例,講下如何排查死鎖成因。

1)通過應用業務日誌定位到問題程式碼,找到相應的事務對應的sql;

因為死鎖被檢測到後會回滾,這些資訊都會以異常反應在應用的業務日誌中,通過這些日誌我們可以定位到相應的程式碼,並把事務的sql給梳理出來。

start tran
1 deleteHeartCheckDOByToken
2 updateSessionUser
...
commit
  • 1
  • 2
  • 3
  • 4

此外,我們根據日誌回滾的資訊發現在檢測出死鎖時這個事務被回滾。

2)確定資料庫隔離級別。

執行select @@global.tx_isolation,可以確定資料庫的隔離級別,我們資料庫的隔離級別是RC,這樣可以很大概率排除gap鎖造成死鎖的嫌疑;

3)找DBA執行下show InnoDB STATUS看看最近死鎖的日誌。

這個步驟非常關鍵。通過DBA的幫忙,我們可以有更為詳細的死鎖資訊。通過此詳細日誌一看就能發現,與之前事務相沖突的事務結構如下:

start tran
1 updateSessionUser
2 deleteHeartCheckDOByToken
...
commit
  • 1
  • 2
  • 3
  • 4
  • 5

  這不就是圖10描述的死鎖嘛!

innodb_deadlock_detect
如果關閉innodb_deadlock_detect,也即關閉了死鎖自動監測機制時,當兩個或多個session間存在死鎖的情況下,MySQL怎麼去處理?
這裡會涉及到另外一個引數:鎖超時,也即innodb_lock_wait_timeout,該引數指定了“鎖申請時候的最長等待時間”
官方的解釋是:The length of time in seconds an InnoDB transaction waits for a row lock before giving up.
innodb_lock_wait_timeout預設值是50秒,也就是意味著session請求時,申請不到鎖的情況下最多等待50秒鐘,然後呢,就等價於死鎖,自動回滾當前事物了?其實不是的,事情沒有想象中的簡單。

innodb_rollback_on_timeout
這裡就涉及到另外一個引數:innodb_rollback_on_timeout,預設值是off,該引數的決定了當前請求鎖超時之後,回滾的是整個事物,還是僅當前語句,
官方的解釋是:InnoDB rolls back only the last statement on a transaction timeout by default。
預設值是off,也就是回滾當前語句(放棄當前語句的鎖申請),有人強烈建議開啟這個選項(on),也就是一旦鎖申請超時,就回滾整個事物。
需要注意的是,預設情況下只回滾當前語句,而不是整個事物,當前的事物還在繼續,連線也還在,這裡與死鎖自動監測機制開啟之後會主動犧牲一個事物不同,鎖超時後並不會主動犧牲其中任何一個事物。
這意味著會出現一種非常嚴重的情況,舉個例子,可以想象一下如下這種情況:


session1                      session2
start transaction;                  start transaction;
update A set val = 'xxx' where id = 1       update B set val = 'yyy' where id = 1 

……                          ……

update B set val = 'xxx' where id = 1        update A set val = 'yyy' where id = 1

if 鎖超時                      if 鎖超時

  #繼續申請鎖                   #繼續申請鎖
  update B set val = 'xxx' where id = 1        update A set val = 'xxx' where id = 1


關閉了死鎖監測機制後,在innodb_rollback_on_timeout保持預設的off的情況下,session1和session2都是無法正常執行下去的,且永遠都無法執行下去。
任意一個session出現鎖超時,放棄當前的語句申請的鎖,而不是整個事物持有的鎖,當前session並不釋放其他session請求的鎖資源,
即便是繼續下去,依舊如此,兩者又陷入了相互等待,相互鎖請求超時,繼續死迴圈。
從這裡可以看到,與死鎖自動檢測機制在發現死鎖是主動選擇一個作為犧牲品不同,一旦關閉了innodb_deadlock_detect,Session中的任意一方都不會主動釋放已經持有的鎖。
此時如果應用程式如果不足夠的健壯,繼續去申請鎖(比如重試機制,嘗試重試相關語句),session雙方會陷入到無限制的鎖超時死迴圈之中。

事實上推論是不是成立的?做個測試驗證一下,資料庫環境資訊如下

模擬事物雙方在當前語句的鎖超時之後,繼續申請鎖,確實是會出現無限制的鎖超時的死迴圈之中。


以上就比較有意思了,與死鎖主動監測並犧牲其中一個事物不同,此時事物雙方互不相讓,當然也都無法成功執行。

這只不過是一個典型的負面場景,除此之外,還會有哪些問題值得思考?
1,因為事物無法快速提交或者回滾,那麼連線持有的時間會增加,一旦併發量上來,連線數可能成為一個問題。
2,鎖超時時間肯定要設定為一個相對較小的時間,但具體又設定為多少靠譜。
3,關閉死鎖檢測,帶來的收益,與副作用相比哪個更高,當前業務型別是否需要關閉死鎖檢測,除非資料庫中相關操作大部分都是短小事物且所衝突的可能性較低。
4,面對鎖超時,應用程式端如何合理地處理鎖超時的情況,是重試還是放棄。
5,與此關聯的innodb_rollback_on_timeout如何設定,是保持預設的關閉(鎖超時的情況下,取消當前語句的所申請),還是開啟(鎖超時的情況下,回滾整個事物)

最後,其實這個問題屬於一個系統工程,不是一個單點問題,除此之外還有可能潛在一些其他的問題,原作者是大神,當然是一個整體方案,需要在整體架構上做處理,作者也給出了一個客觀的處理方式。

參考連結
https://dev.mysql.com/doc/refman/5.7/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout

如何儘可能避免死鎖

  • 以固定的順序訪問表和行。比如兩個更新資料的事務,事務A 更新資料的順序 為1,2;事務B更新資料的順序為2,1。這樣更可能會造成死鎖;

  • 大事務拆小。大事務更傾向於死鎖,如果業務允許,將大事務拆小;

  • 在同一個事務中,儘可能做到一次鎖定所需要的所有資源,減少死鎖概率;

  • 降低隔離級別。如果業務允許,將隔離級別調低也是較好的選擇,比如將隔離級別從RR調整為RC,可以避免掉很多因為gap鎖造成的死鎖。(我司 MySQL 規範做法);

  • 為表新增合理的索引。可以看到如果不走索引將會為表的每一行記錄新增上鎖,死鎖的概率大大增大。

MySQL行級鎖

行鎖是引擎提供的,在MySQL中,InnoDB支援行鎖,MyISAM不支援行鎖,這也是為什麼MySQL推薦我們使用InnoDB引擎,因為行鎖對併發訪問更友好。
但是和MDL釋放鎖類似,在InnoDB事務請求中,行鎖在需要的時候新增,在事務提交後才釋放,也就是說,當begin一個事務,update一個欄位,未commit時,其他事務的update操作必須等待前一個事務commit後,釋放掉行鎖才能執行更新操作。

當我們使用事務操作多張表,加多個行鎖時,一定要注意將影響最大的那一行最後加鎖,減少鎖時間,拿丁奇老師的例子來說明:

業務如下:
1、從顧客A賬戶扣除影票金額
2、從影院B賬戶新增該金額
3、記錄交易日誌

為了保證交易的原子性,這三條操作需要放到一個事務中處理,因為操作2的行記錄是其他事務也需要使用的,所以為了保證鎖等待時間最短,最優的解決辦法是將執行順序變為3,1,2。

死鎖

正常的業務中,我們涉及到的更新操作不會是上面的例子中那麼簡單,當更新操作涉及到多表多欄位的時候,如果不慎,很容易陷入死鎖。
即:當併發時,不同執行緒出現迴圈資源依賴,涉及的執行緒在互相等待對方釋放鎖,就會導致陷入死迴圈的狀態,最終導致死鎖。
如:

事務A在等事務B釋放id=2的行鎖,事務B又在等待事務A釋放id=1的行鎖,就導致了死鎖現象。
MySQL中針對死鎖有兩種處理方式:
1、innodb_lock_wait_timeout=50超時自動退出
2、innodb_deadlock_detect=on死鎖檢測
針對第一種超時退出而言,讓第一個被鎖住的事務超時50s後自動退出,其他請求再執行,對我們的業務來說是無法接受的。雖然我們可以將超時時間設定的小一些,但是太小如1s的話,可能會波及到正常的事務提交,是不可取的。
所以,最好的方式還是第二種,死鎖檢測。但是需要注意的是因為每個需要加行鎖的事務,都需要順藤摸瓜的去檢測是否會導致死鎖,雖然不是掃描所有的事務,但是當請求量很大的時候,死鎖檢測也是很耗費CPU資源的,你會發現,時間都浪費在了檢測上,事務卻沒執行幾個,CPU利用率還很高。

InnoDB是通過索引來實現的行鎖,當更新列上沒有索引時,其更新會鎖整張表。如update T set name='zhangsan' where age=10,若age列無索引,這條語句將會鎖整表。這是因為InnoDB需要確保當你執行該語句時,必須阻止其他事務插入age=10的行資料。
但是如果說執行的是update T set name='zhangsan' where age=10 limit 1的話,就會只鎖定一行。

大併發的效能解決

那麼對於熱點行更新,我們最好怎麼解決其效能為題呢?
個人認為,最優的解決方案就是使用資料庫中介軟體控制到庫的併發量。
原理就是,請求到來時,中介軟體將其接住,放入佇列,每次釋放一定量的請求入庫操作,這樣能有效的控制併發量,減輕資料庫的死鎖檢測壓力。
(在使用連線池的情況下,由於連線會複用,如果一個連線執行了set sql_select_limit=1<該引數為設定從SELECT語句返回的最大行數>,當其他業務複用該連線是,該設定也會生效。為了避免這種情況,5.7版本,MySQL提供了一個reset_connection介面,我們呼叫後連線會被重置,歷史資料被清空,避免以上問題。)
網上還看到其他的解決方案:
1、關閉資料庫的死鎖檢測(關閉的風險頗高)
2、將熱點行拆分為多行或多表,分流操作以達到減少併發量的效果(此種方式需要注意資料的操作合法性,如行資料不能為0,但當前分行已為0了該怎麼辦?)