首页 > 文章列表 > 答读者问:唯一索引冲突,为什么主键的 Supremum 记录会加 Next-Key 锁?

答读者问:唯一索引冲突,为什么主键的 Supremum 记录会加 Next-Key 锁?

mysql
302 2023-06-05

本文缘起于一位读者的提问:插入一条记录,导致唯一索引冲突,为什么会对主键的 supremum 记录加 next-key 排他锁?

我在 MySQL 8.0.32 复现了问题,并调试了加锁流程,写下来和大家分享。

了解完整的加锁流程,有助于我们更深入的理解 InnoDB 的记录锁,希望大家有收获。

本文基于 MySQL 8.0.32 源码,存储引擎为 InnoDB。

1、准备工作

创建测试表:

row_ins_index_entry_step() 插入记录到 uniq_i1,导致唯一索引冲突,它会返回错误码 DB_DUPLICATE_KEY 给 row_ins()。row_ins() 拿到错误码之后,它的执行流程到此结束,把错误码返回给调用者。当执行流程带着错误码(DB_DUPLICATE_KEY)一路返回到 row_insert_for_mysql_using_ins_graph(),接下来会调用 row_mysql_handle_errors() 处理唯一索引冲突的善后逻辑(这部分留到 4.3 回滚语句再聊)。介绍唯一索引冲突的善后逻辑之前,我们以 row_ins_sec_index_entry_low() 为入口,一路跟随执行流程进入 row_ins_sec_index_entry_low(),来看看给唯一索引中冲突记录加 next-key 共享锁的流程。这里的 next-key 共享锁,就是下图中 LOCK_DATA = 1001,1 对应的锁。(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 内部临时表
      ...
    } 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)) {
    ...
    // 判断新插入记录是否会导致冲突
    // 如果会导致冲突,会对冲突记录加锁
    err = row_ins_scan_sec_index_for_duplicate(flags, index, entry, thr, check,
                                               &mtr, offsets_heap);
    ...
  }
  ...
}

row_ins_sec_index_entry_low() 找到插入记录的目标位置之后,如果发现这个位置已经有一条相同的记录了,说明有可能导致唯一索引冲突,调用 row_ins_scan_sec_index_for_duplicate() 确认是否冲突,并根据情况进行加锁处理。

本文中,唯一索引都是指的二级索引。InnoDB 主键的字段值是不允许为 NULL 的。举个例子:对于测试表 t6,假设某条记录的 i1 字段值为 NULL,新记录的 i1 字段值也为 NULL,就可以插入成功,而不会报 Duplicate key 错误。(3)回滚语句row_ins_step() 执行结束之后,row_insert_for_mysql_using_ins_graph() 从 trx->error_state 中得到错误码 DB_DUPLICATE_KEY,说明新插入记录导致唯一索引冲突,调用 row_mysql_handle_errors() 处理冲突的善后逻辑,堆栈如下:| > row_mysql_handle_errors(...) storage/innobase/row/row0mysql.cc:701
| + > // 插入记录导致唯一索引冲突,需要回滚
| + > 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 = | + > // 把主键索引记录上的隐式锁转换为显式锁
| + - 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 = > // 创建锁结构
| + - 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 = | > // 为被删除的主键记录的下一条记录创建锁结构
| + - x = | + - x = | + - x = | > RecLock::create(trx_t*, lock_prdt const*) storage/innobase/lock/lock0lock.cc:1356

row_mysql_handle_errors() 的核心逻辑是个 switch,根据不同的错误码进行相应的处理。

乐观删除指的是删除数据页中的记录之后,不会因为数据页中的记录数量过少而触发相邻的数据页合并。如果乐观删除成功,直接返回 DB_SUCCESS。如果乐观删除失败,再调用 btr_cur_pessimistic_delete() 进行悲观删除。悲观删除指的是删除数据页中的记录之后,因为数据页中的记录数量过少,会触相邻的数据页合并。(4)主键索引记录的隐式锁转换上一小节中,我们没有深入介绍主键索引中即将被删除记录上的隐式锁转换为显式锁的逻辑,接下来,我们来看看这个逻辑。// 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 记录的事务 ID
    trx_id = lock_clust_rec_some_has_impl(rec, index, offsets);
    // 判断事务是否处于活跃状态
    // 如果事务是活跃状态,返回事务的 trx_t 对象
    // 如果事务已提交,返回 nullptr
    trx = trx_rw_is_active(trx_id, true);
  } else { // 二级索引
    ...
  }

  if (trx != nullptr) {
    ulint heap_no = page_rec_get_heap_no(rec);
    ...
    // 如果事务是活跃状态
    // 把 rec 记录上的隐式锁转换为显式锁
    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() 先调用 lock_clust_rec_some_has_impl() 读取主键索引中即将被删除记录的 DB_TRX_ID 字段。

然后调用 trx_rw_is_active() 判断 DB_TRX_ID 对应的事务是否处于活跃状态(事务未提交)。

如果事务处于活跃状态,调用 lock_rec_convert_impl_to_expl_for_trx() 把 rec 记录上的隐式锁转换为显式锁。

// 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);
    ...
    // 判断事务的状态不是 TRX_STATE_COMMITTED_IN_MEMORY
    if (!trx_state_eq(trx, TRX_STATE_COMMITTED_IN_MEMORY) &&
        // heap_no 对应记录上没有显式的排他锁
        !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() 也不会照单全收,它还会进一步判断:

事务状态不是 TRX_STATE_COMMITTED_IN_MEMORY,因为处于这个状态的事务就算是已经提交成功了,已提交成功的事务修改的记录不包含隐藏式锁逻辑,也就不需要把隐式锁转换为显式锁了。记录上没有显式的排他锁。

满足上面 2 个条件之后,才会调用 lock_rec_add_to_queue() 创建锁对象(RecLock)并加入到全局锁对象的 hash 表中,这就最终完成了把主键索引中即将被删除记录上的隐式锁转换为显式锁。

(5)主键索引记录的锁转移

主键索引中即将被删除记录上的显式锁,只是个过渡,它是用来为锁转移做准备的。

不管是乐观删除,还是悲观删除,删除刚插入到主键索引的记录之前,需要把该记录上的锁转移到它的下一条记录上,转移操作由 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 */
  // 把即将被删除记录上的锁转移到它的下一条记录上
  lock_rec_inherit_to_gap(block, block, next_heap_no, heap_no);
  ...
}

lock_update_delete() 调用 rec_get_heap_no_new() 获取即将被删除记录的下一条记录的编号,然后调用 lock_rec_inherit_to_gap() 把即将被删除记录上的锁转移到它的下一条记录上。

// 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 循环中,lock_rec_get_first() 获取主键索引中即将被删除记录上的锁。

能否获取到锁,取决于前面的 row_convert_impl_to_expl_if_needed() 是否已经把记录上的隐式锁转换为显式锁。

row_convert_impl_to_expl_if_needed() 会对多个条件进行判断,以决定是否把记录上的隐式锁转换为显式锁。其中,比较重要的判断条件是事务隔离级别:

如果事务隔离级别是 READ-COMMITTED,隐式锁不转换为显式锁。如果事务隔离级别是 REPEATABLE-READ,再结合其它判断条件,决定是否把隐式锁转换为显式锁。

我们以测试表和示例 SQL 为例,来看看 lock_rec_inherit_to_gap() 的执行流程。

示例 SQL 执行于 REPEATABLE-READ 隔离级别之下,并且满足其它判断条件,row_convert_impl_to_expl_if_needed() 会把记录上的隐式锁转换为显式锁。

所以,lock_rec_get_first() 会获取到主键索引中即将被删除记录上的锁,并且 for 循环中的第 2 个 if 条件成立,执行流程进入 if 分支。

对于示例 SQL,即将被删除记录的下一条记录是 supremum,调用 lock_rec_add_to_queue() 把即将被删除记录上的锁转移到 supremum 记录上。

接下来,介绍 lock_rec_add_to_queue() 代码之前,我们先看一下传给该方法的第 1 个参数的值。

lock_get_mode() 会返回即将被删除记录上的锁:LOCK_REC_NOT_GAP | LOCK_REC | LOCK_X。

第 1 个参数的值为: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 个参数(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() 函数中传过来的第 1 个参数,它的值为:LOCK_REC | LOCK_GAP | LOCK_REC_NOT_GAP | LOCK_X。

对于示例 SQL,即将被删除记录的下一条记录是 supremum,执行流程会命中 if (heap_no == PAGE_HEAP_NO_SUPREMUM) 分支,执行代码:type_mode &= ~(LOCK_GAP | LOCK_REC_NOT_GAP)。

从 type_mode 中去掉 LOCK_GAP、LOCK_REC_NOT_GAP,得到 LOCK_REC | LOCK_X,表示给 supremum 加 next-key 排他锁。

5、总结

REPEATABLE-READ 隔离级别下,如果插入一条记录,导致唯一索引冲突,执行流程如下:

插入记录到主键索引,成功。插入记录到唯一索引,冲突,插入失败。给唯一索引中冲突的记录加锁。
对于 load datafile replace、replace into、insert ... on duplicate key update 语句,加排他锁(LOCK_X)。对于其它语句,加共享锁(LOCK_S)。把主键索引中对应记录上的隐式锁转换为显式锁 [Not RC]。把主键索引记录上的显式锁转移到它的下一条记录上 [Not RC]。删除主键索引记录。

顺便说一下,对于 READ-COMMITTED 隔离级别,大体流程相同,不同之处在于,它没有上面流程中打了 [Not RC] 标记的两个步骤。

对于示例 SQL,READ-COMMITTED 隔离级别下,不会给主键索引的 supremum 记录加锁,加锁情况如下:

图片

最后,把示例 SQL 在 REPEATABLE-READ 隔离级别下的加锁情况放在这里,作个对比:

图片

本文转载自微信公众号「一树一溪」,可以通过以下二维码关注。转载本文请联系一树一溪公众号。