跳轉到

Deadlock

死鎖的發生

本案例使用存儲引擎 Innodb,隔離級別為可重複讀 (RR)。

建一張訂單表,其中 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 表裡加入 6 條記錄

假設這時有兩個事務,一個事務要插入訂單 1007 ,另外一個事務要插入訂單 1008,因為需要對訂單做冪等性校驗,所以兩個事務先要查詢該訂單是否存在,不存在才插入記錄,過程如下:

事務 A 事務 B
begin; begin;
//檢查 1007 訂單是否存在
select id from t_order where order_no = 1007 for update;
//檢查 1008 訂單是否存在
select id from t_order where order_no = 1008 for update;
//如果沒有, 則插入訂單記錄
insert into t_order (order_no, create_date) values (1007, now());
阻塞...
//如果沒有, 則插入訂單記錄
insert into t_order (order_no, create_date) values (1008, now());
阻塞...

可以看到,兩個事務都陷入了等待狀態(前提是沒有打開死鎖檢測),也就是發生了死鎖,因為都在相互等待對方釋放鎖。

這裡在查詢記錄是否存在的時候,使用了 select ... for update 語句,目的是為了防止事務執行的過程中,有其他事務插入了記錄,而出現幻讀的問題。

如果沒有使用 select ... for update 語句,而使用了單純的 select 語句,在兩個訂單號一樣的請求同時進來的情況下,就會出現兩個重複的訂單,有可能出現幻讀,如下圖:

事務 A 事務 B
begin; begin;
//檢查 1007 訂單是否存在
select id from t_order where order_no = 1007;
//檢查 1008 訂單是否存在
select id from t_order where order_no = 1008;
//如果沒有, 則插入訂單記錄
insert into t_order (order_no, create_date) values (1007, now());
//如果沒有, 則插入訂單記錄
insert into t_order (order_no, create_date) values (1008, now());
commit; commit;

如何避免死鎖?

死鎖的四個必要條件:互斥、佔有且等待、不可強佔用、循環等待。只要系統發生死鎖,這些條件必然成立,但是只要破壞任意一個條件就死鎖就不會成立。

在資料庫層面,有兩種策略通過「打破循環等待條件」來解除死鎖狀態:

  • 設置事務等待鎖的超時時間。當一個事務的等待時間超過該值後,就對這個事務進行回滾,於是鎖就釋放了,另一個事務就可以繼續執行了。在 InnoDB 中,參數 innodb_lock_wait_timeout 是用來設置超時時間的,默認值為 50 秒。

  • 開啟主動死鎖檢測。主動死鎖檢測在發現死鎖後,主動回滾死鎖鏈條中的某一個事務,讓其他事務得以繼續執行。將參數 innodb_deadlock_detect 設置為 on,表示開啟這個邏輯,默認就是開啟。

上面這個兩種策略是「當有死鎖發生時」的處理方式。

我們可以回歸業務的角度來預防死鎖,對訂單做冪等性校驗的目的是為了保證不會出現重複的訂單。

我們可以直接將 order_no 字段設置為唯一索引列,利用它的唯一性來保證訂單表不會出現重複的訂單,不過有一點不好的地方就是在我們插入一個已經存在的訂單記錄時就會拋出異常。

加了什麼鎖,導致死鎖的?

準備工作

創建一張 student 表,假設除了 id 字段,其他字段都是普通字段。

CREATE TABLE `t_student` (
  `id` int NOT NULL,
  `no` varchar(255) DEFAULT NULL,
  `name` varchar(255) DEFAULT NULL,
  `age` int DEFAULT NULL,
  `score` int DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

插入相關的資料後,student 表中的記錄如下:

id name age score
15 Bob 25 34
20 Alex 24 77
30 Tom 20 60
37 John 23 40

開始實驗

實驗環境:

  • MySQL 版本:8.0.26
  • 隔離級別:可重複讀(RR)
  • 無開啟死鎖檢測

啟動兩個事務,過程如下表格:

Time 事務 A 事務 B
begin; begin;
1 update student set score = 100 where id = 25;
2 update student set score = 100 where id = 26;
3 insert into student (id, name, age, score) values (25, 'Sony', 28, 90);
阻塞等待...
4 insert into student (id, name, age, score) values (26, 'Joe', 20, 70);
阻塞等待...

可以看到,事務 A 和事務 B 在執行 insert 語句後,都陷入了等待狀態,也就是發生了死鎖,因為都在相互等待對方釋放鎖。

為什麼會發生死鎖?

我們可以通過 select * from performance_schema.data_locksG; 這條語句,查看事務執行 SQL 過程中加了什麼鎖。

接下來,針對每一條 SQL 語句分析具體加了什麼鎖。

Time 1 階段加鎖分析

Time 1 階段,事務 A 執行以下語句:

# 事務 A
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t_student set score = 100 where id = 25;
Query OK, 0 rows affected (0.01 sec)
Rows matched: 0  Changed: 0  Warnings: 0

然後執行 select * from performance_schema.data_locksG; 這條語句,查看事務 A 此時加了什麼鎖。

img

從上圖可以看到,共加了兩個鎖,分別是:

  • 表鎖:X 類型的意向鎖
  • 行鎖:X 類型的間隙鎖

這裡我們重點關注行鎖,圖中 LOCK_TYPE 中的 RECORD 表示行級鎖,而不是記錄鎖,通過 LOCK_MODE 可以確認是 next-key 鎖,間隙鎖,還是記錄鎖:

  • 如果 LOCK_MODE 為 X,說明是 next-key 鎖
  • 如果 LOCK_MODE 為 X, REC_NOT_GAP,說明是記錄鎖
  • 如果 LOCK_MODE 為 X, GAP,說明是間隙鎖

因此,此時事務 A 在主鍵索引(INDEX_NAME : PRIMARY)上加的是間隙鎖,鎖範圍是 (20, 30)

Question

間隙鎖的範圍 (20, 30),是怎麼確定的?

如果 LOCK_MODE 是 next-key 鎖或者間隙鎖,那麼 LOCK_DATA 就表示鎖的範圍最右值,此次的事務 A 的 LOCK_DATA 是 30。

然後鎖範圍的最左值是 student 表中 id 為 30 的上一條記錄的 id 值,即 20。

Time 2 階段加鎖分析

Time 2 階段,事務 B 執行以下語句:

# 事務 B
mysql> begin;
Query OK, 0 rows affected (0.00 sec)

mysql> update t_student set score = 100 where id = 26;
Query OK, 0 rows affected (0.01 sec)
Rows matched: 0  Changed: 0  Warnings: 0

然後執行 select * from performance_schema.data_locksG; 這條語句,查看事務 B 此時加了什麼鎖。

在這裡插入圖片描述

從上圖可以看到,行鎖是 X 類型的間隙鎖,間隙鎖的範圍是 (20, 30)

Time 3 階段加鎖分析

Time 3,事務 A 插入了一條記錄:

# Time 3 階段,事務 A 插入了一條記錄
mysql> insert into t_student(id, no, name, age,score) value (25, 'S0025', 'sony', 28, 90);
    /// 阻塞等待......

此時,事務 A 就陷入了等待狀態。

然後執行 select * from performance_schema.data_locksG; 這條語句,查看事務 A 在獲取什麼鎖而導致被阻塞。

img

可以看到,事務 A 的狀態為等待狀態(LOCK_STATUS: WAITING),因為事務 B 生成的間隙鎖(範圍(20, 30))和 A 要取得的意向鎖(LOCK_MODE:INSERT_INTENTION)是衝突的,所以事務 A 在獲取插入意向鎖時就陷入了等待狀態。

Time 4 階段加鎖分析

Time 4,事務 B 插入了一條記錄:

# Time 4 階段,事務 B 插入了一條記錄
mysql> insert into t_student(id, no, name, age,score) value (26, 'S0026', 'ace', 28, 90);
    /// 阻塞等待......

此時,事務 B 就陷入了等待狀態。

然後執行 select * from performance_schema.data_locksG; 這條語句,查看事務 B 在獲取什麼鎖而導致被阻塞。

img

可以看到,事務 B 在生成插入意向鎖時而導致被阻塞,這是因為事務 B 要向事務 A 生成的間隙鎖(範圍(20, 30))插入了一條記錄,而插入意向鎖和間隙鎖是衝突的,所以事務 B 在獲取插入意向鎖時就陷入了等待狀態。

本次案例中,事務 A 和事務 B 在執行完 update 語句後都持有範圍為 (20, 30) 的間隙鎖,而接下來的插入操作為了獲取到插入意向鎖,都在等待對方事務的間隙鎖釋放,於是就造成了循環等待,滿足了死鎖的四個條件:互斥、佔有且等待、不可強佔用、循環等待,因此發生了死鎖。

總結

兩個事務即使生成的間隙鎖的範圍是一樣的,也不會發生衝突,因為間隙鎖目的是為了防止其他事務插入資料,因此間隙鎖與間隙鎖之間是相互兼容的。

在執行插入語句時,如果插入的記錄在其他事務持有間隙鎖範圍內,插入語句就會被阻塞,因為插入語句在碰到間隙鎖時,會生成一個插入意向鎖,然後插入意向鎖和間隙鎖之間是互斥的關係。

如果兩個事務分別向對方持有的間隙鎖範圍內插入一條記錄,而插入操作為了獲取到插入意向鎖,都在等待對方事務的間隙鎖釋放,於是就造成了循環等待,滿足了死鎖的四個條件:互斥、佔有且等待、不可強佔用、循環等待,因此發生了死鎖。

Reference