自拍偷在线精品自拍偷,亚洲欧美中文日韩v在线观看不卡

答讀者問:唯一索引沖突,為什么主鍵的 Supremum 記錄會加 Next-Key 鎖?

數(shù)據(jù)庫 MySQL
我在 MySQL 8.0.32 復(fù)現(xiàn)了問題,并調(diào)試了加鎖流程,寫下來和大家分享。了解完整的加鎖流程,有助于我們更深入的理解 InnoDB 的記錄鎖,希望大家有收獲。

本文緣起于一位讀者的提問:插入一條記錄,導(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)系一樹一溪公眾號。

責任編輯:姜華 來源: 一樹一溪
相關(guān)推薦

2022-04-29 11:39:28

MySQL幻讀Gap Lock

2024-09-04 08:44:18

MySQL核心模塊

2021-06-05 18:02:20

MySQL加鎖范圍

2023-11-06 11:13:58

Bean占位符標記

2021-06-08 09:41:26

MySQL加鎖范圍

2025-04-24 10:56:01

MySQLInnoDB數(shù)據(jù)庫鎖

2024-06-12 14:03:31

MySQLInnoDB

2022-08-04 08:22:49

MySQL索引

2018-09-14 09:12:00

數(shù)據(jù)庫MySQL索引約束

2018-09-16 23:14:18

MySQL索引約束主鍵

2014-10-13 10:41:48

安防布線

2023-02-10 10:14:59

普通索引唯一索引

2024-10-16 11:11:51

隔離InnoDB死鎖

2012-02-24 10:28:32

2022-09-15 07:54:59

awaitPromise

2024-01-11 08:12:20

重量級監(jiān)視器

2024-05-29 09:05:17

2010-05-28 15:47:16

雙絞線

2022-04-01 08:37:07

SpringAPI前端

2012-10-31 09:41:47

WAN優(yōu)化SSLHTTPS
點贊
收藏

51CTO技術(shù)棧公眾號