MySQL 死鎖了,怎么辦?
之前分享過?? MySQL 死鎖??的文章,然后很多讀者對「插入意向鎖」認識很迷糊。
大家誤以為「插入意向鎖」是意向鎖,也就是表鎖,確實這個名字很讓人誤解。
但是,實際上「插入意向鎖」不是意向鎖,而是特殊的間隙鎖,屬于行級鎖,注意是「特殊」的間隙鎖,并不是我們常說的間隙鎖。
所以,我在原來文章的基礎(chǔ)上補充了兩個知識點:
- 什么是插入意向鎖?
- insert 語句是怎么加鎖的?
提綱如下:
有個業(yè)務(wù)主要邏輯就是新增訂單、修改訂單、查詢訂單等操作。然后因為訂單是不能重復(fù)的,所以當(dāng)時在新增訂單的時候做了冪等性校驗,做法就是在新增訂單記錄之前,先通過 select ... for update 語句查詢訂單是否存在,如果不存在才插入訂單記錄。
而正是因為這樣的操作,當(dāng)業(yè)務(wù)量很大的時候,就可能會出現(xiàn)死鎖。
接下來跟大家聊下為什么會發(fā)生死鎖,以及怎么避免死鎖。
死鎖的發(fā)生
本次案例使用存儲引擎 Innodb,隔離級別為可重復(fù)讀(RR)。
接下來,我用實戰(zhàn)的方式來帶大家看看死鎖是怎么發(fā)生的。
我建了一張訂單表,其中 id 字段為主鍵索引,order_no 字段普通索引,也就是非唯一索引:
CREATE TABLE `t_order` (
`id` int NOT NULL AUTO_INCREMENT,
`order_no` int DEFAULT NULL,
`create_date` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `index_order` (`order_no`) USING BTREE
) ENGINE=InnoDB ;
然后,先 t_order 表里現(xiàn)在已經(jīng)有了 6 條記錄:
假設(shè)這時有兩事務(wù),一個事務(wù)要插入訂單 1007 ,另外一個事務(wù)要插入訂單 1008,因為需要對訂單做冪等性校驗,所以兩個事務(wù)先要查詢該訂單是否存在,不存在才插入記錄,過程如下:
可以看到,兩個事務(wù)都陷入了等待狀態(tài)(前提沒有打開死鎖檢測),也就是發(fā)生了死鎖,因為都在相互等待對方釋放鎖。
這里在查詢記錄是否存在的時候,使用了 select ... for update 語句,目的為了防止事務(wù)執(zhí)行的過程中,有其他事務(wù)插入了記錄,而出現(xiàn)幻讀的問題。
如果沒有使用 select ... for update 語句,而使用了單純的 select 語句,如果是兩個訂單號一樣的請求同時進來,就會出現(xiàn)兩個重復(fù)的訂單,有可能出現(xiàn)幻讀,如下圖:
為什么會產(chǎn)生死鎖?
可重復(fù)讀隔離級別下,是存在幻讀的問題。
Innodb 引擎為了解決「可重復(fù)讀」隔離級別下的幻讀問題,就引出了 next-key 鎖,它是記錄鎖和間隙鎖的組合。
- Record Loc,記錄鎖,鎖的是記錄本身;
- Gap Lock,間隙鎖,鎖的就是兩個值之間的空隙,以防止其他事務(wù)在這個空隙間插入新的數(shù)據(jù),從而避免幻讀現(xiàn)象。
普通的 select 語句是不會對記錄加鎖的,因為它是通過 MVCC 的機制實現(xiàn)的快照讀,如果要在查詢時對記錄加行鎖,可以使用下面這兩個方式:
begin;
//對讀取的記錄加共享鎖
select ... lock in share mode;
commit; //鎖釋放
begin;
//對讀取的記錄加排他鎖
select ... for update;
commit; //鎖釋放
行鎖的釋放時機是在事務(wù)提交(commit)后,鎖就會被釋放,并不是一條語句執(zhí)行完就釋放行鎖。
比如,下面事務(wù) A 查詢語句會鎖住(2, +∞]范圍的記錄,然后期間如果有其他事務(wù)在這個鎖住的范圍插入數(shù)據(jù)就會被阻塞。
next-key 鎖的加鎖規(guī)則其實挺復(fù)雜的,在一些場景下會退化成記錄鎖或間隙鎖。
需要注意的是,如果 update 語句的 where 條件沒有用到索引列,那么就會全表掃描,在一行行掃描的過程中,不僅給行加上了行鎖,還給行兩邊的空隙也加上了間隙鎖,相當(dāng)于鎖住整個表,然后直到事務(wù)結(jié)束才會釋放鎖。
所以在線上千萬不要執(zhí)行沒有帶索引條件的 update 語句,不然會造成業(yè)務(wù)停滯,我有個讀者就因為干了這個事情,然后被老板教育了一波。
回到前面死鎖的例子,在執(zhí)行下面這條語句的時候:
select id from t_order where order_no = 1008 for update;
因為 order_no 不是唯一索引,所以行鎖的類型是間隙鎖,于是間隙鎖的范圍是(1006, +∞)。那么,當(dāng)事務(wù) B 往間隙鎖里插入 id = 1008 的記錄就會被鎖住。
因為當(dāng)我們執(zhí)行以下插入語句時,會在插入間隙上再次獲取插入意向鎖。
Insert into t_order (order_no, create_date) values (1008, now());
插入意向鎖與間隙鎖是沖突的,所以當(dāng)其它事務(wù)持有該間隙的間隙鎖時,需要等待其它事務(wù)釋放間隙鎖之后,才能獲取到插入意向鎖。而間隙鎖與間隙鎖之間是兼容的,所以所以兩個事務(wù)中 select ... for update 語句并不會相互影響。
案例中的事務(wù) A 和事務(wù) B 在執(zhí)行完后 select ... for update 語句后都持有范圍為(1006,+∞)的間隙鎖,而接下來的插入操作為了獲取到插入意向鎖,都在等待對方事務(wù)的間隙鎖釋放,于是就造成了循環(huán)等待,導(dǎo)致死鎖。
為什么間隙鎖與間隙鎖之間是兼容的?
在MySQL官網(wǎng)上還有一段非常關(guān)鍵的描述:
Gap locks in InnoDB are “purely inhibitive”, which means that their only purpose is to prevent other transactions from Inserting to the gap. 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.
這段話表明間隙鎖在本質(zhì)上是不區(qū)分共享間隙鎖或互斥間隙鎖的,而且間隙鎖是不互斥的,即兩個事務(wù)可以同時持有包含共同間隙的間隙鎖。
這里的共同間隙包括兩種場景:
- 其一是兩個間隙鎖的間隙區(qū)間完全一樣;
- 其二是一個間隙鎖包含的間隙區(qū)間是另一個間隙鎖包含間隙區(qū)間的子集。
間隙鎖本質(zhì)上是用于阻止其他事務(wù)在該間隙內(nèi)插入新記錄,而自身事務(wù)是允許在該間隙內(nèi)插入數(shù)據(jù)的。也就是說間隙鎖的應(yīng)用場景包括并發(fā)讀取、并發(fā)更新、并發(fā)刪除和并發(fā)插入。
插入意向鎖是什么?
注意!插入意向鎖名字雖然有意向鎖,但是它并不是意向鎖,它是一種特殊的間隙鎖。
在MySQL的官方文檔中有以下重要描述:
An Insert intention lock is a type of gap lock set by Insert operations prior to row Insertion. This lock signals the intent to Insert in such a way that multiple transactions Inserting into the same index gap need not wait for each other if they are not Inserting at the same position within the gap. Suppose that there are index records with values of 4 and 7. Separate transactions that attempt to Insert values of 5 and 6, respectively, each lock the gap between 4 and 7 with Insert intention locks prior to obtaining the exclusive lock on the Inserted row, but do not block each other because the rows are nonconflicting.
這段話表明盡管插入意向鎖是一種特殊的間隙鎖,但不同于間隙鎖的是,該鎖只用于并發(fā)插入操作。
如果說間隙鎖鎖住的是一個區(qū)間,那么「插入意向鎖」鎖住的就是一個點。因而從這個角度來說,插入意向鎖確實是一種特殊的間隙鎖。
插入意向鎖與間隙鎖的另一個非常重要的差別是:盡管「插入意向鎖」也屬于間隙鎖,但兩個事務(wù)卻不能在同一時間內(nèi),一個擁有間隙鎖,另一個擁有該間隙區(qū)間內(nèi)的插入意向鎖(當(dāng)然,插入意向鎖如果不在間隙鎖區(qū)間內(nèi)則是可以的)。
另外,我補充一點,插入意向鎖的生成時機:
每插入一條新記錄,都需要看一下待插入記錄的下一條記錄上是否已經(jīng)被加了間隙鎖,如果已加間隙鎖,那 Insert 語句應(yīng)該被阻塞,并生成一個插入意向鎖 。
Insert 語句是怎么加行級鎖的?
Insert 語句在正常執(zhí)行時是不會生成鎖結(jié)構(gòu)的,它是靠聚簇索引記錄自帶的 trx_id 隱藏列來作為隱式鎖來保護記錄的。
什么是隱式鎖?
當(dāng)事務(wù)需要加鎖的時,如果這個鎖不可能發(fā)生沖突,InnoDB會跳過加鎖環(huán)節(jié),這種機制稱為隱式鎖。隱式鎖是 InnoDB 實現(xiàn)的一種延遲加鎖機制,其特點是只有在可能發(fā)生沖突時才加鎖,從而減少了鎖的數(shù)量,提高了系統(tǒng)整體性能。
隱式鎖就是在 Insert 過程中不加鎖,只有在特殊情況下,才會將隱式鎖轉(zhuǎn)換為顯示鎖,這里我們列舉兩個場景。
如果記錄之間加有間隙鎖,為了避免幻讀,此時是不能插入記錄的;
如果 Insert 的記錄和已有記錄存在唯一鍵沖突,此時也不能插入記錄;
1.記錄之間加有間隙鎖
每插入一條新記錄,都需要看一下待插入記錄的下一條記錄上是否已經(jīng)被加了間隙鎖,如果已加間隙鎖,那 Insert 語句應(yīng)該被阻塞,并生成一個插入意向鎖。
舉個例子,現(xiàn)在 t_order 表中,只有這些數(shù)據(jù),order_no 是二級索引。
現(xiàn)在,事務(wù) A 執(zhí)行了下面這條語句。
# 事務(wù) A
mysql> begin;
Query OK, 0 rows affected (0.01 sec)
mysql> select * from t_order where order_no = 1006 for update;
Empty set (0.01 sec)
接著,我們執(zhí)行 select * from performance_schema.data_locks\G; 語句 ,確定事務(wù) A 加了什么類型的鎖,這里只關(guān)注在記錄上加鎖的類型。
可以看到,加的是 X 型得鎖,但是具體是記錄鎖、間隙鎖、next-key 鎖呢?注意,這里 LOCK_TYPE 中的 RECORD 表示行級鎖,而不是記錄鎖的意思。
首先通過 LOCK_MODE 可以確認是「next-key 鎖或者間隙鎖」,還是「記錄鎖」:
- 如果 LOCK_MODE 為 X,說明是 next-key 鎖或者間隙鎖;
- 如果 LOCK_MODE 為X, REC_NOT_GAP,說明是記錄鎖。
對于是 next-key 鎖,還是間隙鎖,就要看 LOCK_DATA 信息。
- 如果 LOCK_DATA 信息為 supremum,說明是間隙鎖;
- 如果 LOCK_DATA 信息為具體的記錄值,說明是 next-key;
因此,本次的例子加的是間隙鎖,間隙鎖的范圍是(1005, +∞)。
然后,有個事務(wù) B 在這個間隙鎖中,插入了一個記錄,那么此時該事務(wù) B 就會被阻塞:
# 事務(wù) B 插入一條記錄
mysql> begin;
Query OK, 0 rows affected (0.01 sec)
mysql> insert into t_order(order_no, create_date) values(1010,now());
### 阻塞狀態(tài)。。。。
接著,我們執(zhí)行 select * from performance_schema.data_locks\G; 語句 ,確定事務(wù) B 加了什么類型的鎖,這里只關(guān)注在記錄上加鎖的類型。
可以看到,事務(wù) B 的狀態(tài)為等待狀態(tài),因為向事務(wù) A 生成的間隙鎖 (1005, +∞) 中插入了一條記錄,所以事務(wù) B 的插入操作生成了一個插入意向鎖(LOCK_MODE: X,INSERT_INTENTION )。
2.遇到唯一鍵沖突
如果在插入新記錄時,插入了一個與「已有的記錄的主鍵或者唯一二級索引列值相同」的記錄」(不過可以有多條記錄的唯一二級索引列的值同時為NULL,這里不考慮這種情況),此時插入就會失敗,然后對于這條記錄加上了 S 型的鎖。
至于是行級鎖的類型是記錄鎖,還是 next-key 鎖,跟是主鍵沖突還是唯一二級索引沖突有關(guān)系。
如果主鍵值重復(fù):
- 當(dāng)隔離級別為讀已提交時,插入新記錄的事務(wù)會給已存在的主鍵值重復(fù)的聚簇索引記錄添加 S 型記錄鎖。
- 當(dāng)隔離級別是可重復(fù)讀(默認隔離級別),插入新記錄的事務(wù)會給已存在的主鍵值重復(fù)的聚簇索引記錄添加 S 型 next-key 鎖。
如果唯一二級索引列重復(fù):
- 不論是哪個隔離級別,插入新記錄的事務(wù)都會給已存在的二級索引列值重復(fù)的二級索引記錄添加 S 型 next-key 鎖。對的,沒錯,即使是讀已提交隔離級別也是加 next-key 鎖,這是讀已提交隔離級別中為數(shù)不多的給記錄添加間隙鎖的場景。因為如果不添加間隙鎖的話,會讓唯一二級索引中出現(xiàn)多條唯一二級索引列值相同的記錄,這就違背了 UNIQUE 的約束。
下面舉個唯一二級索引沖突的例子,MySQL 8.0 版本,事務(wù)隔離級別為可重復(fù)讀(默認隔離級別)。
t_order 表中的 order_no 字段為唯一二級索引,并且已經(jīng)存在 order_no 值為 1001 的記錄,此時事務(wù) A,插入了 order_no 為 1001 的記錄,就出現(xiàn)了報錯。
但是除了報錯之外,還做一個很重要的事情,就是對 order_no 值為 1001 這條記錄加上了 S 型的 next-key 鎖。
我們可以執(zhí)行 select * from performance_schema.data_locks\G; 語句 ,確定事務(wù)加了什么類型的鎖,這里只關(guān)注在記錄上加鎖的類型。
可以看到,index_order 二級索引中的 1001(LOCK_DATA) 記錄的鎖類型為 S 型的 next-key 鎖。注意,這里 LOCK_TYPE 中的 RECORD 表示行級鎖,而不是記錄鎖的意思。如果是記錄鎖的話,LOCK_MODE 會顯示 S, REC_NOT_GAP。
此時,事務(wù) B 執(zhí)行了 select * from t_order where order_no = 1001 for update; 就會阻塞,因為這條語句想加 X 型的鎖,是與 S 型的鎖是沖突的,所以就會被阻塞。
我們也可以從 performance_schema.data_locks 這個表中看到,事務(wù) B 的狀態(tài)(LOCK_STATUS)是等待狀態(tài),加鎖的類型 X 型的記錄鎖(LOCK_MODE: X,REC_NOT_GAP )。
上面的案例是針對唯一二級索引重復(fù)而插入失敗的場景。
接下來,分析兩個事務(wù)執(zhí)行過程中,執(zhí)行了相同的 insert 語句的場景。
現(xiàn)在 t_order 表中,只有這些數(shù)據(jù),order_no 為唯一二級索引。
在隔離級別可重復(fù)讀的情況下,開啟兩個事務(wù),前后執(zhí)行相同的 Insert 語句,此時事務(wù) B 的 Insert 語句會發(fā)生阻塞。
兩個事務(wù)的加鎖過程:
- 事務(wù) A 先插入 order_no 為 1006 的記錄,可以插入成功,此時對應(yīng)的唯一二級索引記錄被「隱式鎖」保護,此時還沒有實際的鎖結(jié)構(gòu);
- 接著,事務(wù) B 也插入 order_no 為 1006 的記錄,由于事務(wù) A 已經(jīng)插入 order_no 值為 1006 的記錄,所以事務(wù) B 在插入二級索引記錄時會遇到重復(fù)的唯一二級索引列值,此時事務(wù) B 想獲取一個 S 型 next-key 鎖,但是事務(wù) A 并未提交,事務(wù) A 插入的 order_no 值為 1006 的記錄上的「隱式鎖」會變「顯示鎖」且鎖類型為 X 型的記錄鎖,所以事務(wù) B 向獲取 S 型 next-key 鎖時會遇到鎖沖突,事務(wù) B 進入阻塞狀態(tài)。
我們可以執(zhí)行 select * from performance_schema.data_locks\G; 語句 ,確定事務(wù)加了什么類型的鎖,這里只關(guān)注在記錄上加鎖的類型。
先看事務(wù) A 對 order_no 為 1006 的記錄加了什么鎖?從下圖可以看到,事務(wù) A 對 order_no 為 1006 記錄加上了類型為 X 型的記錄鎖(注意,這個是在執(zhí)行事務(wù) B 之后才產(chǎn)生的鎖,沒執(zhí)行事務(wù) B 之前,該記錄還是隱式鎖)。
然后看事務(wù) B 想對 order_no 為 1006 的記錄加什么鎖?從下圖可以看到,事務(wù) B 想對 order_no 為 1006 的記錄加 S 型的 next-key 鎖,但是由于事務(wù) A 在該記錄上持有了 X 型的記錄鎖,這兩個鎖是沖突的,所以導(dǎo)致事務(wù) B 處于等待狀態(tài)。
從這個實驗可以得知,并發(fā)多個事務(wù)的時候,第一個事務(wù)插入的記錄,并不會加鎖,而是會用隱式鎖保護唯一二級索引的記錄。
但是當(dāng)?shù)谝粋€事務(wù)還未提交的時候,有其他事務(wù)插入了與第一個事務(wù)相同的記錄,第二個事務(wù)就會被阻塞,因為此時第一事務(wù)插入的記錄中的隱式鎖會變?yōu)轱@示鎖且類型是 X 型的記錄鎖,而第二個事務(wù)是想對該記錄加上 S 型的 next-key 鎖,X 型與 S 型的鎖是沖突的,所以導(dǎo)致第二個事務(wù)會等待,直到第一個事務(wù)提交后,釋放了鎖。
如果 order_no 不是唯一二級索引,那么兩個事務(wù),前后執(zhí)行相同的 Insert 語句,是不會發(fā)生阻塞的,就如前面的這個例子。
如何避免死鎖?
死鎖的四個必要條件:互斥、占有且等待、不可強占用、循環(huán)等待。只要系統(tǒng)發(fā)生死鎖,這些條件必然成立,但是只要破壞任意一個條件就死鎖就不會成立。
在數(shù)據(jù)庫層面,有兩種策略通過「打破循環(huán)等待條件」來解除死鎖狀態(tài):
設(shè)置事務(wù)等待鎖的超時時間。當(dāng)一個事務(wù)的等待時間超過該值后,就對這個事務(wù)進行回滾,于是鎖就釋放了,另一個事務(wù)就可以繼續(xù)執(zhí)行了。在 InnoDB 中,參數(shù) innodb_lock_wait_timeout 是用來設(shè)置超時時間的,默認值時 50 秒。當(dāng)發(fā)生超時后,就出現(xiàn)下面這個提示:
開啟主動死鎖檢測。主動死鎖檢測在發(fā)現(xiàn)死鎖后,主動回滾死鎖鏈條中的某一個事務(wù),讓其他事務(wù)得以繼續(xù)執(zhí)行。將參數(shù) innodb_deadlock_detect 設(shè)置為 on,表示開啟這個邏輯,默認就開啟。當(dāng)檢測到死鎖后,就會出現(xiàn)下面這個提示:
上面這個兩種策略是「當(dāng)有死鎖發(fā)生時」的避免方式。
我們可以回歸業(yè)務(wù)的角度來預(yù)防死鎖,對訂單做冪等性校驗的目的是為了保證不會出現(xiàn)重復(fù)的訂單,那我們可以直接將 order_no 字段設(shè)置為唯一索引列,利用它的唯一下來保證訂單表不會出現(xiàn)重復(fù)的訂單,不過有一點不好的地方就是在我們插入一個已經(jīng)存在的訂單記錄時就會拋出異常。
參考資料:
- 《MySQL 是怎樣運行的?》
- http://mysql.taobao.org/monthly/2020/09/06/