答讀者問:唯一索引沖突,為什么主鍵的 Supremum 記錄會加 Next-Key 鎖?
本文緣起于一位讀者的提問:插入一條記錄,導(dǎo)致唯一索引沖突,為什么會對主鍵的 supremum 記錄加 next-key 排他鎖?
我在 MySQL 8.0.32 復(fù)現(xiàn)了問題,并調(diào)試了加鎖流程,寫下來和大家分享。
了解完整的加鎖流程,有助于我們更深入的理解 InnoDB 的記錄鎖,希望大家有收獲。
本文基于 MySQL 8.0.32 源碼,存儲引擎為 InnoDB。
1、準備工作
創(chuàng)建測試表:
CREATE TABLE `t6` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`i1` int unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_i1` (`i1`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
插入測試數(shù)據(jù):
INSERT INTO `t6`(i1) VALUES
(1001), (1002), (1003),
(1004), (1005), (1006);
設(shè)置事務(wù)隔離級別:
在 my.cnf 中,把系統(tǒng)變量 transaction_isolation 設(shè)置為 REPEATABLE-READ。
2、問題復(fù)現(xiàn)
插入一條會導(dǎo)致唯一索引沖突的記錄:
BEGIN;
INSERT INTO `t6`(i1) VALUES(1001);
通過 BEGIN 顯式開啟事務(wù),INSERT 執(zhí)行完成之后,我們可以通過以下 SQL 查看加鎖情況:
SELECT
OBJECT_NAME, INDEX_NAME, LOCK_TYPE,
LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM `performance_schema`.`data_locks`;
結(jié)果如下:
唯一索引(uniq_i1):id = 1,i1 = 1001 的記錄,加 next-key 共享鎖。
主鍵索引(PRIMARY):supremum 記錄,加 next-key 排他鎖。
3、前置知識點:隱式鎖
插入記錄時,隱式鎖是個比較重要的概念,它存在的目的是:減少插入記錄時不必要的加鎖,提升 MySQL 的并發(fā)能力。
我們先來看一下隱式鎖的定義:
事務(wù) T 要插入一條記錄 R,只要即將插入記錄的目標位置沒有被其它事務(wù)上鎖,事務(wù) T 就不需要申請對目標位置加鎖,可以直接插入記錄。
事務(wù) T 提交之前,如果其它事務(wù)出現(xiàn)以下 2 種情況,都必須幫助事務(wù) T 給記錄 R 加上排他鎖:
- 其它事務(wù)執(zhí)行 UPDATE、DELETE 語句時掃描到了記錄 R。
- 其它事務(wù)插入的記錄和 R 存在主鍵或唯一索引沖突。
未提交事務(wù) T 插入的記錄上,這種隱性的、由其它事務(wù)在需要時幫忙創(chuàng)建的鎖,就是隱式鎖。
隱式鎖,就像神話電視劇里的結(jié)界。沒有觸碰到它時,看不見,就像不存在一樣,一旦觸碰到,它就顯現(xiàn)出來了。
隱式鎖可能出現(xiàn)于多種場景,我們來看看主鍵索引的 2 種隱式鎖場景:
前提條件:
事務(wù) T1 插入一條記錄 R1,即將插入 R1 的目標位置沒有被其它事務(wù)上鎖,事務(wù) T1 可以直接插入 R1。
場景 1:
事務(wù) T1 插入 R1 之后,提交事務(wù)之前,事務(wù) T2 試圖插入一條記錄 R2(主鍵字段值和 R1 相同)。
事務(wù) T2 給 R2 尋找插入位置的過程中,就會發(fā)現(xiàn) R2 和 R1 沖突,并且插入 R1 的事務(wù) T1 還沒有提交,這就觸發(fā)了 R1 的隱式鎖邏輯。
事務(wù) T2 會幫助 T1 給 R1 加上排他鎖,然后,它自己會申請對 R1 加共享鎖,并等待事務(wù) T1 釋放 R1 上的排他鎖。
事務(wù) T1 釋放 R1 的鎖之后,如果事務(wù) T2 沒有鎖等待超時,它獲取到 R1 上的鎖之后,就可以繼續(xù)進行主鍵沖突的后續(xù)處理邏輯了。
場景 2:
事務(wù) T1 插入 R1 之后,提交事務(wù)之前,事務(wù) T3 執(zhí)行 UPDATE 或 DELETE 語句時掃描到了 R1,發(fā)現(xiàn)插入 R1 的事務(wù) T1 還沒有提交,同樣觸發(fā)了 R1 的隱式鎖邏輯。
事務(wù) T3 會幫助 T1 給 R1 加上排他鎖,然后,它自己會申請對 R1 加排他鎖,并等待事務(wù) T1 釋放 R1 上的排他鎖。
事務(wù) T1 提交并釋放 R1 的鎖之后,如果事務(wù) T3 沒有鎖等待超時,它獲取到 R1 上的鎖之后,就可以繼續(xù)對 R1 進行修改或刪除操作了。
對隱式鎖有了大概了解之后,接下來,我們回到本文主題,來看看 INSERT 執(zhí)行過程中的加鎖流程。
4、流程分析
我們先來看一下主要堆棧,接下來的流程分析圍繞這個堆棧進行:
| > row_insert_for_mysql_using_ins_graph() storage/innobase/row/row0mysql.cc:1585
| + > row_ins_step(que_thr_t*) storage/innobase/row/row0ins.cc:3677
| + - > row_ins(ins_node_t*, que_thr_t*) storage/innobase/row/row0ins.cc:3559
| + - x > row_ins_index_entry_step(ins_node_t*, que_thr_t*) storage/innobase/row/row0ins.cc:3435
| + - x = > row_ins_index_entry() storage/innobase/row/row0ins.cc:3303
| + - x = | > row_ins_sec_index_entry() storage/innobase/row/row0ins.cc:3203
| + - x = | + > row_ins_sec_index_entry_low() storage/innobase/row/row0ins.cc:2926
| + - x = | + - > row_ins_scan_sec_index_for_duplicate() storage/innobase/row/row0ins.cc:1894
| + > row_mysql_handle_errors() storage/innobase/row/row0mysql.cc:701
這個堆棧的關(guān)鍵步驟有 2 個:
- row_ins_step(),插入記錄到主鍵、唯一索引。
- row_mysql_handle_errors(),插入失敗之后,進行錯誤處理。
(1)插入記錄到主鍵、唯一索引
// storage/innobase/row/row0mysql.cc
static dberr_t row_insert_for_mysql_using_ins_graph(...) {
...
// 主要構(gòu)造用于執(zhí)行插入操作的 2 個對象:
// 1. ins_node_t 對象,保存在 prebuilt->ins_node 中
// 2. que_fork_t 對象,保存在 prebuilt->ins_graph 中
row_get_prebuilt_insert_row(prebuilt);
node = prebuilt->ins_node;
// 把 server 層的記錄格式轉(zhuǎn)換為 InnoDB 的記錄格式
row_mysql_convert_row_to_innobase(node->row, prebuilt, mysql_rec, &temp_heap);
...
// 執(zhí)行插入操作
row_ins_step(thr);
...
if (err != DB_SUCCESS) {
error_exit:
que_thr_stop_for_mysql(thr);
...
// 錯誤處理
auto was_lock_wait = row_mysql_handle_errors(&err, trx, thr, &savept);
...
return (err);
}
...
}
這個方法的主要邏輯:
- 調(diào)用 row_get_prebuilt_insert_row(),構(gòu)造包含插入數(shù)據(jù)的 ins_node_t 對象、查詢執(zhí)行圖 que_fork_t 對象,分別保存到 prebuilt 的 ins_node、ins_graph 屬性中。
- 把 server 層的記錄格式轉(zhuǎn)換為 InnoDB 的記錄格式。
- 調(diào)用 row_ins_step(),插入記錄到主鍵索引、二級索引(包含唯一索引、非唯一索引)。
// storage/innobase/row/row0ins.cc
que_thr_t *row_ins_step(que_thr_t *thr)
{
...
// 重置 node->trx_id_buf 指針指向的內(nèi)存區(qū)域
memset(node->trx_id_buf, 0, DATA_TRX_ID_LEN);
// 把當前事務(wù) ID 拷貝到 node->trx_id_buf 指針指向的內(nèi)存區(qū)域
trx_write_trx_id(node->trx_id_buf, trx->id);
if (node->state == INS_NODE_SET_IX_LOCK) {
...
// 給表加上意向鎖
err = lock_table(0, node->table, LOCK_IX, thr);
...
}
...
err = row_ins(node, thr);
...
return (thr);
}
row_ins_step() 調(diào)用 row_ins() 插入記錄到主鍵索引、二級索引。
// storage/innobase/row/row0ins.cc
[[nodiscard]] static dberr_t row_ins(...)
{
...
// 迭代表中的索引,插入記錄到索引中
while (node->index != nullptr) {
// 只要不是全文索引
if (node->index->type != DICT_FTS) {
// 調(diào)用 row_ins_index_entry_step()
// 插入記錄到當前迭代的索引中
err = row_ins_index_entry_step(node, thr);
switch (err) {
// 執(zhí)行成功,跳出 switch
// 會接著進行下一輪迭代
case DB_SUCCESS:
break;
// 存在主鍵索引或唯一索引沖突
case DB_DUPLICATE_KEY:
thr_get_trx(thr)->error_state = DB_DUPLICATE_KEY;
thr_get_trx(thr)->error_index = node->index;
// 貫穿到 default 分支
[[fallthrough]];
default:
// 返回錯誤碼 DB_DUPLICATE_KEY
return err;
}
}
// 插入記錄到主鍵索引或二級索引成功
// node->index、entry 指向表中的下一個索引
node->index = node->index->next();
node->entry = UT_LIST_GET_NEXT(tuple_list, node->entry);
...
}
...
}
row_ins() 的主要邏輯是個 while 循環(huán),逐個迭代表中的索引,每迭代一個索引,都把構(gòu)造好的記錄插入到索引中。迭代完全部索引之后,插入一條記錄到表中的操作就完成了。
接下來,我們通過示例 SQL 來看看 row_ins() 的具體執(zhí)行流程。
-- 為了方便,這里再展示下測試表和示例 SQL
CREATE TABLE `t6` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`i1` int unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_i1` (`i1`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;
INSERT INTO `t6`(i1) VALUES(1001);
測試表 t6 有兩個索引:主鍵索引、uniq_i1(唯一索引),對于示例 SQL,上面代碼中的 while 會進行 2 輪迭代:
第 1 輪,調(diào)用 row_ins_index_entry_step(),插入記錄到主鍵索引。示例 SQL 沒有指定主鍵字段值,主鍵字段會使用自增值,不會和表中原有記錄沖突,插入操作能執(zhí)行成功。
第 2 輪,調(diào)用 row_ins_index_entry_step(),插入記錄到 uniq_i1。新插入記錄的 i1 字段值為 1001,和表中原有記錄(id = 1)的 i1 字段值相同,會導(dǎo)致唯一索引沖突。
row_ins_index_entry_step() 插入記錄到 uniq_i1,導(dǎo)致唯一索引沖突,它會返回錯誤碼 DB_DUPLICATE_KEY 給 row_ins()。
row_ins() 拿到錯誤碼之后,它的執(zhí)行流程到此結(jié)束,把錯誤碼返回給調(diào)用者。
當執(zhí)行流程帶著錯誤碼(DB_DUPLICATE_KEY)一路返回到 row_insert_for_mysql_using_ins_graph(),接下來會調(diào)用 row_mysql_handle_errors() 處理唯一索引沖突的善后邏輯(這部分留到 4.3 回滾語句再聊)。
介紹唯一索引沖突的善后邏輯之前,我們以 row_ins_sec_index_entry_low() 為入口,一路跟隨執(zhí)行流程進入 row_ins_sec_index_entry_low(),來看看給唯一索引中沖突記錄加 next-key 共享鎖的流程。
這里的 next-key 共享鎖,就是下圖中 LOCK_DATA = 1001,1 對應(yīng)的鎖。
(2)唯一索引記錄加鎖
// storage/innobase/row/row0ins.cc
dberr_t row_ins_sec_index_entry_low(...) {
...
if (dict_index_is_spatial(index)) {
// 處理空間索引的邏輯
...
} else {
if (index->table->is_intrinsic()) {
// MySQL 內(nèi)部臨時表
...
} else {
// 找到記錄將要插入到哪個位置
btr_cur_search_to_nth_level(index, 0, entry, PAGE_CUR_LE, search_mode,
&cursor, 0, __FILE__, __LINE__, &mtr);
}
}
...
// 索引中需要用幾個(n_unique)字段
// 才能唯一標識一條記錄
n_unique = dict_index_get_n_unique(index);
// 如果是主鍵索引或唯一索引
if (dict_index_is_unique(index) &&
// 并且即將插入的記錄
// 和索引中的記錄相同
(cursor.low_match >= n_unique || cursor.up_match >= n_unique)) {
...
// 判斷新插入記錄是否會導(dǎo)致沖突
// 如果會導(dǎo)致沖突,會對沖突記錄加鎖
err = row_ins_scan_sec_index_for_duplicate(flags, index, entry, thr, check,
&mtr, offsets_heap);
...
}
...
}
row_ins_sec_index_entry_low() 找到插入記錄的目標位置之后,如果發(fā)現(xiàn)這個位置已經(jīng)有一條相同的記錄了,說明有可能導(dǎo)致唯一索引沖突,調(diào)用 row_ins_scan_sec_index_for_duplicate() 確認是否沖突,并根據(jù)情況進行加鎖處理。
// storage/innobase/row/row0ins.cc
[[nodiscard]] static dberr_t row_ins_scan_sec_index_for_duplicate(...)
{
...
// SQL 語句是否包含解決主鍵、唯一索引沖突的邏輯
allow_duplicates = row_allow_duplicates(thr);
...
do {
...
if (flags & BTR_NO_LOCKING_FLAG) {
/* Set no locks when applying log in online table rebuild. */
} else if (allow_duplicates) {
...
// 如果 SQL 語句包含解決主鍵、唯一索引沖突的邏輯
// 給沖突記錄加排他鎖(LOCK_X)
err = row_ins_set_rec_lock(LOCK_X, lock_type, block, rec, index, offsets,
thr);
} else /* else_1 */ {
if (skip_gap_locks) {
// 如果是數(shù)據(jù)字典表、SDI 表
// 決定加什么鎖,忽略
...
} else if (is_supremum) {
/* We use next key lock to possibly combine the locks in bitmap.
Equivalent to LOCK_GAP. */
// next-key 鎖
lock_type = LOCK_ORDINARY;
} else if (is_next) {
/* Only gap lock is required on next record. */
// gap 鎖
lock_type = LOCK_GAP;
} else /* else_2 */ {
/* Next key lock for all equal keys. */
// next-key 鎖
lock_type = LOCK_ORDINARY;
}
...
// SQL 語句【不包含】解決主鍵、唯一索引沖突的邏輯
// 給沖突記錄加共享鎖(LOCK_S)
err = row_ins_set_rec_lock(LOCK_S, lock_type, block, rec, index, offsets,
thr);
}
...
if (is_supremum) {
continue;
}
// !index->allow_duplicates = true
// 即 index->allow_duplicates = false
// 表示不允許索引中存在重復(fù)記錄
// 調(diào)用 row_ins_dupl_error_with_rec()
// 確定新插入記錄是否會導(dǎo)致索引沖突
if (!is_next && !index->allow_duplicates) {
if (row_ins_dupl_error_with_rec(rec, entry, index, offsets)) {
// 返回 true,說明會導(dǎo)致索引沖突
// 把錯誤碼賦值給 err 變量
// 作為方法的返回值
err = DB_DUPLICATE_KEY;
...
goto end_scan;
}
} else /* else_3 */ {
ut_a(is_next || index->allow_duplicates);
goto end_scan;
}
} while (pcur.move_to_next(mtr));
end_scan:
/* Restore old value */
dtuple_set_n_fields_cmp(entry, n_fields_cmp);
return err;
}
以下 3 種 SQL,allow_duplicates = true,表示 SQL 包含解決主鍵、唯一索引沖突的邏輯:
- load datafile replace
- replace into
- insert ... on duplicate key update
解決沖突的方式:
- load datafile replace、replace into,刪除表中的沖突記錄,插入新記錄。
- insert ... on duplicate key update,用 update 后面的各字段值更新表中沖突記錄對應(yīng)的字段。
如果 SQL 包含解決主鍵、唯一索引沖突的邏輯,會更新或刪除沖突記錄,所以需要加排他鎖(LOCK_X)。
對于示例 SQL,allow_duplicates = false,執(zhí)行流程會進入 else_1 分支。
因為示例 SQL 不包含解決主鍵、唯一索引沖突的邏輯,不會更新、刪除沖突記錄,所以,只需要對沖突記錄加共享鎖(LOCK_S),加鎖的精確模式為 next-key 鎖(對應(yīng) else_2 分支)。
和變量 allow_duplicates 的含義不同,if (!is_next && !index->allow_duplicates) 中的 index->allow_duplicates 表示唯一索引是否允許存在重復(fù)記錄:
- 對于 MySQL 內(nèi)部臨時表的二級索引,index->allow_duplicates = true。
- 對于其它表,index->allow_duplicates = false。
對于示例 SQL,if (!is_next && !index->allow_duplicates) 條件成立,調(diào)用 row_ins_dupl_error_with_rec() 得到返回值 true,說明新插入記錄和唯一索引中的原有記錄沖突。
執(zhí)行流程進入 if (row_ins_dupl_error_with_rec(rec, entry, index, offsets)) 分支,設(shè)置變量 err 的值為 DB_DUPLICATE_KEY。
那么,問題來了:插入記錄到唯一索引時,發(fā)現(xiàn)插入目標位置已經(jīng)有一條相同的記錄了,這不能說明新插入記錄和唯一索引中原有記錄沖突嗎?
還真不能,因為唯一索引有個特殊場景要處理,那就是 NULL 值。
InnoDB 認為 NULL 表示未知,NULL 和 NULL 也是不相等的,所以,唯一索引中可以包含多條字段值為 NULL 的記錄。
本文中,唯一索引都是指的二級索引。InnoDB 主鍵的字段值是不允許為 NULL 的。
舉個例子:對于測試表 t6,假設(shè)某條記錄的 i1 字段值為 NULL,新記錄的 i1 字段值也為 NULL,就可以插入成功,而不會報 Duplicate key 錯誤。
(3)回滾語句
row_ins_step() 執(zhí)行結(jié)束之后,row_insert_for_mysql_using_ins_graph() 從 trx->error_state 中得到錯誤碼 DB_DUPLICATE_KEY,說明新插入記錄導(dǎo)致唯一索引沖突,調(diào)用 row_mysql_handle_errors() 處理沖突的善后邏輯,堆棧如下:
| > row_mysql_handle_errors(...) storage/innobase/row/row0mysql.cc:701
| + > // 插入記錄導(dǎo)致唯一索引沖突,需要回滾
| + > trx_rollback_to_savepoint(trx_t*, trx_savept_t*) storage/innobase/trx/trx0roll.cc:151
| + - > trx_rollback_to_savepoint_low(trx_t*, trx_savept_t*) storage/innobase/trx/trx0roll.cc:114
| + - x > que_run_threads(que_thr_t*) storage/innobase/que/que0que.cc:1001
| + - x = > que_run_threads_low(que_thr_t*) storage/innobase/que/que0que.cc:966
| + - x = | > que_thr_step(que_thr_t*) storage/innobase/que/que0que.cc:913
| + - x = | + > row_undo_step(que_thr_t*) storage/innobase/row/row0undo.cc:362
| + - x = | + - > row_undo(undo_node_t*, que_thr_t*) storage/innobase/row/row0undo.cc:296
| + - x = | + - x > row_undo_ins(undo_node_t*, que_thr_t*) storage/innobase/row/row0uins.cc:500
| + - x = | + - x = > row_undo_ins_remove_clust_rec(undo_node_t*) storage/innobase/row/row0uins.cc:118
| + - x = | + - x = | > row_convert_impl_to_expl_if_needed(btr_cur_t*, undo_node_t*) storage/innobase/row/row0undo.cc:338
| + - x = | + - x = | + > // 把主鍵索引記錄上的隱式鎖轉(zhuǎn)換為顯式鎖
| + - x = | + - x = | + > lock_rec_convert_impl_to_expl(...) storage/innobase/lock/lock0lock.cc:5544
| + - x = | + - x = | + - > lock_rec_convert_impl_to_expl_for_trx(...) storage/innobase/lock/lock0lock.cc:5496
| + - x = | + - x = | + - x > lock_rec_add_to_queue(...) storage/innobase/lock/lock0lock.cc:1613
| + - x = | + - x = | + - x = > lock_rec_other_has_expl_req(...) storage/innobase/lock/lock0lock.cc:900
| + - x = | + - x = | + - x = > // 創(chuàng)建鎖結(jié)構(gòu)
| + - x = | + - x = | + - x = > RecLock::create(trx_t*, lock_prdt const*) storage/innobase/lock/lock0lock.cc:1356
| + - x = | + - x = | > // 先進行樂觀刪除,如果樂觀刪除失敗,后面會進行悲觀刪除
| + - x = | + - x = | > btr_cur_optimistic_delete(...) storage/innobase/include/btr0cur.h:466
| + - x = | + - x = | + > btr_cur_optimistic_delete_func(...) storage/innobase/btr/btr0cur.cc:4562
| + - x = | + - x = | + - > lock_update_delete(...) storage/innobase/lock/lock0lock.cc:3350
| + - x = | + - x = | + - x > // 剛剛插入的記錄,因為唯一索引沖突需要刪除,讓它的下一條記錄繼承 GAP 鎖
| + - x = | + - x = | + - x > lock_rec_inherit_to_gap(...) storage/innobase/lock/lock0lock.cc:2588
| + - x = | + - x = | + - x = > lock_rec_add_to_queue(...) storage/innobase/lock/lock0lock.cc:1681
| + - x = | + - x = | + - x = | > // 為被刪除的主鍵記錄的下一條記錄創(chuàng)建鎖結(jié)構(gòu)
| + - x = | + - x = | + - x = | > RecLock::create(trx_t*, lock_prdt const*) storage/innobase/lock/lock0lock.cc:1356
row_mysql_handle_errors() 的核心邏輯是個 switch,根據(jù)不同的錯誤碼進行相應(yīng)的處理。
// storage/innobase/row/row0mysql.cc
bool row_mysql_handle_errors(...)
{
...
switch (err) {
...
case DB_DUPLICATE_KEY:
...
if (savept) {
/* Roll back the latest, possibly incomplete insertion
or update */
trx_rollback_to_savepoint(trx, savept);
}
/* MySQL will roll back the latest SQL statement */
break;
...
}
...
}
對于錯誤碼 DB_DUPLICATE_KEY,row_mysql_handle_errors() 會調(diào)用 trx_rollback_to_savepoint() 回滾示例 SQL 對于主鍵索引所做的插入記錄操作。
savept 是調(diào)用 row_ins_step() 插入記錄到主鍵、唯一索引之前的保存點,trx_rollback_to_savepoint() 可以利用 savept 中的保存點,刪除 row_ins_step() 剛剛插入到主鍵索引中的記錄,讓主鍵索引回到 row_ins_step() 執(zhí)行之前的狀態(tài)。
對于示例 SQL,trx_rollback_to_savepoint() 經(jīng)過多級之后,調(diào)用 row_undo_ins_remove_clust_rec() 刪除已插入到主鍵索引的記錄。
// storage/innobase/row/row0uins.cc
[[nodiscard]] static dberr_t row_undo_ins_remove_clust_rec(
undo_node_t *node) /*!< in: undo node */
{
...
// 把新插入到主鍵索引中的記錄上的隱式鎖
// 轉(zhuǎn)換為顯式鎖
row_convert_impl_to_expl_if_needed(btr_cur, node);
// 先進行樂觀刪除
if (btr_cur_optimistic_delete(btr_cur, 0, &mtr)) {
err = DB_SUCCESS;
goto func_exit;
}
...
// 如果樂觀刪除失敗,再進行悲觀刪除
btr_cur_pessimistic_delete(&err, false, btr_cur, 0, true, node->trx->id,
node->undo_no, node->rec_type, &mtr, &node->pcur,
nullptr);
}
刪除主鍵索引記錄之前,需要給它加鎖。因為插入操作包含隱式鎖的邏輯,所以這里的加鎖操作是把即將被刪除記錄上的隱式鎖轉(zhuǎn)換為顯式鎖。
當然,需要滿足一定的條件,row_convert_impl_to_expl_if_needed() 才會把主鍵索引中即將被刪除記錄上的隱式鎖轉(zhuǎn)換為顯式鎖。
// storage/innobase/row/row0undo.cc
void row_convert_impl_to_expl_if_needed(btr_cur_t *cursor, undo_node_t *node) {
...
// 滿足以下 3 種條件之一,不需要把隱式鎖轉(zhuǎn)換為顯式鎖:
// 1. !node->partial = true,即 node->partial = false
// 表示整個事務(wù)回滾
// 2. node->trx == nullptr
// 3. node->trx->isolation_level < trx_t::REPEATABLE_READ
// 事務(wù)隔離級別為:讀未提交(RU)、讀已提交(RC)
if (!node->partial || (node->trx == nullptr) ||
node->trx->isolation_level < trx_t::REPEATABLE_READ) {
return;
}
...
// 滿足以下 4 種條件,需要把隱式鎖轉(zhuǎn)換顯式鎖:
// 1. heap_no 對應(yīng)的記錄不是 supremum
// 2. 當前索引不是空間索引
// 3. 不是用戶臨時表
// 4. 不是 MySQL 內(nèi)部臨時表
if (/* 1 */ heap_no != PAGE_HEAP_NO_SUPREMUM &&
/* 2 */ !dict_index_is_spatial(index) &&
/* 3 */ !index->table->is_temporary() &&
/* 4 */ !index->table->is_intrinsic()) {
lock_rec_convert_impl_to_expl(block, rec, index,
Rec_offsets().compute(rec, index));
}
}
對于示例 SQL,第 1 個 if 條件不成立,所以不會執(zhí)行 return,而是會繼續(xù)判斷第 2 個 if 條件。
第 2 個 if 條件成立,執(zhí)行流程進入 if 分支,調(diào)用 lock_rec_convert_impl_to_expl() 把隱式鎖轉(zhuǎn)換為顯式鎖。
執(zhí)行流程回到 row_undo_ins_remove_clust_rec(),調(diào)用 row_convert_impl_to_expl_if_needed() 把主鍵索引中即將被刪除記錄上的隱式鎖轉(zhuǎn)換為顯式鎖之后,接下就是刪除記錄了。
先調(diào)用 btr_cur_optimistic_delete() 進行樂觀刪除。
樂觀刪除指的是刪除數(shù)據(jù)頁中的記錄之后,不會因為數(shù)據(jù)頁中的記錄數(shù)量過少而觸發(fā)相鄰的數(shù)據(jù)頁合并。
如果樂觀刪除成功,直接返回 DB_SUCCESS。
如果樂觀刪除失敗,再調(diào)用 btr_cur_pessimistic_delete() 進行悲觀刪除。
悲觀刪除指的是刪除數(shù)據(jù)頁中的記錄之后,因為數(shù)據(jù)頁中的記錄數(shù)量過少,會觸相鄰的數(shù)據(jù)頁合并。
(4)主鍵索引記錄的隱式鎖轉(zhuǎn)換
上一小節(jié)中,我們沒有深入介紹主鍵索引中即將被刪除記錄上的隱式鎖轉(zhuǎn)換為顯式鎖的邏輯,接下來,我們來看看這個邏輯。
// storage/innobase/lock/lock0lock.cc
void lock_rec_convert_impl_to_expl(...) {
trx_t *trx;
...
// 主鍵索引
if (index->is_clustered()) {
trx_id_t trx_id;
// 獲取 rec 記錄中 DB_TRX_ID 字段的值
// 拿到插入 rec 記錄的事務(wù) ID
trx_id = lock_clust_rec_some_has_impl(rec, index, offsets);
// 判斷事務(wù)是否處于活躍狀態(tài)
// 如果事務(wù)是活躍狀態(tài),返回事務(wù)的 trx_t 對象
// 如果事務(wù)已提交,返回 nullptr
trx = trx_rw_is_active(trx_id, true);
} else { // 二級索引
...
}
if (trx != nullptr) {
ulint heap_no = page_rec_get_heap_no(rec);
...
// 如果事務(wù)是活躍狀態(tài)
// 把 rec 記錄上的隱式鎖轉(zhuǎn)換為顯式鎖
lock_rec_convert_impl_to_expl_for_trx(block, rec, index, offsets, trx,
heap_no);
}
}
InnoDB 主鍵索引的記錄中,都有一個隱藏字段 DB_TRX_ID。
lock_rec_convert_impl_to_expl() 先調(diào)用 lock_clust_rec_some_has_impl() 讀取主鍵索引中即將被刪除記錄的 DB_TRX_ID 字段。
然后調(diào)用 trx_rw_is_active() 判斷 DB_TRX_ID 對應(yīng)的事務(wù)是否處于活躍狀態(tài)(事務(wù)未提交)。
如果事務(wù)處于活躍狀態(tài),調(diào)用 lock_rec_convert_impl_to_expl_for_trx() 把 rec 記錄上的隱式鎖轉(zhuǎn)換為顯式鎖。
// storage/innobase/lock/lock0lock.cc
static void lock_rec_convert_impl_to_expl_for_trx(...)
{
...
{
locksys::Shard_latch_guard guard{UT_LOCATION_HERE, block->get_page_id()};
...
trx_mutex_enter(trx);
...
// 判斷事務(wù)的狀態(tài)不是 TRX_STATE_COMMITTED_IN_MEMORY
if (!trx_state_eq(trx, TRX_STATE_COMMITTED_IN_MEMORY) &&
// heap_no 對應(yīng)記錄上沒有顯式的排他鎖
!lock_rec_has_expl(LOCK_X | LOCK_REC_NOT_GAP, block, heap_no, trx)) {
ulint type_mode;
// 加鎖粒度:記錄(LOCK_REC)
// 加鎖模式:寫鎖(LOCK_X)
// 加鎖的精確模式:記錄(LOCK_REC_NOT_GAP)
type_mode = (LOCK_REC | LOCK_X | LOCK_REC_NOT_GAP);
lock_rec_add_to_queue(type_mode, block, heap_no, index, trx, true);
}
trx_mutex_exit(trx);
}
trx_release_reference(trx);
...
}
lock_rec_convert_impl_to_expl_for_trx() 也不會照單全收,它還會進一步判斷:
- 事務(wù)狀態(tài)不是 TRX_STATE_COMMITTED_IN_MEMORY,因為處于這個狀態(tài)的事務(wù)就算是已經(jīng)提交成功了,已提交成功的事務(wù)修改的記錄不包含隱藏式鎖邏輯,也就不需要把隱式鎖轉(zhuǎn)換為顯式鎖了。
- 記錄上沒有顯式的排他鎖。
滿足上面 2 個條件之后,才會調(diào)用 lock_rec_add_to_queue() 創(chuàng)建鎖對象(RecLock)并加入到全局鎖對象的 hash 表中,這就最終完成了把主鍵索引中即將被刪除記錄上的隱式鎖轉(zhuǎn)換為顯式鎖。
(5)主鍵索引記錄的鎖轉(zhuǎn)移
主鍵索引中即將被刪除記錄上的顯式鎖,只是個過渡,它是用來為鎖轉(zhuǎn)移做準備的。
不管是樂觀刪除,還是悲觀刪除,刪除剛插入到主鍵索引的記錄之前,需要把該記錄上的鎖轉(zhuǎn)移到它的下一條記錄上,轉(zhuǎn)移操作由 lock_update_delete() 完成。
// storage/innobase/lock/lock0lock.cc
void lock_update_delete(const buf_block_t *block, const rec_t *rec) {
...
if (page_is_comp(page)) {
// 獲取即將被刪除的記錄的編號
heap_no = rec_get_heap_no_new(rec);
// 獲取即將被刪除記錄的下一條記錄的編號
next_heap_no = rec_get_heap_no_new(page + rec_get_next_offs(rec, true));
} else {
...
}
...
/* Let the next record inherit the locks from rec, in gap mode */
// 把即將被刪除記錄上的鎖轉(zhuǎn)移到它的下一條記錄上
lock_rec_inherit_to_gap(block, block, next_heap_no, heap_no);
...
}
lock_update_delete() 調(diào)用 rec_get_heap_no_new() 獲取即將被刪除記錄的下一條記錄的編號,然后調(diào)用 lock_rec_inherit_to_gap() 把即將被刪除記錄上的鎖轉(zhuǎn)移到它的下一條記錄上。
// storage/innobase/lock/lock0lock.cc
static void lock_rec_inherit_to_gap(...)
{
lock_t *lock;
...
// heap_no 是主鍵索引中即將被刪除的記錄編號
for (lock = lock_rec_get_first(lock_sys->rec_hash, block, heap_no);
lock != nullptr; lock = lock_rec_get_next(heap_no, lock)) {
/* Skip inheriting lock if set */
if (lock->trx->skip_lock_inheritance) {
continue;
}
if (!lock_rec_get_insert_intention(lock) &&
!lock->index->table->skip_gap_locks() &&
(!lock->trx->skip_gap_locks() || lock->trx->lock.inherit_all.load())) {
lock_rec_add_to_queue(LOCK_REC | LOCK_GAP | lock_get_mode(lock),
heir_block, heir_heap_no, lock->index, lock->trx);
}
}
}
for 循環(huán)中,lock_rec_get_first() 獲取主鍵索引中即將被刪除記錄上的鎖。
能否獲取到鎖,取決于前面的 row_convert_impl_to_expl_if_needed() 是否已經(jīng)把記錄上的隱式鎖轉(zhuǎn)換為顯式鎖。
row_convert_impl_to_expl_if_needed() 會對多個條件進行判斷,以決定是否把記錄上的隱式鎖轉(zhuǎn)換為顯式鎖。其中,比較重要的判斷條件是事務(wù)隔離級別:
- 如果事務(wù)隔離級別是 READ-COMMITTED,隱式鎖不轉(zhuǎn)換為顯式鎖。
- 如果事務(wù)隔離級別是 REPEATABLE-READ,再結(jié)合其它判斷條件,決定是否把隱式鎖轉(zhuǎn)換為顯式鎖。
我們以測試表和示例 SQL 為例,來看看 lock_rec_inherit_to_gap() 的執(zhí)行流程。
示例 SQL 執(zhí)行于 REPEATABLE-READ 隔離級別之下,并且滿足其它判斷條件,row_convert_impl_to_expl_if_needed() 會把記錄上的隱式鎖轉(zhuǎn)換為顯式鎖。
所以,lock_rec_get_first() 會獲取到主鍵索引中即將被刪除記錄上的鎖,并且 for 循環(huán)中的第 2 個 if 條件成立,執(zhí)行流程進入 if 分支。
對于示例 SQL,即將被刪除記錄的下一條記錄是 supremum,調(diào)用 lock_rec_add_to_queue() 把即將被刪除記錄上的鎖轉(zhuǎn)移到 supremum 記錄上。
接下來,介紹 lock_rec_add_to_queue() 代碼之前,我們先看一下傳給該方法的第 1 個參數(shù)的值。
lock_get_mode() 會返回即將被刪除記錄上的鎖:LOCK_REC_NOT_GAP | LOCK_REC | LOCK_X。
第 1 個參數(shù)的值為:LOCK_REC | LOCK_GAP | lock_get_mode(lock)。
把 lock_get_mode() 的返回值代入其中,得到:LOCK_REC | LOCK_GAP | LOCK_REC_NOT_GAP | LOCK_REC | LOCK_X。
去重之后,得到傳給 lock_rec_add_to_queue() 的第 1 個參數(shù)(type_mode)的值:LOCK_REC | LOCK_GAP | LOCK_REC_NOT_GAP | LOCK_X。
// storage/innobase/lock/lock0lock.cc
static void lock_rec_add_to_queue(ulint type_mode, ...) {
...
// 對 supremum 偽記錄進行特殊處理
if (heap_no == PAGE_HEAP_NO_SUPREMUM) {
...
// 去掉 LOCK_GAP、LOCK_REC_NOT_GAP
type_mode &= ~(LOCK_GAP | LOCK_REC_NOT_GAP);
}
...
// 實例化鎖對象
RecLock rec_lock(index, block, heap_no, type_mode);
...
// 把鎖對象加入全局鎖對象 hash 表
rec_lock.create(trx);
...
}
type_mode 就是 lock_rec_inherit_to_gap() 函數(shù)中傳過來的第 1 個參數(shù),它的值為:LOCK_REC | LOCK_GAP | LOCK_REC_NOT_GAP | LOCK_X。
對于示例 SQL,即將被刪除記錄的下一條記錄是 supremum,執(zhí)行流程會命中 if (heap_no == PAGE_HEAP_NO_SUPREMUM) 分支,執(zhí)行代碼:type_mode &= ~(LOCK_GAP | LOCK_REC_NOT_GAP)。
從 type_mode 中去掉 LOCK_GAP、LOCK_REC_NOT_GAP,得到 LOCK_REC | LOCK_X,表示給 supremum 加 next-key 排他鎖。
5、總結(jié)
REPEATABLE-READ 隔離級別下,如果插入一條記錄,導(dǎo)致唯一索引沖突,執(zhí)行流程如下:
- 插入記錄到主鍵索引,成功。
- 插入記錄到唯一索引,沖突,插入失敗。
- 給唯一索引中沖突的記錄加鎖。
對于 load datafile replace、replace into、insert ... on duplicate key update 語句,加排他鎖(LOCK_X)。對于其它語句,加共享鎖(LOCK_S)。 - 把主鍵索引中對應(yīng)記錄上的隱式鎖轉(zhuǎn)換為顯式鎖 [Not RC]。
- 把主鍵索引記錄上的顯式鎖轉(zhuǎn)移到它的下一條記錄上 [Not RC]。
- 刪除主鍵索引記錄。
順便說一下,對于 READ-COMMITTED 隔離級別,大體流程相同,不同之處在于,它沒有上面流程中打了 [Not RC] 標記的兩個步驟。
對于示例 SQL,READ-COMMITTED 隔離級別下,不會給主鍵索引的 supremum 記錄加鎖,加鎖情況如下:
最后,把示例 SQL 在 REPEATABLE-READ 隔離級別下的加鎖情況放在這里,作個對比:
本文轉(zhuǎn)載自微信公眾號「一樹一溪」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請聯(lián)系一樹一溪公眾號。