MYSQL死鎖之路 - 常見SQL語句的加鎖分析

這篇博客將對一些常見的 SQL 語句進行加鎖分析,看看我們平時執行的那些 SQL 都會加什麼鎖。只有對我們所寫的 SQL 語句加鎖過程瞭如指掌,才能在遇到死鎖問題時倒推出是什麼鎖導致的問題。在前面的博客中我們已經學習了 MySQL 下不同的鎖模式和鎖類型,我們要特別注意它們的兼容矩陣,熟悉哪些鎖是不兼容的,這些不兼容的鎖往往就是導致死鎖的罪魁禍首。總體來說,MySQL 中的鎖可以分成兩個粒度:表鎖和行鎖,表鎖有:表級讀鎖,表級寫鎖,讀意向鎖,寫意向鎖,自增鎖;行鎖有:讀記錄鎖,寫記錄鎖,間隙鎖,Next-key 鎖,插入意向鎖。不出意外,絕大多數的死鎖問題都是由這些鎖之間的衝突導致的。

我們知道,不同的隔離級別加鎖也是不一樣的,譬如 RR 隔離級別下有間隙鎖和 Next-key 鎖,這在 RC 隔離級別下是沒有的(也有例外),所以在對 SQL 進行加鎖分析時,必須得知道數據庫的隔離級別。由於 RR 和 RC 用的比較多,所以這篇博客只對這兩種隔離級別做分析。

這是《解決死鎖之路》系列博文中的一篇,你還可以閱讀其他幾篇:

  1. 學習事務與隔離級別
  2. 瞭解常見的鎖類型
  3. 掌握常見 SQL 語句的加鎖分析
  4. 死鎖問題的分析和解決

一、基本的加鎖規則

雖然 MySQL 的鎖各式各樣,但是有些基本的加鎖原則是保持不變的,譬如:快照讀是不加鎖的,更新語句肯定是加排它鎖的,RC 隔離級別是沒有間隙鎖的等等。這些規則整理如下,後面就不再重複介紹了:

  • 常見語句的加鎖

SELECT … 語句正常情況下爲快照讀,不加鎖;
SELECT … LOCK IN SHARE MODE 語句爲當前讀,加 S 鎖;
SELECT … FOR UPDATE 語句爲當前讀,加 X 鎖;
常見的 DML 語句(如 INSERT、DELETE、UPDATE)爲當前讀,加 X 鎖;
常見的 DDL 語句(如 ALTER、CREATE 等)加表級鎖,且這些語句爲隱式提交,不能回滾;

  • 表鎖

    • 表鎖(分 S 鎖和 X 鎖)
    • 意向鎖(分 IS 鎖和 IX 鎖)
    • 自增鎖(一般見不到,只有在 innodb_autoinc_lock_mode = 0 或者 Bulk inserts 時纔可能有)
  • 行鎖

    • 記錄鎖(分 S 鎖和 X 鎖)
    • 間隙鎖(分 S 鎖和 X 鎖)
    • Next-key 鎖(分 S 鎖和 X 鎖)
    • 插入意向鎖
  • 行鎖分析

    • 行鎖都是加在索引上的,最終都會落在聚簇索引上;
    • 加行鎖的過程是一條一條記錄加的;
  • 鎖衝突

    • S 鎖和 S 鎖兼容,X 鎖和 X 鎖衝突,X 鎖和 S 鎖衝突;
    • 表鎖和行鎖的衝突矩陣參見前面的博客 瞭解常見的鎖類型
  • 不同隔離級別下的鎖

上面說 SELECT … 語句正常情況下爲快照讀,不加鎖;但是在 Serializable 隔離級別下爲當前讀,加 S 鎖;
RC 隔離級別下沒有間隙鎖和 Next-key 鎖(特殊情況下也會有:purge + unique key);
不同隔離級別下鎖的區別,參見前面的博客 學習事務與隔離級別;

二、簡單 SQL 的加鎖分析

何登成前輩在他的博客《MySQL 加鎖處理分析》中對一些常見的 SQL 加鎖進行了細緻的分析,這篇博客可以說是網上介紹 MySQL 加鎖分析的一個範本,網上幾乎所有關於加鎖分析的博客都是參考了這篇博客,勘稱經典,強烈推薦。我這裏也不例外,只是在他的基礎上進行了一些整理和總結。

我們使用下面這張 students 表作爲實例,其中 id 爲主鍵,no(學號)爲二級唯一索引,name(姓名)和 age(年齡)爲二級非唯一索引,score(學分)無索引。

這一節我們只分析最簡單的一種 SQL,它只包含一個 WHERE 條件,等值查詢或範圍查詢。雖然 SQL 非常簡單,但是針對不同類型的列,我們還是會面對各種情況:

  • 聚簇索引,查詢命中:UPDATE students SET score = 100 WHERE id = 15;
  • 聚簇索引,查詢未命中:UPDATE students SET score = 100 WHERE id = 16;
  • 二級唯一索引,查詢命中:UPDATE students SET score = 100 WHERE no = ‘S0003’;
  • 二級唯一索引,查詢未命中:UPDATE students SET score = 100 WHERE no = ‘S0008’;
  • 二級非唯一索引,查詢命中:UPDATE students SET score = 100 WHERE name = ‘Tom’;
  • 二級非唯一索引,查詢未命中:UPDATE students SET score = 100 WHERE name = ‘John’;
  • 無索引:UPDATE students SET score = 100 WHERE score = 22;
  • 聚簇索引,範圍查詢:UPDATE students SET score = 100 WHERE id <= 20;
  • 二級索引,範圍查詢:UPDATE students SET score = 100 WHERE age <= 23;
  • 修改索引值:UPDATE students SET name = ‘John’ WHERE id = 15;

2.1 聚簇索引,查詢命中

語句 UPDATE students SET score = 100 WHERE id = 15 在 RC 和 RR 隔離級別下加鎖情況一樣,都是對 id 這個聚簇索引加 X 鎖,如下:

primary-index-locks.png

2.2 聚簇索引,查詢未命中

如果查詢未命中紀錄,在 RC 和 RR 隔離級別下加鎖是不一樣的,因爲 RR 有 GAP 鎖。語句 UPDATE students SET score = 100 WHERE id = 16 在 RC 和 RR 隔離級別下的加鎖情況如下(RC 不加鎖):

primary-index-locks-gap.png

2.3 二級唯一索引,查詢命中

語句 UPDATE students SET score = 100 WHERE no = 'S0003' 命中二級唯一索引,上一篇博客中我們介紹了索引的結構,我們知道二級索引的葉子節點中保存了主鍵索引的位置,在給二級索引加鎖的時候,主鍵索引也會一併加鎖。這個在 RC 和 RR 兩種隔離級別下沒有區別:

secondary-index-unique-locks.png

那麼,爲什麼主鍵索引上的記錄也要加鎖呢?因爲有可能其他事務會根據主鍵對 students 表進行更新,如:UPDATE students SET score = 100 WHERE id = 20,試想一下,如果主鍵索引沒有加鎖,那麼顯然會存在併發問題。

2.4 二級唯一索引,查詢未命中

如果查詢未命中紀錄,和 2.2 情況一樣,RR 隔離級別會加 GAP 鎖,RC 無鎖。語句 UPDATE students SET score = 100 WHERE no = 'S0008' 加鎖情況如下:

secondary-index-unique-locks-gap.png

這種情況下只會在二級索引加鎖,不會在聚簇索引上加鎖。

2.5 二級非唯一索引,查詢命中

如果查詢命中的是二級非唯一索引,在 RR 隔離級別下,還會加 GAP 鎖。語句 UPDATE students SET score = 100 WHERE name = 'Tom' 加鎖如下:

secondary-index-non-unique-locks.png

爲什麼非唯一索引會加 GAP 鎖,而唯一索引不用加 GAP 鎖呢?原因很簡單,GAP 鎖的作用是爲了解決幻讀,防止其他事務插入相同索引值的記錄,而唯一索引和主鍵約束都已經保證了該索引值肯定只有一條記錄,所以無需加 GAP 鎖。

這裏還有一點要注意一下,數一數右圖中的鎖你可能會覺得一共加了 7 把鎖,實際情況不是,要注意的是 (Tom, 37) 上的記錄鎖和它前面的 GAP 鎖合起來是一個 Next-key 鎖,這個鎖加在 (Tom, 37) 這個索引上,另外 (Tom, 49) 上也有一把 Next-key 鎖。那麼最右邊的 GAP 鎖加在哪呢?右邊已經沒有任何記錄了啊。其實,在 InnoDb 存儲引擎裏,每個數據頁中都會有兩個虛擬的行記錄,用來限定記錄的邊界,分別是:Infimum Record 和 Supremum Record,Infimum 是比該頁中任何記錄都要小的值,而 Supremum 比該頁中最大的記錄值還要大,這兩條記錄在創建頁的時候就有了,並且不會刪除。上面右邊的 GAP 鎖就是加在 Supremum Record 上。所以說,上面右圖中共有 2 把 Next-key 鎖,1 把 GAP 鎖,2 把記錄鎖,一共 5 把鎖。

2.6 二級非唯一索引,查詢未命中

如果查詢未命中紀錄,和 2.2、2.4 情況一樣,RR 隔離級別會加 GAP 鎖,RC 無鎖。語句 UPDATE students SET score = 100 WHERE name = 'John' 加鎖情況如下:

secondary-index-non-unique-locks-gap.png

2.7 無索引

如果 WHERE 條件不能走索引,MySQL 會如何加鎖呢?有的人說會在表上加 X 鎖,也有人說會根據 WHERE 條件將篩選出來的記錄在聚簇索引上加上 X 鎖,那麼究竟如何,我們看下圖:

no-index-locks.png

在沒有索引的時候,只能走聚簇索引,對錶中的記錄進行全表掃描。在 RC 隔離級別下會給所有記錄加行鎖,在 RR 隔離級別下,不僅會給所有記錄加行鎖,所有聚簇索引和聚簇索引之間還會加上 GAP 鎖。

語句 UPDATE students SET score = 100 WHERE score = 22 滿足條件的雖然只有 1 條記錄,但是聚簇索引上所有的記錄,都被加上了 X 鎖。那麼,爲什麼不是隻在滿足條件的記錄上加鎖呢?這是由於 MySQL 的實現決定的。如果一個條件無法通過索引快速過濾,那麼存儲引擎層面就會將所有記錄加鎖後返回,然後由 MySQL Server 層進行過濾,因此也就把所有的記錄都鎖上了。

不過在實際的實現中,MySQL 有一些改進,如果是 RC 隔離級別,在 MySQL Server 過濾條件發現不滿足後,會調用 unlock_row 方法,把不滿足條件的記錄鎖釋放掉(違背了 2PL 的約束)。這樣做可以保證最後只會持有滿足條件記錄上的鎖,但是每條記錄的加鎖操作還是不能省略的。如果是 RR 隔離級別,一般情況下 MySQL 是不能這樣優化的,除非設置了 innodb_locks_unsafe_for_binlog 參數,這時也會提前釋放鎖,並且不加 GAP 鎖,這就是所謂的 semi-consistent read,關於 semi-consistent read 可以參考 這裏

2.8 聚簇索引,範圍查詢

上面所介紹的各種情況其實都是非常常見的 SQL,它們有一個特點:全部都只有一個 WHERE 條件,並且都是等值查詢。那麼問題來了,如果不是等值查詢而是範圍查詢,加鎖情況會怎麼樣呢?有人可能會覺得這很簡單,根據上面的加鎖經驗,我們只要給查詢範圍內的所有記錄加上鎖即可,如果隔離級別是 RR,所有記錄之間再加上間隙鎖。事實究竟如何,我們看下面的圖:

primary-index-range-locks.png

SQL 語句爲 UPDATE students SET score = 100 WHERE id <= 20,按理說我們只需要將 id = 20、18、15 三條記錄鎖住即可,但是看右邊的圖,在 RR 隔離級別下,我們還把 id = 30 這條記錄以及 (20, 30] 之間的間隙也鎖起來了,很顯然這是一個 Next-key 鎖。如果 WHERE 條件是 id < 20,則會把 id = 20 這條記錄鎖住。爲什麼會這樣我也不清楚,網上搜了很久,有人說是爲了防止幻讀,但 id 是唯一主鍵,(20, 30] 之間是不可能再插入一條 id = 20 的,所以具體的原因還需要再分析下,如果你知道,還請不吝賜教。

所以對於範圍查詢,如果 WHERE 條件是 id <= N,那麼 N 後一條記錄也會被加上 Next-key 鎖;如果條件是 id < N,那麼 N 這條記錄會被加上 Next-key 鎖。另外,如果 WHERE 條件是 id >= N,只會給 N 加上記錄鎖,以及給比 N 大的記錄加鎖,不會給 N 前一條記錄加鎖;如果條件是 id > N,也不會鎖前一條記錄,連 N 這條記錄都不會鎖。

====================== 11月26號補充 =========================

我在做實驗的時候發現,在 RR 隔離級別,條件是 id >= 20,有時會對 id < 20 的記錄加鎖,有時候又不加,感覺找不到任何規律,請以實際情況爲準。我對範圍查詢的加鎖原理還不是很明白,後面有時間再仔細研究下,也歡迎有興趣的同學一起討論下。

下面是我做的一個簡單的實驗,表很簡單,只有一列主鍵 id:

mysql> show create table t1;
+-------+--------------------------------------------+
| Table | Create Table                               |
+-------+--------------------------------------------+
| t1    | CREATE TABLE `t1` (                        |
|       |    `id` int(11) NOT NULL AUTO_INCREMENT,   |
|       |    PRIMARY KEY (`id`)                      |
|       | ) ENGINE=InnoDB DEFAULT CHARSET=utf8       |
+-------+--------------------------------------------+

表裏一共三條數據:

mysql> select * from t1;
+----+
| id |
+----+
|  2 |
|  4 |
|  6 |
+----+
3 rows in set (0.00 sec)

執行 delete from t1 where id > 2 時加鎖情況是:(2, 4], (4, 6], (6, +∞)
執行 select * from t1 where id > 2 for update 時加鎖情況是:(-∞, 2], (2, 4], (4, 6], (6, +∞)
可見 select for update 和 delete 的加鎖還是有所區別的,至於 select for update 爲什麼加 (-∞, 2] 這個鎖,我還是百思不得其解。後來無意中給表 t1 加了一個字段 a int(11) NOT NULL,竟然發現 select * from t1 where id > 2 for update 就不會給 (-∞, 2] 加鎖了,真的非常奇怪。

====================== 12月3號補充 =========================

經過幾天的搜索,終於找到了一個像樣的解釋(但不好去證實):當數據表中數據非常少時,譬如上面那個的例子,select … [lock in share mode | for update] 語句會走全表掃描,這樣表中所有記錄都會被鎖住,這就是 (-∞, 2] 被鎖的原因。而 delete 語句並不會走全表掃描。

2.9 二級索引,範圍查詢

然後我們把範圍查詢應用到二級非唯一索引上來,SQL 語句爲:UPDATE students SET score = 100 WHERE age <= 23,加鎖情況如下圖所示:

secondary-index-range-locks.png

可以看出和聚簇索引的範圍查詢一樣,除了 WHERE 條件範圍內的記錄加鎖之外,後面一條記錄也會加上 Next-key 鎖,這裏有意思的一點是,儘管滿足 age = 24 的記錄有兩條,但只有第一條被加鎖,第二條沒有加鎖,並且第一條和第二條之間也沒有加鎖。

2.10 修改索引值

這種情況比較容易理解,WHERE 部分的索引加鎖原則和上面介紹的一樣,多的是 SET 部分的加鎖。譬如 UPDATE students SET name = ‘John’ WHERE id = 15 不僅在 id = 15 記錄上加鎖之外,還會在 name = ‘Bob’(原值)和 name = ‘John’(新值) 上加鎖。示意圖如下(此處理解有誤,參見下面的評論區):

update-index-locks.png

RC 和 RR 沒有區別。

三、複雜條件加鎖分析

前面的例子都是非常簡單的 SQL,只包含一個 WHERE 條件,並且是等值查詢,當 SQL 語句中包含多個條件時,對索引的分析就相當重要了。因爲我們知道行鎖最終都是加在索引上的,如果我們連執行 SQL 語句時會使用哪個索引都不知道,又怎麼去分析這個 SQL 所加的鎖呢?

MySQL 的索引是一個很複雜的話題,甚至可以寫一本書出來了。這裏就只是學習一下在對複雜 SQL 加鎖分析之前如何先對索引進行分析。譬如下面這樣的 SQL:

mysql> DELETE FROM students WHERE name = 'Tom' AND age = 22;

其中 name 和 age 兩個字段都是索引,那麼該如何加鎖?這其實取決於 MySQL 用哪個索引。可以用 EXPLAIN 命令分析 MySQL 是如何執行這條 SQL 的,通過這個命令可以知道 MySQL 會使用哪些索引以及怎麼用索引來執行 SQL 的,只有執行會用到的索引纔有可能被加鎖,沒有使用的索引是不加鎖的,這裏有一篇 EXPLAIN 的博客可以參考。也可以使用 MySQL 的 optimizer_trace 功能 來對 SQL 進行分析,它支持將執行的 SQL 的查詢計劃樹記錄下來,這個稍微有點難度,有興趣的同學可以研究下。那麼 MySQL 是如何選擇合適的索引呢?其實 MySQL 會給每一個索引一個指標,叫做索引的選擇性,這個值越高表示使用這個索引能最大程度的過濾更多的記錄,關於這個,又是另一個話題了。

當然,從兩個索引中選擇一個索引來用,這種情況的加鎖分析和我們上一節討論的情形並沒有本質的區別,只需要將那個沒有用索引的 WHERE 條件當成普通的過濾條件就好了。這裏我們會把用到的索引稱爲 Index Key,而另一個條件稱爲 Table Filter。譬如這裏如果用到的索引爲 age,那麼 age 就是 Index Key,而 name = ‘Tom’ 就是 Table Filter。Index Key 又分爲 First Key 和 Last Key,如果 Index Key 是範圍查詢的話,如下面的例子:

mysql> DELETE FROM students WHERE name = 'Tom' AND age > 22 AND age < 25;

其中 First Key 爲 age > 22,Last Key 爲 age < 25。

所以我們在加鎖分析時,只需要確定 Index Key 即可,鎖是加在 First Key 和 Last Key 之間的記錄上的,如果隔離級別爲 RR,同樣會有間隙鎖。要注意的是,當索引爲複合索引時,Index Key 可能會有多個,何登成的這篇博客《SQL中的where條件,在數據庫中提取與應用淺析》 詳細介紹瞭如何從一個複雜的 WHERE 條件中提取出 Index Key,推薦一讀。這裏 也有一篇博客介紹了 MySQL 是如何利用索引的。

當索引爲複合索引時,不僅可能有多個 Index Key,而且還可能有 Index Filter。所謂 Index Filter,就是複合索引中除 Index Key 之外的其他可用於過濾的條件。如果 MySQL 是 5.6 之前的版本,Index Filter 和 Table Filter 沒有區別,統統將 Index First Key 與 Index Last Key 範圍內的索引記錄,回表讀取完整記錄,然後返回給 MySQL Server 層進行過濾。而在 MySQL 5.6 之後,Index Filter 與 Table Filter 分離,Index Filter 下降到 InnoDB 的索引層面進行過濾,減少了回表與返回 MySQL Server 層的記錄交互開銷,提高了SQL的執行效率,這就是傳說中的 ICP(Index Condition Pushdown),使用 Index Filter 過濾不滿足條件的記錄,無需加鎖。

這裏引用何登成前輩博客中的一個例子(圖片來源):

complicated-sql-locks.png

可以看到 pubtime > 1 and pubtime < 20 爲 Index First Key 和 Index Last Key,MySQL 會在這個範圍內加上記錄鎖和間隙鎖;userid = ‘hdc’ 爲 Index Filter,這個過濾條件可以在索引層面就可以過濾掉一條記錄,因此如果數據庫支持 ICP 的話,(4, yyy, 3) 這條記錄就不會加鎖;comment is not NULL 爲 Table Filter,雖然這個條件也可以過濾一條記錄,但是它不能在索引層面過濾,而是在根據索引讀取了整條記錄之後才過濾的,因此加鎖並不能省略。

四、DELETE 語句加鎖分析

一般來說,DELETE 的加鎖和 SELECT FOR UPDATE 或 UPDATE 並沒有太大的差異,DELETE 語句一樣會有下面這些情況:

  • 聚簇索引,查詢命中:DELETE FROM students WHERE id = 15;
  • 聚簇索引,查詢未命中:DELETE FROM students WHERE id = 16;
  • 二級唯一索引,查詢命中:DELETE FROM students WHERE no = ‘S0003’;
  • 二級唯一索引,查詢未命中:DELETE FROM students WHERE no = ‘S0008’;
  • 二級非唯一索引,查詢命中:DELETE FROM students WHERE name = ‘Tom’;
  • 二級非唯一索引,查詢未命中:DELETE FROM students WHERE name = ‘John’;
  • 無索引:DELETE FROM students WHERE score = 22;
  • 聚簇索引,範圍查詢:DELETE FROM students WHERE id <= 20;
  • 二級索引,範圍查詢:DELETE FROM students WHERE age <= 23;
  • 針對這些情況的加鎖分析和上文一致,這裏不再贅述。

那麼 DELETE 語句和 UPDATE 語句的加鎖到底會有什麼不同呢?我們知道,在 MySQL 數據庫中,執行 DELETE 語句其實並沒有直接刪除記錄,而是在記錄上打上一個刪除標記,然後通過後臺的一個叫做 purge 的線程來清理。從這一點來看,DELETE 和 UPDATE 確實是非常相像。事實上,DELETE 和 UPDATE 的加鎖也幾乎是一樣的,這裏要單獨加一節來說明 DELETE 語句的加鎖分析,其實並不是因爲 DELETE 語句的加鎖和其他語句有所不同,而是因爲 DELETE 語句導致多了一種特殊類型的記錄:標記爲刪除的記錄,對於這種類型記錄,它的加鎖和其他記錄的加鎖機制不一樣。所以這一節的標題叫做 標記爲刪除的記錄的加鎖分析 可能更合適。

那麼問題又來了:什麼情況下會對已標記爲刪除的記錄加鎖呢?我總結下來會有兩種情況:阻塞後加鎖快照讀後加鎖(自己取得名字),下面分別介紹。

阻塞後加鎖 如下圖所示,事務 A 刪除 id = 18 這條記錄,同時事務 B 也刪除 id = 18 這條記錄,很顯然,id 爲主鍵,DELETE 語句需要獲取 X 記錄鎖,事務 B 阻塞。事務 A 提交之後,id = 18 這條記錄被標記爲刪除,此時事務 B 就需要對已刪除記錄進行加鎖。

delete-locks-after-block.png

快照讀後加鎖 如下圖所示,事務 A 刪除 id = 18 這條記錄,並提交。事務 B 在事務 A 提交之前有一次 id = 18 的快照讀,所以在後面刪除 id = 18 這條記錄的時候就需要對已刪除記錄加鎖了。如果沒有事務開頭的這個快照讀,DELETE 語句就只是簡單的刪除一條不存在的記錄。

delete-locks-after-snapshot-read.png

注意,上面的事務 B 不限於 DELETE 語句,換成 UPDATE 或 SELECT FOR UPDATE 同樣適用。網上對這種刪除記錄的加鎖分析並不多,我通過自己做的實驗,得到了下面這些結論,如有不正確的地方,歡迎斧正。(實驗環境,MySQL 版本:5.7,隔離級別:RR)

  • 刪除記錄爲聚簇索引

    • 阻塞後加鎖:在刪除記錄上加 X 記錄鎖(rec but not gap),並在刪除的後一條記錄上加間隙鎖(gap before rec)
    • 快照讀後加鎖:在刪除記錄上加 X 記錄鎖(rec but not gap)
  • 刪除記錄爲二級索引(唯一索引和非唯一索引都適用)

    • 阻塞後加鎖:在刪除記錄上加 Next-key 鎖,並在刪除的後一條記錄上加間隙鎖
    • 快照讀後加鎖:在刪除記錄上加 Next-key 鎖,並在刪除的後一條記錄上加間隙鎖

要注意的是,這裏的隔離級別爲 RR,如果在 RC 隔離級別下,加鎖過程應該會不一樣,感興趣的同學可以自行實驗。關於 DELETE 語句的加鎖,何登成前輩在他的博客:一個最不可思議的MySQL死鎖分析 裏面有詳細的分析,並介紹了頁面鎖的相關概念,還原了僅僅只有一條 DELETE 語句也會造成死鎖的整個過程,講的很精彩。

五、INSERT 語句加鎖分析

上面所提到的加鎖分析,都是針對 SELECT FOR UPDATE、UPDATE、DELETE 等進行的,那麼針對 INSERT 加鎖過程又是怎樣的呢?我們下面繼續探索。

還是用 students 表來實驗,譬如我們執行下面的 SQL:

mysql> insert into students(no, name, age, score) value('S0008', 'John', 26, 87);

然後我們用 show engine innodb status\G 查詢事務的鎖情況:

---TRANSACTION 3774, ACTIVE 2 sec
1 lock struct(s), heap size 1136, 0 row lock(s), undo log entries 1
MySQL thread id 150, OS thread handle 10420, query id 3125 localhost ::1 root
TABLE LOCK table `sys`.`t3` trx id 3774 lock mode IX

我們發現除了一個 IX 的 TABLE LOCK 之外,就沒有其他的鎖了,難道 INSERT 不加鎖?一般來說,加鎖都是對錶中已有的記錄進行加鎖,而 INSERT 語句是插入一條新的紀錄,這條記錄表中本來就沒有,那是不是就不需要加鎖了?顯然不是,至少有兩個原因可以說明 INSERT 加了鎖:

  1. 爲了防止幻讀,如果記錄之間加有 GAP 鎖,此時不能 INSERT;
  2. 如果 INSERT 的記錄和已有記錄造成唯一鍵衝突,此時不能 INSERT;

要解決這兩個問題,都是靠鎖來解決的(第一個加插入意向鎖,第二個加 S 鎖進行當前讀),只是在 INSERT 的時候如果沒有出現這兩種情況,那麼鎖就是隱式的,只是我們看不到而已。這裏我們不得不提一個概念叫 隱式鎖(Implicit Lock),它對我們分析 INSERT 語句的加鎖過程至關重要。

關於隱式鎖,這篇文章《MySQL數據庫InnoDB存儲引擎中的鎖機制》對其做了詳細的說明,講的非常清楚,推薦一讀。可以參考上一篇介紹的悲觀鎖和樂觀鎖。

鎖是一種悲觀的順序化機制,它假設很可能發生衝突,因此在操作數據時,就加鎖,如果衝突的可能性很小,多數的鎖都是不必要的。Innodb 實現了一個延遲加鎖的機制來減少加鎖的數量,這被稱爲隱式鎖。
隱式鎖中有個重要的元素:事務ID(trx_id)。隱式鎖的邏輯過程如下:

A. InnoDB 的每條記錄中都有一個隱含的 trx_id 字段,這個字段存在於簇索引的 B+Tree 中;
B. 在操作一條記錄前,首先根據記錄中的 trx_id 檢查該事務是否是活動的事務(未提交或回滾),如果是活動的事務,首先將隱式鎖轉換爲顯式鎖(就是爲該事務添加一個鎖);
C. 檢查是否有鎖衝突,如果有衝突,創建鎖,並設置爲 waiting 狀態;如果沒有衝突不加鎖,跳到 E;
D. 等待加鎖成功,被喚醒,或者超時;
E. 寫數據,並將自己的 trx_id 寫入 trx_id 字段。

隱式鎖的特點是隻有在可能發生衝突時才加鎖,減少了鎖的數量。另外,隱式鎖是針對被修改的 B+Tree 記錄,因此都是 Record 類型的鎖,不可能是 Gap 或 Next-Key 類型。
INSERT 操作只加隱式鎖,不需要顯示加鎖; UPDATE、DELETE 在查詢時,直接對查詢用的 Index 和主鍵使用顯示鎖,其他索引上使用隱式鎖。
理論上說,可以對主鍵使用隱式鎖的。提前使用顯示鎖應該是爲了減少死鎖的可能性。INSERT,UPDATE,DELETE 對 B+Tree 們的操作都是從主鍵的 B+Tree 開始,因此對主鍵加鎖可以有效的阻止死鎖。

INSERT 加鎖流程如下(參考):

  • 首先對插入的間隙加插入意向鎖(Insert Intension Locks)

    • 如果該間隙已被加上了 GAP 鎖或 Next-Key 鎖,則加鎖失敗進入等待;
    • 如果沒有,則加鎖成功,表示可以插入;
  • 然後判斷插入記錄是否有唯一鍵,如果有,則進行唯一性約束檢查

    • 如果不存在相同鍵值,則完成插入
    • 如果存在相同鍵值,則判斷該鍵值是否加鎖
    • 如果沒有鎖, 判斷該記錄是否被標記爲刪除
    • 如果標記爲刪除,說明事務已經提交,還沒來得及 purge,這時加 S 鎖等待;
    • 如果沒有標記刪除,則報 1062 duplicate key 錯誤;
    • 如果有鎖,說明該記錄正在處理(新增、刪除或更新),且事務還未提交,加 S 鎖等待;
  • 插入記錄並對記錄加 X 記錄鎖;

這裏的表述其實並不準確,有興趣的同學可以去閱讀 InnoDb 的源碼分析 INSERT 語句具體的加鎖過程,我在 《讀 MySQL 源碼再看 INSERT 加鎖流程》 這篇博客中有詳細的介紹。

參考

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章