一起聊聊兩條INSERT語句引發的死鎖

2022-02-10 16:00:47
本篇文章給大家帶來了關於mysql中死鎖的相關問題,主要介紹了兩條一樣的INSERT語句引發死鎖的相關知識,希望對大家有幫助。

兩條一樣的INSERT語句竟然引發了死鎖,這究竟是人性的扭曲,還是道德的淪喪,讓我們不禁感嘆一句:臥槽!這也能死鎖,然後眼中含著悲催的淚水無奈的改起了業務程式碼。

好的,在深入分析為啥兩條一樣的INSERT語句也會產生死鎖之前,我們先介紹一些基礎知識。

準備一下環境

為了故事的順利發展,我們新建一個用了無數次的hero表:

CREATE TABLE hero (
    number INT AUTO_INCREMENT,
    name VARCHAR(100),
    country varchar(100),
    PRIMARY KEY (number),
    UNIQUE KEY uk_name (name)
) Engine=InnoDB CHARSET=utf8;

然後向這個表裡插入幾條記錄:

INSERT INTO hero VALUES
    (1, 'l劉備', '蜀'),
    (3, 'z諸葛亮', '蜀'),
    (8, 'c曹操', '魏'),
    (15, 'x荀彧', '魏'),
    (20, 's孫權', '吳');

現在hero表就有了兩個索引(一個唯一二級索引,一個聚簇索引),示意圖如下:

02.png

INSERT語句如何加鎖

讀過《MySQL是怎樣執行的:從根兒上理解MySQL》的小夥伴肯定知道,INSERT語句在正常執行時是不會生成鎖結構的,它是靠聚簇索引記錄自帶的trx_id隱藏列來作為隱式鎖來保護記錄的。

但是在一些特殊場景下,INSERT語句還是會生成鎖結構的,我們列舉一下:

1. 待插入記錄的下一條記錄上已經被其他事務加了gap鎖時

每插入一條新記錄,都需要看一下待插入記錄的下一條記錄上是否已經被加了gap鎖,如果已加gap鎖,那INSERT語句應該被阻塞,並生成一個插入意向鎖。

比方說對於hero表來說,事務T1執行在REPEATABLE READ(後續簡稱為RR,後續也會把READ COMMITTED簡稱為RC)隔離級別中,執行了下邊的語句:

# 事務T1
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM hero WHERE number < 8 FOR UPDATE;
+--------+------------+---------+
| number | name       | country |
+--------+------------+---------+
|      1 | l劉備      | 蜀      |
|      3 | z諸葛亮    | 蜀      |
+--------+------------+---------+
2 rows in set (0.02 sec)

這條語句會對主鍵值為1、3、8的這3條記錄都新增X型next-key鎖,不信的話我們使用SHOW ENGINE INNODB STATUS語句看一下加鎖情況,圖中箭頭指向的記錄就是number值為8的記錄:

03.png

小貼士:

至於SELECT、DELETE、UPDATE語句如何加鎖,我們已經在之前的文章中分析過了,這裡就不再贅述了。

此時事務T2想插入一條主鍵值為4的聚簇索引記錄,那麼T2在插入記錄前,首先要定位一下主鍵值為4的聚簇索引記錄在頁面中的位置,發現主鍵值為4的下一條記錄的主鍵值是8,而主鍵值是8的聚簇索引記錄已經被新增了gap鎖(next-key鎖包含了正經記錄鎖和gap鎖),那麼事務1就需要進入阻塞狀態,並生成一個型別為插入意向鎖的鎖結構。

我們在事務T2中執行一下INSERT語句驗證一下:

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO hero VALUES(4, 'g關羽', '蜀');

此時T2進入阻塞狀態,我們再使用SHOW ENGINE INNODB STATUS看一下加鎖情況:

04.png

可見T2對主鍵值為8的聚簇索引記錄加了一個插入意向鎖(就是箭頭處指向的lock_mode X locks gap before rec insert intention),並且處在waiting狀態。

好了,驗證過之後,我們再來看看程式碼裡是如何實現的:

05.png

lock_rec_insert_check_and_lock函數用於看一下別的事務是否阻止本次INSERT插入,如果是,那麼本事務就給被別的事務新增了gap鎖的記錄生成一個插入意向鎖,具體過程如下:

06.png

小貼士:

lock_rec_other_has_conflicting函數用於檢測本次要獲取的鎖和記錄上已有的鎖是否有衝突,有興趣的同學可以看一下。

2. 遇到重複鍵時

如果在插入新記錄時,發現頁面中已有的記錄的主鍵或者唯一二級索引列與待插入記錄的主鍵或者唯一二級索引列值相同(不過可以有多條記錄的唯一二級索引列的值同時為NULL,這裡不考慮這種情況了),此時插入新記錄的事務會獲取頁面中已存在的鍵值相同的記錄的鎖。

如果是主鍵值重複,那麼:

  • 當隔離級別不大於RC時,插入新記錄的事務會給已存在的主鍵值重複的聚簇索引記錄新增S型正經記錄鎖。

  • 當隔離級別不小於RR時,插入新記錄的事務會給已存在的主鍵值重複的聚簇索引記錄新增S型next-key鎖。

如果是唯一二級索引列重複,那不論是哪個隔離級別,插入新記錄的事務都會給已存在的二級索引列值重複的二級索引記錄新增S型next-key鎖,再強調一遍,加的是next-key鎖!加的是next-key鎖!加的是next-key鎖!這是rc隔離級別中為數不多的給記錄新增gap鎖的場景。

小貼士:

本來設計InnoDB的大叔並不想在RC隔離級別引入gap鎖,但是由於某些原因,如果不新增gap鎖的話,會讓唯一二級索引中出現多條唯一二級索引列值相同的記錄,這就違背了UNIQUE約束。所以後來設計InnoDB的大叔就很不情願的在RC隔離級別也引入了gap鎖。

我們也來做一個實驗,現在假設上邊的T1和T2都回滾了,現在將隔離級別調至RC,重新開啟事務進行測試。

mysql> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Query OK, 0 rows affected (0.01 sec)
# 事務T1
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO hero VALUES(30, 'x荀彧', '魏');
ERROR 1062 (23000): Duplicate entry 'x荀彧' for key 'uk_name'

然後執行SHOW ENGINE INNODB STATUS語句看一下T1加了什麼鎖:

07.png

可以看到即使現在T1的隔離級別為RC,T1仍然給name列值為'x荀彧'的二級索引記錄新增了S型next-key鎖(圖中紅框中的lock mode S)。

如果我們的INSERT語句還帶有ON DUPLICATE KEY... 這樣的子句,如果遇到主鍵值或者唯一二級索引列值重複的情況,會對B+樹中已存在的相同鍵值的記錄加X型鎖,而不是S型鎖(不過具體鎖的具體型別是和前面描述一樣的)。

好了,又到了看程式碼求證時間了,我們看一下吧:

08.png

row_ins_scan_sec_index_for_duplicate是檢測唯一二級索引列值是否重複的函數,具體加鎖的程式碼如下所示:

09.png

如上圖所示,在遇到唯一二級索引列重複的情況時:

  • 1號紅框表示對帶有ON DUPLICATE ...子句時的處理方案,具體就是新增X型鎖。

  • 2號紅框表示對正常INSERT語句的處理方案,具體就是新增S型鎖。

不過不論是那種情況,新增的lock_typed的值都是LOCK_ORDINARY,表示next-key鎖。

在主鍵重複時INSERT語句的加鎖程式碼我們就不列舉了。

3. 外來鍵檢查時

當我們向子表中插入記錄時,我們分兩種情況討論:

  • 當子表中的外來鍵值可以在父表中找到時,那麼無論當前事務是什麼隔離級別,只需要給父表中對應的記錄新增一個S型正經記錄鎖就好了。

  • 當子表中的外來鍵值在父表中找不到時:那麼如果當前隔離級別不大於RC時,不對父表記錄加鎖;當隔離級別不小於RR時,對父表中該外來鍵值所在位置的下一條記錄新增gap鎖。

死鎖要出場了

好了,基礎知識預習完了,該死鎖出場了。

看下邊這個平平無奇的INSERT語句:

INSERT INTO hero(name, country) VALUES('g關羽', '蜀'), ('d鄧艾', '魏');

這個語句用來插入兩條記錄,不論是在RC,還是RR隔離級別,如果兩個事務並行執行它們是有一定機率觸發死鎖的。為了穩定復現這個死鎖,我們把上邊一條語句拆分成兩條語句:

INSERT INTO hero(name, country) VALUES('g關羽', '蜀');
INSERT INTO hero(name, country) VALUES('d鄧艾', '魏');

拆分前和拆分後起到的作用是相同的,只不過拆分後我們可以人為的控制插入記錄的時機。如果T1和T2的執行順序是這樣的:

10.png

也就是:

  • T1先插入name值為g關羽的記錄,可以插入成功,此時對應的唯一二級索引記錄被隱式鎖保護,我們執行SHOW ENGINE INNODB STATUS語句,發現啥一個行鎖(row lock)都沒有(因為SHOW ENGINE INNODB STATUS不顯示隱式鎖):

11.png

  • 接著T2也插入name值為g關羽的記錄。由於T1已經插入name值為g關羽的記錄,所以T2在插入二級索引記錄時會遇到重複的唯一二級索引列值,此時T2想獲取一個S型next-key鎖,但是T1並未提交,T1插入的name值為g關羽的記錄上的隱式鎖相當於一個X型正經記錄鎖(RC隔離級別),所以T2向獲取S型next-key鎖時會遇到鎖衝突,T2進入阻塞狀態,並且將T1的隱式鎖轉換為顯式鎖(就是幫助T1生成一個正經記錄鎖的鎖結構)。這時我們再執行SHOW ENGINE INNODB STATUS語句:

12.png

可見,T1持有的name值為g關羽的隱式鎖已經被轉換為顯式鎖(X型正經記錄鎖,lock_mode X locks rec but not gap);T2正在等待獲取一個S型next-key鎖(lock mode S waiting)。

  • 接著T1再插入一條name值為d鄧艾的記錄。在插入一條記錄時,會在頁面中先定位到這條記錄的位置。在插入name值為d鄧艾的二級索引記錄時,發現現在頁面中的記錄分佈情況如下所示:

13.png

很顯然,name值為'd鄧艾'的二級索引記錄所在位置的下一條二級索引記錄的name值應該是'g關羽'(按照漢語拼音排序)。那麼在T1插入name值為d鄧艾的二級索引記錄時,就需要看一下name值為'g關羽'的二級索引記錄上有沒有被別的事務加gap鎖。

有同學想說:目前只有T2想在name值為'g關羽'的二級索引記錄上新增S型next-key鎖(next-key鎖包含gap鎖),但是T2並沒有獲取到鎖呀,目前正在等待狀態。那麼T1不是能順利插入name值為'g關羽'的二級索引記錄麼?

我們看一下執行結果:

# 事務T2
mysql> INSERT INTO hero(name, country) VALUES('g關羽', '蜀');
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

很顯然,觸發了一個死鎖,T2被InnoDB回滾了。

這是為啥呢?T2明明沒有獲取到name值為'g關羽'的二級索引記錄上的S型next-key鎖,為啥T1還不能插入入name值為d鄧艾的二級索引記錄呢?

這我們還得回到程式碼上來,看一下插入新記錄時是如何判斷鎖是否衝突的:

14.png

看一下畫紅框的註釋,意思是:只要別的事務生成了一個顯式的gap鎖的鎖結構,不論那個事務已經獲取到了該鎖(granted),還是正在等待獲取(waiting),當前事務的INSERT操作都應該被阻塞。

回到我們的例子中來,就是T2已經在name值為'g關羽'的二級索引記錄上生成了一個S型next-key鎖的鎖結構,雖然T2正在阻塞(尚未獲取鎖),但是T1仍然不能插入name值為d鄧艾的二級索引記錄。

這樣也就解釋了死鎖產生的原因:

  • T1在等待T2釋放name值為'g關羽'的二級索引記錄上的gap鎖。

  • T2在等待T1釋放name值為'g關羽'的二級索引記錄上的X型正經記錄鎖。

兩個事務相互等待對方釋放鎖,這樣死鎖也就產生了。

怎麼解決這個死鎖問題?

兩個方案:

  • 方案一:一個事務中只插入一條記錄。

  • 方案二:先插入name值為'd鄧艾'的記錄,再插入name值為'g關羽'的記錄

推薦學習:

以上就是一起聊聊兩條INSERT語句引發的死鎖的詳細內容,更多請關注TW511.COM其它相關文章!