服了!DELETE 同一行記錄也會造成死鎖!!
1.問題背景
“哥們,又雙叒叕寫了個死鎖,秀??!??”
圖片
就算是經(jīng)常寫死鎖的同學(xué)看到估計(jì)都會有點(diǎn)懵,兩條一模一樣的 DELETE 語句怎么會產(chǎn)生死鎖呢?
2.MySQL 鎖回顧
看到這里的靚仔肯定對 MySQL 的鎖非常了解,哥們還是帶大家對鎖的分類進(jìn)行快速回顧;
本文將基于 MySQL 5.7.21 版本進(jìn)行討論,該版本使用 InnoDB 存儲引擎,并采用 Repeated Read 作為事務(wù)隔離級別。
圖片
要查看 MySQL 的加鎖信息,必須啟用 InnoDB 狀態(tài)監(jiān)控功能;
SET GLOBAL innodb_status_output=ON;
SET GLOBAL innodb_status_output_locks=ON;
要獲取 InnoDB 存儲引擎的詳細(xì)狀態(tài)信息,可以使用以下 SQL 命令;
SHOW ENGINE INNODB STATUS;
3.DELETE 流程
在深入分析問題原因之前先對 DELETE 操作的基本流程進(jìn)行復(fù)習(xí)。眾所周知,MySQL 以頁作為數(shù)據(jù)的基本存儲單位,每個頁內(nèi)包含兩個主要的鏈表:正常記錄鏈表和垃圾鏈表。每條記錄都有一個記錄頭,記錄頭中包括一個關(guān)鍵屬性——deleted_flag。
圖片
執(zhí)行 DELETE 操作期間,系統(tǒng)首先將正常記錄的記錄頭中的 delete_flag 標(biāo)記設(shè)置為 1。這一步驟也被稱為 delete mark,是數(shù)據(jù)刪除流程的一部分。
圖片
在事務(wù)成功提交之后,由 purge 線程 負(fù)責(zé)對已標(biāo)記為刪除的數(shù)據(jù)執(zhí)行邏輯刪除操作。這一過程包括將記錄從正常記錄鏈表中移除,并將它們添加到垃圾鏈表中,以便后續(xù)的清理工作。
圖片
針對不同狀態(tài)下的記錄,MySQL 在加鎖時(shí)采取不同的策略,特別是在處理唯一索引上記錄的加鎖情況。以下是具體的加鎖規(guī)則:
- 正常記錄: 對于未被標(biāo)記為刪除的記錄,MySQL 會施加記錄鎖,以確保事務(wù)的隔離性和數(shù)據(jù)的一致性。
- delete mark: 當(dāng)記錄已被標(biāo)記為刪除(即 delete_flag 被設(shè)置為1),但尚未由 purge 線程清理時(shí),MySQL 會對這些記錄施加臨鍵鎖,以避免在清理前發(fā)生數(shù)據(jù)沖突。
- 已刪除記錄: 對于已經(jīng)被 purge 線程邏輯刪除的記錄,MySQL 會施加間隙鎖,這允許在已刪除記錄的索引位置插入新記錄,同時(shí)保持索引的完整性和順序性。
4.原因剖析
在分析死鎖的案例中,我們關(guān)注的表 t_order_extra_item_15 具有一個由 (order_id, extra_key) 組成的聯(lián)合唯一索引。為了更好地理解死鎖的產(chǎn)生機(jī)制,我們將對上述死鎖日志進(jìn)行簡化處理。
事務(wù)137060372(A) | 事務(wù)137060371(B) | |
執(zhí)行語句 | delete from t_order_extra_item_15 WHERE (order_id = xxx and extra_key = xxx) | delete from t_order_extra_item_15 WHERE (order_id = xxx and extra_key = xxx) |
持有鎖 | lock_mode X locks rec but not gap(記錄鎖) | |
等待鎖 | lock_mode X locks rec but not gap waiting(記錄鎖) | lock_mode X waiting(臨鍵鎖) |
事務(wù) A 試圖獲取記錄鎖,但被事務(wù) B 持有的相同的記錄鎖所阻塞。而且,事務(wù) B 在嘗試獲取臨鍵鎖時(shí)也遇到了阻塞,這是因?yàn)槭聞?wù) A 先前已經(jīng)請求了記錄鎖,從而形成了一種相互等待的狀態(tài),這種情況最終導(dǎo)致了死鎖的發(fā)生。
然而事務(wù) B 為何在已經(jīng)持有記錄鎖的情況下還需要等待臨鍵鎖?唯一合理的解釋是,在事務(wù) B 最初執(zhí)行 DELETE 操作時(shí),它所嘗試操作的記錄已經(jīng)被其他事務(wù)鎖定。當(dāng)這個其他事務(wù)完成了 delete mark 并提交后,事務(wù) B 不得不重新發(fā)起對臨鍵鎖的請求。
經(jīng)過深入分析得出結(jié)論,在并發(fā)環(huán)境中,必然存在另一個執(zhí)行相同 DELETE 操作的事務(wù),我們稱之為事務(wù) C。
通過仔細(xì)分析業(yè)務(wù)代碼和服務(wù)日志,我們迅速驗(yàn)證了這一假設(shè)。現(xiàn)在,導(dǎo)致死鎖的具體原因已經(jīng)非常明顯。為了幫助大家更好地理解三個事務(wù)的執(zhí)行順序,我們制定了一個事務(wù)執(zhí)行時(shí)序的設(shè)想表格。
事務(wù) A | 事務(wù) B | 事務(wù) C |
1. delete from t_order_extra_item_15 WHERE (order_id = xxx and extra_key = xxx ) ) 獲取記錄鎖成功(lock_mode X locks rec but not gap) | ||
2. delete from t_order_extra_item_15 WHERE (order_id = xxx and extra_key = xxx ) ) 等待獲取記錄鎖( lock_mode X locks rec but not gap waiting) | ||
3. delete from t_order_extra_item_15 WHERE (order_id = xxx and extra_key = xxx ) ) 等待獲取記錄鎖( lock_mode X locks rec but not gap waiting) | ||
4. delete mark 設(shè)置記錄頭刪除標(biāo)識位 delete_flag=1 | ||
5. 事務(wù)提交 | ||
6. 獲取記錄鎖成功 記錄狀態(tài)變更重新獲取臨鍵鎖(lock_mode X) | ||
7. 發(fā)現(xiàn)死鎖,回滾該事務(wù) WE ROLL BACK TRANSACTION | ||
8. 事務(wù)提交 |
在執(zhí)行流程的第 6 步中,事務(wù) B 嘗試重新獲取臨鍵鎖,這時(shí)與事務(wù) A 發(fā)生了相互等待的狀況,導(dǎo)致死鎖的發(fā)生。為解決這一問題,數(shù)據(jù)庫管理系統(tǒng)自動回滾了事務(wù) A,以打破死鎖狀態(tài)。
5.現(xiàn)場還原
哥們深知道理論分析至關(guān)重要,實(shí)踐才是檢驗(yàn)真理的唯一標(biāo)準(zhǔn)。Talk is cheap, Show me the code. 在相同的系統(tǒng)環(huán)境下,我們創(chuàng)建了一個測試表來模擬實(shí)際情況;
CREATE TABLE `t_lock` (
`id` int NOT NULL,
`uniq` int NOT NULL,
`idx` int NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uniq` (`uniq`) USING BTREE,
KEY `idx` (`idx`)
);
INSERT INTO t_lock VALUES (1, 1, 1);
INSERT INTO t_lock VALUES (5, 5, 5);
INSERT INTO t_lock VALUES (10, 10, 10);
大聰明一上來便直接手動開啟 3 個 MySQL 命令列界面,每個界面中獨(dú)立開啟事務(wù)執(zhí)行 DELETE FROM t_lock where uniq = 5; 語句,然而實(shí)驗(yàn)結(jié)果并未能成功復(fù)現(xiàn)先前討論的死鎖狀況。
經(jīng)過反復(fù) SHOW ENGINE INNODB STATUS; 檢查鎖的狀態(tài)得出結(jié)論:在 DELETE 操作中,加鎖和 delete mark 是連續(xù)的不可分割的步驟,不受人為干預(yù)。一旦一個事務(wù)開始執(zhí)行 DELETE,其他事務(wù)對該記錄的訪問請求將自動轉(zhuǎn)為臨鍵鎖,避免了死鎖的發(fā)生。
為了更準(zhǔn)確地模擬并發(fā)環(huán)境下 DELETE 操作可能導(dǎo)致的死鎖,這里采用 Java 語言編寫了一個示例程序;
public class Main {
private static final String URL = "jdbc:mysql://localhost:3306/db_test";
private static final String USER = "root";
private static final String PASSWORD = "123456";
private static final String SQL = "DELETE FROM t_lock WHERE uniq = 5;";
public static void main(String[] args) {
// 開啟 3 個線程,模擬并發(fā)刪除
for (int i = 0; i < 3; i++) {
new Thread(Main::executeSQL).start();
}
}
public static void executeSQL() {
try (
Connection connection = DriverManager.getConnection(URL, USER, PASSWORD);
Statement statement = connection.createStatement()
) {
System.out.println(LocalTime.now() + ":" + Thread.currentThread().getName());
// 關(guān)閉自動提交
connection.setAutoCommit(false);
int rows = statement.executeUpdate(SQL);
// 延時(shí) 5 秒便于觀察加鎖信息
Thread.sleep(5000);
connection.commit();
System.out.println(LocalTime.now() + ":" + Thread.currentThread().getName() + ":" + rows);
} catch (Exception e) {
// 死鎖堆棧輸出
e.printStackTrace();
}
}
}
果不其然,程序執(zhí)行異常,異常堆棧中清晰地記錄了死鎖信息。進(jìn)一步檢查 MySQL 服務(wù)端的死鎖日志,與線上業(yè)務(wù)的死鎖日志如出一轍。程序執(zhí)行過程中三個并發(fā)事務(wù)的加鎖信息,和文章第四段的原因分析完全一致。這證實(shí)了我們的現(xiàn)場模擬成功復(fù)現(xiàn)了死鎖情況。
6 問題思考
6.1 可以通過 SELECT FOR UPDATE 避免嗎
不行。SELECT FOR UPDATE 的加鎖邏輯與 DELETE 語句的加鎖邏輯是一致的。加鎖的類型完全取決于被加鎖記錄的狀態(tài)。由于這一機(jī)制,使用 SELECT FOR UPDATE 并不能解決由 DELETE 操作引起的死鎖問題。
6.2 只有唯一索引會有這個問題嗎
的確,只有唯一索引會引發(fā)此類死鎖問題,主鍵索引和普通索引均不會。在上述的系統(tǒng)環(huán)境下的實(shí)驗(yàn)結(jié)果表明,不同索引類型在索引等值加 X 鎖情況下的行為如下:
主鍵索引 | 唯一索引 | 普通索引 | |
正常記錄 | 記錄鎖 | 記錄鎖 | 臨鍵鎖 |
delete mark | 記錄鎖 | 臨鍵鎖 | 臨鍵鎖 |
已刪除記錄 | 間隙鎖 | 間隙鎖 | 間隙鎖 |
唯一索引在處理"正常記錄"時(shí)施加的是記錄鎖,但在處理處于"delete mark"狀態(tài)的記錄時(shí),它施加的是臨鍵鎖。這種加鎖類型的不一致性,在執(zhí)行并發(fā)的 DELETE 操作時(shí),增加了導(dǎo)致死鎖的風(fēng)險(xiǎn)。
6.3 持有記錄鎖后再請求臨鍵鎖為什么需要等待
因?yàn)樵谕恍杏涗浬线^去已經(jīng)有事務(wù)在等待獲取鎖了,為了避免鎖饑餓現(xiàn)象的發(fā)生,先前請求加鎖的事務(wù)在鎖釋放后將獲得優(yōu)先權(quán)??谡f無憑,大聰明直接開啟 2 個 MySQL 命令列界面,分別執(zhí)行 DELETE FROM t_lock where uniq = 5; 語句,實(shí)際操作結(jié)果如下;
事務(wù) A | 事務(wù) B |
1. delete from t_lock WHERE uniq = 5; 獲取記錄鎖成功(lock_mode X locks rec but not gap) | |
2. delete mark 設(shè)置記錄頭刪除標(biāo)識位 delete_flag=1 | |
3. delete from t_lock WHERE uniq = 5; 等待獲取臨鍵鎖( lock_mode X waiting) | |
4. delete from t_lock WHERE uniq = 5; 獲取臨鍵鎖成功(lock_mode X) | |
5. 發(fā)現(xiàn)死鎖,回滾該事務(wù) WE ROLL BACK TRANSACTION | |
6. 事務(wù)提交 |
在操作流程的第四步中,事務(wù) A 嘗試請求對 uniq = 5 的臨鍵鎖,發(fā)現(xiàn)事務(wù) B 已經(jīng)先行一步請求了同一行記錄上的臨鍵鎖。然而,事務(wù) B 的這一請求由于事務(wù) A 持有的記錄鎖而被阻塞,從而相互等待造成了死鎖現(xiàn)象。
6.4 高版本的 MySQL 會存在 DELETE 死鎖嗎
在 MySQL 環(huán)境 8.x 版本環(huán)境中,DELETE 操作引發(fā)的死鎖情況得到了改進(jìn)。通過觀察加鎖日志發(fā)現(xiàn),事務(wù)在對于 delete mark 的記錄加鎖時(shí),如果已經(jīng)持有了該記錄的記錄鎖,他將獲取間隙鎖而不是臨鍵鎖,這一變化有效避免了死鎖的發(fā)生。
具體的加鎖信息在此略去,大伙們?nèi)舾信d趣可以親自進(jìn)行驗(yàn)證。??
7 事后總結(jié)
問題的來龍去脈都已梳理清晰,解決方案可歸納為以下幾種:
- 升級 MySQL 版本: ?? 升級到最新版本可能會帶來人力成本和系統(tǒng)風(fēng)險(xiǎn);
- 更改隔離級別 RC: ?? 可以解決死鎖問題,但會引入臟讀和幻讀現(xiàn)象;
- 放任不管: ?? 不影響數(shù)據(jù)一致性,會導(dǎo)致服務(wù)和數(shù)據(jù)庫出現(xiàn)異常;
- 引入分布式鎖: ?? 開發(fā)成本相對較小,且影響范圍可控,已被采納;
平日朗誦八股文時(shí)如濤濤江水連綿不絕,可實(shí)際業(yè)務(wù)場景總會遇到各種奇葩的問題。因此,我們應(yīng)該始終對技術(shù)保持一顆敬畏之心,追求不斷學(xué)習(xí)和成長。
作者:曹建濤,轉(zhuǎn)轉(zhuǎn)C2C&寄賣業(yè)務(wù)研發(fā)工程師
參考資料
[1]InnoDB Locking: https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html
[2]An InnoDB Deadlock Example: https://dev.mysql.com/doc/refman/5.7/en/innodb-deadlock-example.html