文章目錄
前言
一、死鎖
- 1.1 什麼是死鎖
- 1.2 死鎖產生的四個必要條件
- 1.3 模擬產生死鎖的程式碼
- 1.4 死鎖的產生原因
二、如何避免或解決死鎖
- 2.1 死鎖預防
- 2.2 死鎖避免
- 2.3 死鎖檢測
- 2.4 死鎖解除
三、資料庫鎖
- 3.1 鎖分類
- 3.2 InnoDB中不同SQL語句設定的鎖
- 3.3 控制事務
四、MySQL中的死鎖
-
4.1 MySQL中的死鎖現象
-
4.2 MySQL中死鎖如何解決
- 4.2.1 MySQL的鎖超時機制
- 4.2.2 死鎖檢測演算法 - wait-for graph
- 4.2.3 如何預防/避免死鎖產生
五、如何確保 N 個執行緒可以訪問 N 個資源,同時又不導致死鎖
前言
-- 鎖等待超時配置
show variables like 'innodb_lock_wait_timeout';
show variables like '%lock_wait_timeout%';
+--------------------------+----------+
| Variable_name | Value |
+--------------------------+----------+
| innodb_lock_wait_timeout | 50 |
| lock_wait_timeout | 31536000 |
+--------------------------+----------+
-- MySQL死鎖定位
-- 檢視有哪些執行緒正在執行
show processlist;
-- 檢視當前執行的所有事務
select * from information_schema.INNODB_TRX;
-- information_schema和鎖相關的還有INNODB_LOCK_WAITS(檢視鎖爭執雙方)、INNODB_LOCKS(鎖相關的資訊)
一、死鎖
1.1 什麼是死鎖
死鎖(Deadlock)是指兩個或兩個以上的執行緒(或程序)在執行過程中,因爭奪資源而造成的一種互相等待的現象,若無外力作用,它們都將無法推進下去,此時稱系統處於死鎖狀態或系統產生了死鎖。
很顯然,如果沒有外力的作用,那麼死鎖涉及到的各個程序都將永遠處於封鎖狀態。可以想象成幾個小船在某個狹窄水域中,彼此堵住,誰也無法前進。
1.2 死鎖產生的四個必要條件
要理解死鎖的產生,必須知道它的四個必要條件:
- 互斥條件:資源被程序排他地佔用。程序要求對所分配的資源(如印表機)進行排他性控制,即在一段時間內某資源僅為一個程序所佔有。此時若有其他程序請求該資源,則請求程序只能等待。
- 保持並等待條件:程序已經保持了至少一個資源,但又提出了新的資源請求,而該資源已被其他程序佔有,此時請求程序被阻塞,但對自己已獲得的資源保持不放。
- 不可剝奪條件:程序所獲得的資源在未使用完畢之前,不能被其他程序強行奪走,即只能由獲得該資源的程序自己來釋放(只能是主動釋放)。
- 迴圈等待條件:存在一種程序資源的迴圈等待鏈,鏈中每一個程序已獲得的資源同時被鏈中下一個程序所請求。
注意:這四個條件是產生死鎖的必要條件,也就是說只要系統發生死鎖,這些條件必然成立,而只要上述條件之一不滿足,就不會發生死鎖。
1.3 模擬產生死鎖的程式碼
下面透過一個實際的例子來模擬執行緒產生死鎖
public class DeadLockDemo {
private static Object resource1 = new Object();//資源 1
private static Object resource2 = new Object();//資源 2
public static void main(String[] args) {
new Thread(() -> {
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource2");
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
}
}
}, "執行緒 1").start();
new Thread(() -> {
synchronized (resource2) {
System.out.println(Thread.currentThread() + "get resource2");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread() + "waiting get resource1");
synchronized (resource1) {
System.out.println(Thread.currentThread() + "get resource1");
}
}
}, "執行緒 2").start();
}
}
輸出
Thread[執行緒 1,5,main]get resource1
Thread[執行緒 2,5,main]get resource2
Thread[執行緒 2,5,main]waiting get resource1
Thread[執行緒 1,5,main]waiting get resource2
執行緒A透過synchronized (resourcel)
獲得resource1
的監視器鎖,然後透過·Thread.sleep(1000)
;讓執行緒A休眠1s 為的是讓執行緒B得到執行然後獲取到resource2
的監視器鎖。執行緒A和執行緒B休眠結束了都開始企圖請求獲取對方的資源,然後這兩個執行緒就會陷入互相等待的狀態,這也就產生了死鎖。
1.4 死鎖的產生原因
(1)競爭資源引起程序死鎖
當系統中供多個程序共享的資源如印表機、公用佇列等等,其數目不足以滿足諸程序的需要時,會引起諸程序對資源的競爭而產生死鎖。
(2)可剝奪資源和不可剝奪資源
-
可剝奪資源,是指某程序在獲得這類資源後,該資源可以再被其他程序或系統剝奪。例如,優先權高的程序可以剝奪優先權低的程序的處理機。又如,記憶體區可由儲存器管理程式,把一個程序從一個儲存區移到另一個儲存區,此即剝奪了該程序原來佔有的儲存區,甚至可將一程序從記憶體調到外存上,可見,CPU和主存均屬於可剝奪性資源。
-
不可剝奪資源,當系統把這類資源分配給某程序後,再不能強行收回,只能在程序用完後自行釋放,如磁帶機、印表機等。
(3)競爭不可剝奪資源
在系統中所配置的不可剝奪資源,由於它們的數量不能滿足諸程序執行的需要,會使程序在執行過程中,因爭奪這些資源而陷於僵局。
例如,系統中只有一臺印表機R1和一臺磁帶機R2,可供程序P1和P2共享。假定PI已佔用了印表機R1,P2已佔用了磁帶機R2,若P2繼續要求印表機R1,P2將阻塞;P1若又要求磁帶機,P1也將阻塞。
於是,在P1和P2之間就形成了僵局,兩個程序都在等待對方釋放自己所需要的資源,但是它們又都因不能繼續獲得自己所需要的資源而不能繼續推進,從而也不能釋放自己所佔有的資源,以致進入死鎖狀態。
(4)競爭臨時資源
上面所說的印表機資源屬於可順序重複使用型資源,稱為永久資源。還有一種所謂的臨時資源,這是指由一個程序產生,被另一個程序使用,短時間後便無用的資源,故也稱為消耗性資源,如硬體中斷、訊號、訊息、緩衝區內的訊息等,它也可能引起死鎖。
例如,SI,S2,S3是臨時性資源,程序P1產生訊息S1,又要求從P3接收訊息S3;程序P3產生訊息S3,又要求從程序P2處接收訊息S2;程序P2產生訊息S2,又要求從P1處接收產生的訊息S1。
如果訊息通訊按如下順序進行:
P1: ···Relese(S1);Request(S3); ···
P2: ···Relese(S2);Request(S1); ···
P3: ···Relese(S3);Request(S2); ···
並不可能發生死鎖。但若改成下述的執行順序:
P1: ···Request(S3);Relese(S1);···
P2: ···Request(S1);Relese(S2); ···
P3: ···Request(S2);Relese(S3); ···
則可能發生死鎖。
二、如何避免或解決死鎖
解決死鎖的方法一般情況下有預防、避免、檢測、解除:
- 預防:採用某種策略,限制併發程序對資源的請求,從而使得死鎖的必要條件在系統執行的任何時間上都不滿足
- 避免:在系統分配資源時,根據資源使用情況提前做出預測,從而避免死鎖的發生
- 檢測:系統設有專門的機構,當死鎖發生時,該機構能檢測死鎖發生並精確確定與死鎖有關的程序和資源
- 解除:與檢測相配套的一種措施,將程序從死鎖狀態下解脫出來
2.1 死鎖預防
對於死鎖產生的四個必要條件,只要破壞其中一個條件,就可以預防死鎖的發生:
破壞第一個條件 互底條件:使得資源是可以同時訪問的,這是種簡單的方法,磁碟就可以用這種方法管理,但是我們要知道,有很多資源往往是不能同時訪問的,所以這種做法在大多數的場合是行不通的。
破壞第三個條件 不可剝奪/非搶佔:也就是說可以採用剝奪式排程演算法,但剝奪式排程方法目前一般僅適用於主存資源和處理器資源的分配,並不適用於所有的資源,會導致資源利用率下降。
所以一般比較實用的預防死鎖的方法,是透過考慮破壞第二個條件和第四個條件。
(1)破壞持有等待:靜態分配策略,一次性申請所有資源
靜態分配策略可以破壞死鎖產生的第二個條件(佔有並等待)。所謂靜態分配策略,就是指一個程序必須在執行前就申請到它所需要的全部資源,並且知道它所要的資源都得到滿足之後才開始執行。程序要麼佔有所有的資源然後開始執行,要麼不佔有資源,不會出現佔有一些資源等待一些資源的情況。
靜態分配策略邏輯簡單,實現也很容易,但這種策略嚴重地降低了資源利用率,因為在每個程序所佔有的資源中,有些資源是在比較靠後的執行時間裡採用的,甚至有些資源是在額外的情況下才使用的,這樣就可能造成一個程序佔有了一些幾乎不用的資源而使其他需要該資源的程序產生等待的情況。
(2)破壞環路等待:層次分配策略,所有資源被分成多個層次。一個程序得到某資源後只能申請較高一層的資源;一個資源釋放資源只能先釋放較高層的資源
層次分配策略破壞了產生死鎖的第四個條件(迴圈等待)。在層次分配策略下,所有的資源被分成了多個層次,一個程序得到某一次的一個資源後,它只能再申請較高一層的資源;當一個程序要釋放某層的一個資源時,必須先釋放所佔用的較高層的資源,按這種策略,是不可能出現迴圈等待鏈的,因為那樣的話,就出現了已經申請了較高層的資源,反而去申請了較低層的資源,不符合層次分配策略,證明略。
2.2 死鎖避免
上面提到的破壞死鎖產生的四個必要條件之一就可以成功 預防系統發生死鎖,但是會導致低效的程序執行和資源使用率。而死鎖的避免相反,它的角度是允許系統中同時存在四個必要條件,只要掌握併發程序中與每個程序有關的資源動態申請情況,做出明智和合理的選擇,仍然可以避免死鎖,因為四大條件僅僅是產生死鎖的必要條件。
我們將系統的狀態分為安全狀態和不安全狀態,每當在為申請者分配資源前先測試系統狀態,若把系統資源分配給申請者會產生死鎖,則拒絕分配,否則接受申請,併為它分配資源。
- 如果作業系統能夠保證所有的程序在有限的時間內得到需要的全部資源,則稱系統處於安全狀態,否則說系統是不安全的。很顯然,系統處於安全狀態則不會發生死鎖,系統若處於不安全狀態則可能發生死鎖。
那麼如何保證系統保持在安全狀態呢?透過演算法,其中最具有代表性的避免死鎖演算法就是Dijkstra的銀行家演算法,銀行家演算法用一句話表達就是:當一個程序申請使用資源的時候,銀行家演算法透過先試探分配給該程序資源,然後透過安全性演算法判斷分配後系統是否處於安全狀態,若不安全則試探分配作廢,讓該程序繼續等待,若能夠進入到安全的狀態,則就真的分配資源給該程序。
銀行家演算法詳情可見:一句話+一張圖說清楚一一銀行家演算法
死鎖的避免(銀行家演算法)改善了資源使用率低的問題,但是它要不斷地檢測每個程序對各類資源的佔用和申請情況,以及做安全性檢查,需要花費較多的時間。
2.3 死鎖檢測
對資源的分配加以限制可以預防和避免死鎖的發生,但是都不利於各程序對系統資源的充分共享。解決死鎖問題的另一條途徑是死鎖檢測和解除(這裡突然聯想到了樂觀鎖和悲觀鎖,感覺死鎖的檢測和解除就像是樂觀鎖,分配資源時不去提前管會不會發生死鎖了,等到真的死鎖出現了再來解決嘛,而死鎖的預防和避免更像是悲觀鎖,總是覺得死鎖會出現,所以在分配資源的時候就很謹慎)。
這種方法對資源的分配不加以任何限制,也不採取死鎖避免措施,但系統定時地執行一個“死鎖檢測”的程式,判斷系統內是否出現死鎖,如果檢測到系統發生了死鎖,再採取措施去解除它。
程序-資源分配圖中存在環路並不一定是發生了死鎖。因為迴圈等待資源僅僅是死鎖發生的必要條件,而不是充分條件。圖2-22便是一個有環路而無死鎖的例子。雖然程序P1和程序P3分別佔用了一個資源R1和一個資源R2,並且因為等待另一個資源R2和另一個資源R1形成了環路,但程序P2和程序P4分別佔有了一個資源R1和一個資源R2,它們申請的資源得到了滿足,在有限的時間裡會歸還資源,於是程序P1或P3都能獲得另一個所需的資源,環路自動解除,系統也就不存在死鎖狀態了。
檢測系統是否產生死鎖:
- 若程序-資源分配圖中無環路,則此時系統沒有發生死鎖
- 若程序-資源分配圖中有環路,且每個資源類僅有一個資源,則系統發生了死鎖
- 若程序-資源分配圖中有換圖,且資源類有多個資源,此時系統未必會發生死鎖。若程序-資源分配圖中能找出一個 既不阻塞又非獨立的程序,該程序在有限時間內歸還佔用的資源,也就是把邊消除了,重複此過程,直至在有限時間內消除所有邊,則不會發生死鎖(消除邊的過程類似於 拓撲排序)。
2.4 死鎖解除
當死鎖檢測程式檢測到存在死鎖發生時,應設法讓其解除,讓系統從死鎖狀態中恢復過來,常用的解除死鎖的方法有以下四種:
- 立即結束所有程序的執行,重新啟動作業系統:方法簡單,但以前所有工作全部作廢,損失很大
- 撤銷涉及死鎖的所有程序,解除死鎖後繼續執行:打破了死鎖的迴圈等待條件,但將付出很大代價,例如有些程序可能已經計算了很長時間,由於被撤銷而使產生的部分結果也被消除了,再重新執行時還要再次進行計算。
- 逐個撤銷涉及死鎖的程序,回收資源直至死鎖解除
- 搶佔資源,從涉及死鎖的一個或多個程序中搶佔資源,把奪得的資源再分配給涉及死鎖的程序直至死鎖解除
三、資料庫鎖
3.1 鎖分類
MySQL
的鎖機制與索引機制類似,都是由儲存引擎負責實現的,這也就意味著不同的儲存引擎,支援的鎖也並不同,這裡是指不同的引擎實現的鎖粒度不同。但除開從鎖粒度來劃分鎖之外,其實鎖也可以從其他的維度來劃分,因此也會造出很多關於鎖的名詞,下面先簡單梳理一下MySQL
的鎖體系:
- 以鎖粒度的維度劃分
- 全域性鎖:鎖定資料庫中的所有表。加上全域性鎖之後,整個資料庫只能允許讀,不允許做任何寫操作
- 表級鎖:每次操作鎖住整張表。主要分為三類
- 表鎖(分為表共享讀鎖 read lock、表獨佔寫鎖 write lock)
- 後設資料鎖(meta data lock,MDL):基於表的後設資料加鎖,加鎖後整張表不允許其他事務操作。這裡的後設資料可以簡單理解為一張表的表結構
- 意向鎖(分為意向共享鎖、意向排他鎖):這個是
InnoDB
中為了支援多粒度的鎖,為了相容行鎖、表鎖而設計的,使得表鎖不用檢查每行資料是否加鎖,使用意向鎖來減少表鎖的檢查
- 行級鎖:每次操作鎖住對應的行資料。主要分為三類
- 記錄鎖 / Record 鎖:也就是行鎖,一條記錄和一行資料是同一個意思。防止其他事務對此行進行update和delete,在 RC、RR隔離級別下都支援
- 間隙鎖 / Gap 鎖:鎖定索引記錄間隙(不含該記錄),確保索引記錄間隙不變,防止其他事務在這個間隙進行insert,產生幻讀。在RR隔離級別下都支援
- 臨鍵鎖 / Next-Key 鎖:間隙鎖的升級版,同時具備記錄鎖+間隙鎖的功能,左開右閉區間,在RR隔離級別下支援
- 以互斥性的角度劃分
- 共享鎖 / S鎖:不同事務之間不會相互排斥、可以同時獲取的鎖
- 排他鎖 / X鎖:exclusive,不同事務之間會相互排斥、同時只能允許一個事務獲取的鎖
- 共享排他鎖 / SX鎖:
MySQL5.7
版本中新引入的鎖,主要是解決SMO
帶來的問題
- 以操作型別的維度劃分
- 讀鎖:查詢資料時使用的鎖
- 寫鎖:執行插入、刪除、修改、
DDL
語句時使用的鎖
- 以加鎖方式的維度劃分
- 顯示鎖:編寫
SQL
語句時,手動指定加鎖的粒度 - 隱式鎖:執行
SQL
語句時,根據隔離級別自動為SQL
操作加鎖
- 顯示鎖:編寫
- 以思想的維度劃分
- 樂觀鎖:每次執行前認為自己會成功,因此先嚐試執行,失敗時再獲取鎖
- 悲觀鎖:每次執行前都認為自己無法成功,因此會先獲取鎖,然後再執行
放眼望下來,是不是看著還蠻多的,但總歸說來說去其實就共享鎖、排他鎖兩種,只是加的方式不同、加的地方不同,因此就演化出了這麼多鎖的稱呼。
各類鎖的具體詳解,可查閱 MySQL鎖、加鎖機制(超詳細)—— 鎖分類、全域性鎖、共享鎖、排他鎖;表鎖、後設資料鎖、意向鎖;行鎖、間隙鎖、臨鍵鎖;樂觀鎖、悲觀鎖
3.2 InnoDB中不同SQL語句設定的鎖
InnoDB設定特定型別的鎖如下:
SELECT ... FROM
是一致性讀,讀取資料庫的快照並且不設定鎖,除非將事務隔離級別設定為SERIALIZABLE
。對於SERIALIZABLE
級別,搜尋在遇到的索引記錄上設定共享的臨鍵鎖。但是,對於使用唯一索引鎖定行來搜尋唯一行的語句,只需要索引記錄鎖。- 對於
SELECT ... FOR UPDATE
或SELECT ... LOCK IN SHARE MODE
,會為掃描的行獲取鎖,並且預計會為不符合包含在結果集中的行釋放鎖(例如,如果它們不滿足子句中給出的條件WHERE
)。但是,在某些情況下,行可能不會立即解鎖,因為結果行與其原始源之間的關係在查詢執行期間丟失。例如,在一個UNION
,在評估表中掃描(並鎖定)的行是否符合結果集之前,可能會將這些行插入到臨時表中。在這種情況下,臨時表中的行與原始表中的行的關係將丟失,並且原始表中的行直到查詢執行結束才解鎖。SELECT ... LOCK IN SHARE MODE
對搜尋遇到的所有索引記錄設定共享的臨鍵鎖。但是,對於使用唯一索引鎖定行來搜尋唯一行的語句,只需要索引記錄鎖。SELECT ... FOR UPDATE
對搜尋遇到的每個記錄設定獨佔的臨鍵鎖鎖定。但是,對於使用唯一索引鎖定行來搜尋唯一行的語句,只需要索引記錄鎖。 對於搜尋遇到的索引記錄,SELECT ... FOR UPDATE
會阻止其他會話在某些事務隔離級別中執行SELECT ... LOCK IN SHARE MODE
或讀取操作。一致讀取會忽略對讀取檢視中存在的記錄設定的任何鎖定。
UPDATE ... WHERE ...
對搜尋遇到的每個記錄設定獨佔的臨鍵鎖鎖定。但是,對於使用唯一索引鎖定行來搜尋唯一行的語句,只需要索引記錄鎖。- 當
UPDATE
修改聚集索引記錄時,將對受影響的輔助索引記錄進行隱式鎖定。UPDATE
在插入新的二級索引記錄之前執行重複檢查掃描時,以及插入新的二級索引記錄時,該操作還會對受影響的二級索引記錄獲取共享鎖 。 DELETE FROM ... WHERE ...
對搜尋遇到的每個記錄設定獨佔的臨鍵鎖鎖定。但是,對於使用唯一索引鎖定行來搜尋唯一行的語句,只需要索引記錄鎖。INSERT
在插入的行上設定排它鎖。該鎖是索引記錄鎖,而不是臨鍵鎖(即沒有間隙鎖),並且不會阻止其他會話插入到插入行之前的間隙中。 在插入行之前,會設定一種稱為插入意向鎖的間隙鎖。此鎖表明插入的意圖是,插入同一索引間隙的多個事務如果沒有插入間隙內的同一位置,則無需互相等待。 如果發生重複鍵錯誤,則會在重複索引記錄上設定共享鎖。如果另一個會話已經擁有排它鎖,則如果多個會話嘗試插入同一行,則使用共享鎖可能會導致死鎖。
sql語句 | 加鎖 |
---|---|
SELECT ... FROM | 不加鎖 |
flush tables with read lock; | 加全域性鎖、獲取全域性鎖 |
lock tables 表名... read/write | 表鎖,表共享讀/寫鎖 |
SELECT ... LOCK IN SHARE MODE | 共享的臨鍵鎖(搜尋到的)/記錄鎖(唯一索引) |
SELECT ... FOR UPDATE | 排他的臨鍵鎖(搜尋到的)/記錄鎖(唯一索引) |
UPDATE ... WHERE ... | 排他的臨鍵鎖(搜尋到的)/記錄鎖(唯一索引) |
DELETE FROM ... WHERE ... | 排他的臨鍵鎖(搜尋到的)/記錄鎖(唯一索引) |
INSERT | 排他的記錄鎖,插入行前的插入意向鎖,唯一鍵衝突時設定共享鎖 |
insert、update、delete、select … for update(增、改、刪、排他鎖) | 意向排它鎖,與表鎖共享鎖(read)及排他鎖(write)都互斥 |
INSERT ... ON DUPLICATE KEY UPDATE | 排他的記錄鎖,插入行前的插入意向鎖,唯一鍵衝突時設定排他鎖 |
alter table ...(修改表結構) | EXCLUSIVE(後設資料排他鎖),與其他的MDL都互斥 |
delete 和update 更新的行不存在的時候會加 間隙鎖。
3.3 控制事務
方式一 關閉事務自動提交,透過commit;
# 1.檢視/設定事務提交方式
SELECT @@autocommit;
SET @@autocommit=0; #設為手動提交事務(1為自動提交,0為手動提交 執行完sql之後 執行commit;)
# 2.提交事務 執行完sql之後 執行commit;
COMMIT;
# 3.回滾事務
ROLLBACK;
注意:上述的這種方式,我們是修改了事務的自動提交行為, 把預設的自動提交修改為了手動提交, 此時我們執行的DML語句都不會提交, 需要手動的執行commit進行提交。
方式二 保持autocommit=1,不必設定autocommit=0。
# 1.開啟事務
START TRANSACTION 或 BEGIN;
...... #執行語句
# 2.提交事務。如果所有語句都成功執行,則提交事務
COMMIT;
# 3.回滾事務。如果在執行過程中發生錯誤,或者你想回滾事務,則使用ROLLBACK
ROLLBACK;
-- 開啟事務
start transaction;
-- 1. 查詢張三餘額
select * from account where name = '張三';
-- 2. 張三的餘額減少1000
update account set money = money - 1000 where name = '張三';
-- 3. 李四的餘額增加1000
update account set money = money + 1000 where name = '李四';
-- 如果正常執行完畢, 則提交事務
commit;
-- 如果執行過程中報錯, 則回滾事務
-- rollback;
四、MySQL中的死鎖
4.1 MySQL中的死鎖現象
MySQL
與Redis、Nginx
這類單執行緒工作的程式不同,它屬於一種內部採用多執行緒工作的應用,因而不可避免的就會產生死鎖問題,比如舉個例子:
SELECT * FROM `wj_account`;
+------------+---------+
| user_name | balance |
+------------+---------+
| Jenny | 6666666 |
| Lucy | 8888888 |
+------------+---------+
-- T1事務:Lucy向Jenny轉賬
UPDATE `wj_account` SET balance = balance - 888 WHERE user_name = "Lucy";
UPDATE `wj_account` SET balance = balance + 888 WHERE user_name = "Jenny";
-- T2事務:Jenny向Lucy轉賬
UPDATE `wj_account` SET balance = balance - 666 WHERE user_name = "Jenny";
UPDATE `wj_account` SET balance = balance + 666 WHERE user_name = "Lucy";
上面有一張很簡單的賬戶表,因為只是為了演示效果,所以其中僅設計了使用者名稱和餘額兩個欄位,緊接著有T1、T2
兩個事務,T1
中Lucy向Jenny轉賬,而T2
中則是Jenny向Lucy轉賬,也就是一個相互轉賬的過程,此時來分析一下:
- ①
T1
事務會先扣減Lucy的賬戶餘額,因此會修改資料,此時會預設加上排他鎖。 - ②
T2
事務也會先扣減Jenny的賬戶餘額,因此同樣會對Jenny這條資料加上排他鎖。 - ③
T1
減完了Lucy的餘額後,準備獲取鎖把Jenny的餘額加888
,但由於此時Jenny的鎖被T2
事務持有,T1
會陷入阻塞等待。 - ④
T2
減完Jenny的餘額後,也準備獲取鎖把Lucy的餘額加666
,但此時Lucy的鎖被T1
持有。
此時就會出現問題,T1
等待T2
釋放鎖、T2
等待T1
釋放鎖,雙方各自等待對方釋放鎖,一直如此僵持下去,最終就引發了死鎖問題,那先來看看具體的SQL
執行情況是什麼樣的呢?如下:
如上圖所示,一步步的跟著標出的序號去看,最終會發現:當死鎖問題出現時,MySQL
會自動檢測並介入,強制回滾結束一個“死鎖的參與者(事務)”,從而打破死鎖的僵局,讓另一個事務能繼續執行。
看到這裡有小夥伴會問了,為啥
MySQL
能自動檢測死鎖呀?其實這跟死鎖檢測機制有關,後續再細說。
注意:
但是要牢記一點,如果你也想自己做上述實驗,那麼千萬不要忘了在建立表後,基於user_name
建立一個主鍵索引:
ALTER TABLE `wj_account` ADD PRIMARY KEY p_index(user_name);
如果你不為user_name
欄位加上主鍵索引,那是無法模擬出死鎖問題的,這是為什麼呢?還記得之前在 《MySQL鎖、加鎖機制(超詳細)—— 鎖分類、全域性鎖、共享鎖、排他鎖;表鎖、後設資料鎖、意向鎖;行鎖、間隙鎖、臨鍵鎖;樂觀鎖、悲觀鎖》 中聊到的記錄鎖嘛?在InnoDB
中,如果一條SQL
語句能命中索引執行,那就會加行鎖,但如果無法命中索引加的就是表鎖。
在上述給出的案例中,因為表中沒有顯示指定主鍵,同時也不存在一個唯一非空的索引,因此InnoDB
會隱式定義一個row_id
來維護聚簇索引的結構,但因為update
語句中無法使用這個隱藏列,所以是走全表方式執行,因此就將整個表資料鎖起來了。
而這裡的四條update
語句都是基於wj_account
賬戶表在操作,因此兩個事務競爭的是同一個鎖資源,所以自然無法復現死鎖現象,也就是T1
修改時,T2
的第一條SQL
也不能執行,會阻塞等待表鎖的釋放。
而當咱們顯示的定義了主鍵索引後,InnoDB
會基於該主鍵欄位去構建聚簇索引,因此後續的update
語句可以命中索引,執行時自然獲取的也是行級別的排他鎖。
4.2 MySQL中死鎖如何解決
在之前關於死鎖的併發文章中聊到過,對於解決死鎖問題可以從多個維度出發,比如預防死鎖、避免死鎖、解除死鎖等,而當死鎖問題出現後該如何解決呢?一般只有兩種方案:
- 鎖超時機制:事務/執行緒在等待鎖時,超出一定時間後自動放棄等待並返回。
- 外力介入打破僵局:第三者介入,將死鎖情況中的某個事務/執行緒強制結束,讓其他事務繼續執行。
4.2.1 MySQL的鎖超時機制
在InnoDB
中其實提供了鎖的超時機制,也就是一個事務在長時間內無法獲取到鎖時,就會主動放棄等待,丟擲相關的錯誤碼及資訊,然後返回給客戶端。但這裡的時間限制到底是多久呢?可以透過如下命令查詢:
show variables like 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50 |
+--------------------------+-------+
預設的鎖超時時間是50s
,也就是在50s
內未獲取到鎖的事務,會自動結束並返回。那也就意味著當死鎖情況出現時,這個死鎖過程最多持續50s
,然後其中就會有一個事務主動退出競爭,釋放持有的鎖資源,這似乎聽起來蠻不錯呀,但實際業務中,僅依賴超時機制去解除死鎖是不夠的,畢竟高併發情況下,50s
時間太長了,會導致越來越多的事務阻塞。
那麼咱們能不能把這個引數調小一點呢?比如調到1s
,可以嗎?當然可以,確實也能確保死鎖發生後,在很短的時間內可以自動解除,但改掉了這個引數之後,也會影響正常事務等待鎖的時間,也就是大部分未發生死鎖,但需要等待鎖資源的事務,在等待1s
之後,就會立馬報錯並返回,這顯然並不合理,畢竟容易誤傷“友軍”。
也正是由於依靠鎖超時機制,略微有些不靠譜,因此InnoDB
也專門針對於死鎖問題,研發了一種檢測演算法,名為wait-for graph
演算法。
4.2.2 死鎖檢測演算法 - wait-for graph
這種演算法是專門用於檢測死鎖問題的,在該演算法中會對於目前庫中所有活躍的事務生成等待圖,啥意思呢?以上述的死鎖案例來看,在MySQL
內部會生成一張這樣的等待圖:
也就是T1
持有著「Lucy」這條資料的鎖,正在等待獲取「Jenny」這條資料的鎖,而T2
事務持有「Jenny」這條資料的鎖,正在等待獲取「Lucy」這條資料的鎖,最終T1、T2
兩個事務之間就出現了等待閉環,因此當MySQL
發現了這種等待閉環時,就會強制介入,回滾結束其中一個事務,強制打破該閉環,從而解除死鎖問題【這個“等待圖”只是為了方便理解畫出來的,內部的實現其實存在些許差異】。
wait-for graph
演算法被啟用後,會要求MySQL
收集兩個資訊:
- 鎖的資訊連結串列:目前持有每個鎖的事務是誰。
- 事務等待連結串列:阻塞的事務要等待的鎖是誰。
每當一個事務需要阻塞等待某個鎖時,就會觸發一次wait-for graph
演算法,該演算法會以當前事務作為起點,然後從「鎖的資訊連結串列」中找到對應中鎖資訊,再去根據鎖的持有者(事務),在「事務等待連結串列」中進行查詢,看看持有鎖的事務是否在等待獲取其他鎖,如果是,則再去看看另一個持有鎖的事務,是否在等待其他鎖.....,經過一系列的判斷後,再看看是否會出現閉環,出現的話則介入破壞。
案例理解:
上面這個演算法的過程,聽起來似乎有些暈乎乎的,但實際上並不難,套個例子來理解,好比目前庫中有T1、T2、T3
三個事務、有X1、X2、X3
三個鎖,事務與鎖的關係如下:
此時當T3
事務需要阻塞等待獲取X1
鎖時,就會觸發一次wait-for graph
演算法,流程如下:
- ①先根據
T3
要獲取的X1
鎖,在「鎖的資訊連結串列」中找到X1
鎖的持有者T1
。 - ②再在「事務等待連結串列」中查詢,看看
T1
是否在等待獲取其他鎖,此時會得知T1
等待X2
。 - ③再去「鎖的資訊連結串列」中找到
X2
鎖的持有者T2
,再看看T2
是否在阻塞等待獲取其他鎖。 - ④再在「事務等待連結串列」中查詢
T2
,發現T2
正在等待獲取X3
鎖,再找X3
鎖的持有者。
經過上述一系列演算法過程後,最終會發現X3
鎖的持有者為T3
,而本次演算法又正是T3
事務觸發的,此時又回到了T3
事務,也就代表著產生了“閉環”,因此也可以證明這裡出現了死鎖現象,所以MySQL
會強制回滾其中的一個事務,來抵達解除死鎖的目的。
但出現死鎖問題時,
MySQL
會選擇哪個事務回滾呢?之前分析過,當一個事務在執行SQL
更改資料時,都會記錄在Undo-log
日誌中,Undo
量越小的事務,代表它對資料的更改越少,同時回滾的代價最低,因此會選擇Undo
量最小的事務回滾(如若兩個事務的Undo
量相同,會選擇回滾觸發死鎖的事務)。
同時,可以透過innodb_deadlock_detect=on|off
這個引數,來控制是否開啟死鎖檢測機制。
注意:死鎖檢測機制在MySQL
後續的高版本中是預設開啟的,但實際上死鎖檢測的開銷不小,上面三個併發事務阻塞時,會對「事務等待連結串列、鎖的資訊連結串列」共計檢索六次,那當阻塞的併發事務越來越多時,檢測的效率也會呈線性增長。
4.2.3 如何預防/避免死鎖產生
因為死鎖的檢測過程較為耗時,所以儘量不要等死鎖出現後再去解除,而是儘量調整業務避免死鎖的產生,一般來說可以從如下方面考慮:
- 維持一定的鎖定順序:如果不同程式會併發存取多個表,儘量約定以相同的順序訪問表,可以大大降低死鎖機會,因為事務不會因為等待其他事務釋放鎖而相互阻塞。例如,如果有多個表或資源需要鎖定,總是按照相同的順序(如字典順序)鎖定這些資源。
- 減小鎖粒度:合理的設計索引結構,使業務
SQL
在執行時能透過索引定位到具體的幾行資料,避免無索引行鎖升級為表鎖,儘量使用行級鎖而不是表級鎖,減小鎖的粒度(SQL語句中不要使用太複雜的關聯多表的查詢;使用explain“執行計劃"對SQL語句進行分析,對於有全表掃描和全表鎖定的SQL語句,建立相應的索引進行最佳化) - 使用低隔離級別:業務允許的情況下,也可以將隔離級別調低,因為級別越低,鎖的限制會越小。
- 調整業務
SQL
的邏輯順序,較大、耗時較長的事務儘量放在特定時間去執行(如凌晨對賬...)。 - 縮短一個事務持有鎖的時間:避免事務中的使用者互動,保持事務簡短並在一個批處理中;儘可能的拆分業務的粒度,一個業務組成的大事務,儘量拆成多個小事務,縮短一個事務持有鎖的時間
- 減少不必要的鎖:如果沒有強制性要求,就儘量不要手動在事務中獲取排他鎖,否則會造成一些不必要的鎖出現,增大產生死鎖的機率。
- 減少事務持續時間、使用鎖超時:儘量縮短事務的執行時間,長事務佔用鎖的時間越長,與其他事務發生衝突的可能性就越大;設定鎖的超時時間,在等待鎖超過設定的時間後將自動回滾,這不僅可以防止死鎖,還可以避免一個事務無限期地等待資源
- 如果業務處理不好,可以用分散式事務鎖或者使用樂觀鎖
- 監控和日誌記錄:實施監控和日誌記錄來跟蹤死鎖和效能瓶頸。這可以幫助識別導致死鎖的具體事務和操作,從而進行針對性的最佳化。
- 死鎖檢測和回滾:啟用資料庫的死鎖檢測功能,讓資料庫管理系統能夠自動檢測死鎖並回滾某個事務來解鎖。這通常是最後的手段,因為它可能導致資料不一致的問題。應當只在其他方法都無法實現時使用。
- ........
其實簡單來說,也就是在業務允許的情況下,儘量縮短一個事務持有鎖的時間、減小鎖的粒度以及鎖的數量。
同時也要記住:當
MySQL
執行過程中產生了死鎖問題,那這個死鎖問題以後絕對會再次出現,當死鎖被MySQL
自己解除後,一定要記住去排除業務SQL
的執行邏輯,找到產生死鎖的業務,然後調整業務SQL
的執行順序,這樣才能從根源上避免死鎖產生。
五、如何確保 N 個執行緒可以訪問 N 個資源,同時又不導致死鎖
使用多執行緒的時候,一種非常簡單的避免死鎖的方式就是:指定獲取鎖的順序,並強制執行緒按照指定的順序獲取鎖。
因此,如果所有的執行緒都是以同樣的順序加鎖和釋放鎖,就不會出現死鎖了。
參考 JavaGuide作業系統常見面試題總結、MySQL死鎖及原始碼分析!、全解MySQL之死鎖問題分析、事務隔離與鎖機制的底層原理剖析