一次 MySQL 線上死鎖分析實戰
阿新 • • 發佈:2021-02-24
> 關鍵詞:MySQL Index Merge
## 前言
MySQL 的鎖機制相信大家在學習 MySQL 的時候都有簡單的瞭解過,那既然有鎖就必定繞不開死鎖這個問題。其實 MySQL 在大部分場景下是不會存在死鎖問題的(比如併發量不高,SQL 寫得不至於太拉胯的情況),但是在高併發的業務場景下,一不注意就會產生死鎖,而這個死鎖分析起來也比較麻煩。
前段時間在公司實習的時候就遇到了一個比較**奇怪的死鎖**,之前一直沒來得及好好整理,最近有空復現了一下,算是積累一點經驗。
## 業務場景
簡單說一下業務背景,公司做的是電商直播,我負責的是主播端相關的業務。而這個死鎖就出現在主播後臺對商品資訊進行更新的時候。
我們的一個商品會有兩個關聯的 ID,通過其中任何一個 ID 都無法確定唯一一件商品(也就是說這個 ID 和商品是**一對多**的關係),只能同時查詢兩個 ID,才能確定一件商品。所以在更新商品資訊的時候,需要在 where 條件中同時指定兩個 ID,下面是死鎖 SQL 的結構(已脫敏):
```sql
UPDATE test_table SET `name`="zhangsan" WHERE class_id = 10 AND teacher_id = 8;
```
**這個 SQL 非常簡單,根據兩個等值條件,對一個欄位進行更新。**
不知道你看到這個 SQL 會不會懵逼,按常理來說,應該是一個事務裡有多條 SQL 才會有可能出現死鎖,這一條 SQL 怎麼可能出現死鎖呢?
是的,我當時也有這樣的疑惑,甚至懷疑是不是報警系統瞎報(最後證明不是…),當時是真的摸不著頭腦。並且因為資料庫許可權的原因,想看死鎖日誌都看不到,又是臨近下班的時候,找 DBA 能麻煩死,所以就直接搜尋引擎走起了……(關鍵詞:update 死鎖 單條 sql),最後查出來是由於 MySQL 的索引合併優化導致的,即 Index Merge,下面會進行詳細講解並復現一下死鎖場景。
## 索引合併
Index Merge 是 MySQL 在 5.0 的時候引入的一項優化功能,主要是用於優化一條 SQL 使用多個索引的情況。
我們來看剛剛的 SQL,假設 `class_id` 和 `teacher_id` 分別是兩個普通索引:
```sql
UPDATE test_table SET `name`="zhangsan" WHERE class_id = 10 AND teacher_id = 8;
```
**如果沒有 Index Merge 優化的時候**,MySQL 查詢資料的步驟如下:
- 根據 class_id 或 teacher_id (具體使用哪個索引由優化器根據實際資料情況自行判斷,這裡假設使用 `class_id`的索引)在二級索引上查詢到對應資料的主鍵 ID
- 根據查詢到的主鍵 ID 進行回標查詢(即查詢聚簇索引),得到相應的資料行
- 從資料行中獲取 `teacher_id` ,判斷其是否等於 8,滿足條件則返回
從這個過程中,不難看出,**MySQL 只使用到了一個索引**,至於為什麼不使用多個索引,簡單來說就是因為多個索引在多棵樹上,強行使用反而降低效能。
**再來看看引入了 Index Merge 優化後**,MySQL 查詢資料的步驟如下:
- 根據 `class_id` 查詢到相應的主鍵,再根據主鍵回表查詢到對應的資料行(記為結果集 A)
- 根據 `teacher_id` 查詢到相應的主鍵,再根據主鍵回表查詢到對應的資料行(記為結果集 B)
- 將結果集 A 和結果集 B **執行交集**操作,獲得最終滿足條件的結果集
這裡可以看出,有了 Index Merge 之後,MySQL 將一條 SQL 語句拆分成了兩個查詢步驟,**分別使用兩個索引,再用交集操作優化效能**。
## 死鎖分析
分析完了 Index Merge 的步驟,我們再回過頭想一下為什麼會出現死鎖呢?
還記得上面說的 Index Merge 將一條 SQL 查詢拆分成了兩個步驟嗎,問題就出現在這裡。我們知道 `UPDATE` 語句是會加上一個**行級排他鎖**的,在分析加鎖步驟之前,我們假設有如下一個資料表:
![](https://cdn.juzibiji.top/img/20210218214348.png)
上表資料滿足我們文章開頭說的特點,根據 `class_id` 和 `teacher_id` 單個欄位均無法唯一確定一條資料,只能聯合兩個欄位,才能確定一條資料,並且設定 `class_id` 和 `teacher_id` 分別為兩個普通索引。
假設有如下兩條 SQL 語句併發執行,它們的引數完全不同,直覺告訴我們應該不會出現死鎖,但直覺往往是錯誤的:
```sql
// 執行緒 A 執行
UPDATE test_table SET `name`="zhangsan" WHERE class_id = 2 AND teacher_id = 1;
// 執行緒 B 執行
UPDATE test_table SET `name`="zhangsan" WHERE class_id = 1 AND teacher_id = 2;
```
那麼**在 Index Merge 的優化下**,併發執行如上 SQL 的時候,MySQL 的加鎖步驟如下:
![](https://cdn.juzibiji.top/img/20210218215709.png)
**最終,兩個事務互相等待,形成死鎖**
## 解決方案
因為這個死鎖本質上還是由於 Index Merge 這個優化導致的,所以要解決這個場景的死鎖問題,本質上只要讓 MySQL 不走 Index Merge 優化即可。
**方案一**
手動將一條 SQL 拆分成多條 SQL,在邏輯層做交集操作,阻止 MySQL 的~~憨憨~~優化行為,比如這裡我們可以先根據 `class_id` 查詢到相應主鍵,再根據 `teacher_id` 查詢相應主鍵,最後根據交集後的主鍵查詢資料。
**方案二**
建立聯合索引,比如這裡可以將 `class_id` 和 `teacher_id` 建立一個聯合索引,MySQL 就不會走 Index Merge 了
**方案三**
強制走單個索引,在表名後新增 `for index(class_id)` 可以指定該語句僅走 class_id 索引
**方案四**
關閉 Index Merge 優化:
- 永久關閉:`SET [GLOBAL|SESSION] optimizer_switch='index_merge=off';`
- 臨時關閉:`UPDATE /*+ NO_INDEX_MERGE(test_table) */ test_table SET `name`="zhangsan" WHERE class_id = 10 AND teacher_id = 8;`
## 場景復現
### 資料準備
為了方便測試,這裡提供一個 SQL 指令碼,將其用 Navicat 匯入後即可得到需要的測試資料:
下載地址:[https://cdn.juzibiji.top/file/index_merge_student.sql](https://cdn.juzibiji.top/file/index_merge_student.sql)
匯入之後,我們會得到如下格式的 10000 條測試資料:
![](https://cdn.juzibiji.top/img/20210221213715.png)
### 測試程式碼
由於篇幅限制,這裡僅給出程式碼 Gist 連結:[https://gist.github.com/juzi214032/17c0f7a51bd8d1c0ab39fa203f930c60](https://gist.github.com/juzi214032/17c0f7a51bd8d1c0ab39fa203f930c60)
![](https://cdn.juzibiji.top/img/20210223212530.png)
上述程式碼主要是開啟 100 個執行緒執行我們的資料修改 SQL 語句,來模擬線上併發情況,在執行幾秒鐘後,我們會得到下面這樣一個報錯:
```
com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction
```
這代表已經產生了死鎖異常
### 死鎖分析
上面我們用程式碼已經構造出了一個死鎖,接下來我們進入 MySQL 看看死鎖日誌,在 MySQL 中執行如下命令即可檢視死鎖日誌:
```sql
SHOW ENGINE INNODB STATUS;
```
![](https://cdn.juzibiji.top/img/20210223213200.png)
在日誌中,我們找到 `LATEST DETECTED DEADLOCK` 這一行,這裡開始便是我們上次產生的死鎖,接下來我們開始分析。
通過第 29 行可以看到,事務 1 執行的 SQL 的條件是 `class_id = 6` 和 `teacher_id = 16 `,它目前持有了一個行鎖,第 34~39 行是該行資料,34 行是主鍵的十六進位制表示,我們轉換為 10 進位制即為 **1616**。同樣的,看 45 行,其等待拿鎖的是主鍵 id 1517 的資料。
![](https://cdn.juzibiji.top/img/20210223215020.png)
接下來用同樣的方法分析事務 2,可知事務 2 持有了 3 把鎖,分別是主鍵 id 為**1317、1417、1517** 的資料行,等待的是 **1616** 。
看到這裡我們就已經發現了,事務 1 持有 1616 等待 1517,事務 2 持有1517 等待 1616,所以形成了一個死鎖。此時 MySQL 的處理方法是回滾持有鎖最少的事務,並且 JDBC 會丟擲我們前面的 MySQLTransactionRollbackException 回滾異常。
## 總結
這個死鎖在排查的時候其實非常不好排查,如果你不知道 MySQL 的 Index Merge,那麼在排查的時候其實是毫無頭緒的,因為呈現在你面前的就只有一條非常簡單的 SQL,就算看死鎖日誌,也是一樣的不明所以。
所以處理這類問題,更多的還是考驗你的知識儲備量和經驗,只要遇到過一次,後面在寫 SQL 的時候多加註意就