MySQL update 语句加锁分析

引言

本文测试并基于源码分析 update 语句在以下场景下的加锁流程:

  • 根据主键更新普通字段(其中普通字段指没有索引的字段)
  • 根据唯一键更新普通字段(其中唯一键指二级唯一索引)
  • 根据主键更新唯一键
  • 根据唯一键更新主键

参考了多位大佬的文章,但由于个人能力有限,以下内容可能有误,欢迎批评指正。

准备工作

测试数据

数据库版本:5.7.24

事务隔离级别:RR

测试数据

CREATE TABLE `t_dupp` (
  `id` int(11NOT NULL AUTO_INCREMENT,
  `age` int(11DEFAULT NULL,
  `name` varchar(10DEFAULT NULL,
  `a` int(11DEFAULT '0',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_age_name` (`age`,`name`)
ENGINE=InnoDB CHARSET=utf8 ROW_FORMAT=Compact;

insert into t_dupp(age,namevalues(1,'a'),(2,'b'),(3,'c'),(4,'d');

其中:

  • 测试表中有主键与两个字段组成的联合唯一键。

查看数据

mysql> select * from t_dupp;
+----+------+------+------+
| id | age  | name | a    |
+----+------+------+------+
|  1 |    1 | a    |    0 |
|  2 |    2 | b    |    0 |
|  3 |    3 | c    |    0 |
|  4 |    4 | d    |    0 |
+----+------+------+------+
4 rows in set (0.00 sec)

场景

下面对比测试 update 语句在以下四种场景下加锁的差异。

  • 根据主键更新普通字段(其中普通字段指没有索引的字段)
  • 根据唯一键更新普通字段(其中唯一键指二级唯一索引)
  • 根据主键更新唯一键
  • 根据唯一键更新主键

其中调试过程中在lock_rec_lock函数设置断点。

根据主键更新普通字段

update t_dupp set a=1 where id=2;

理论上只改主键索引,不改二级索引。

测试

事务信息

---TRANSACTION 13227907, ACTIVE 5 sec
lock struct(s), heap size 11361 row lock(s), undo log entries 1
MySQL thread id 3616101, OS thread handle 139677278025472query id 63629494 localhost admin
TABLE LOCK table `test_zk`.`t_dupp` trx id 13227907 lock mode IX
RECORD LOCKS space id 2160 page no 3 n bits 72 index PRIMARY of table `test_zk`.`t_dupp` trx id 13227907 lock_mode X locks rec but not gap
Record lockheap no 3 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
 0: len 4; hex 80000002; asc     ;;
 1: len 6; hex 000000c9d783; asc       ;;
 2: len 7; hex 23000000271bac; asc #   '  ;;
 3: len 4; hex 80000002; asc     ;;
 4: len 1; hex 62; asc b;;
 5: len 4; hex 80000001; asc     ;;

其中:

  • 主键索引 X 型 record lock id=2,表明针对唯一键 next-key lock 退化为 record lock。

堆栈信息

MySQL update 语句加锁分析

其中:

  • 根据主键更新普通字段时给主键加锁

注意其中调用row_search_mvcc加锁,可是 MVCC 不是用于快照读吗,更新操作时将旧数据写入 undo log 用于 MVCC,为什么写入需要调用该函数加锁?

分析

实际上 InnoDB 每当读取一条记录时,都会调用一次row_search_mvcc,该函数是 InnoDB 读取一条记录最重要的函数。

row_search_mvcc函数中包含了处理一条记录的各种手段,如:

  • 对一条记录进行诸如多版本的可见性判断;
  • 要不要对记录进行加锁的判断;
  • 要是加锁的话加什么锁的选择;
  • 完成记录从 InnoDB 的存储格式到 server 层存储格式的转换。

因此执行 update、delete 之前也需要调用row_search_mvcc函数先在 B+ 树中定位到相应的记录

InnoDB 对记录的加锁操作主要是在row_search_mvcc中的,在进行加锁类型判断后调用sel_set_rec_lock函数给索引记录加锁。

SELECT ... LOCK IN SHARE MODESELECT ... FOR UPDATEUPDATEDELETE这样的语句都会调用row_search_mvcc完成加锁操作。

其中:

  • SELECT ... LOCK IN SHARE MODE会为记录添加 S 型锁;
  • SELECT ... FOR UPDATEUPDATEDELETE会为记录添加 X 型锁。


调用row_search_mvcc函数读取记录时有时候会利用 MVCC 读取记录,有时候会通过加锁读取记录

因此根据主键更新普通字段时调用row_search_mvcc函数,通过加锁读取记录,与 MVCC 没有关系,row_search_mvcc就是一个函数名,把里边的 mvcc 当成 xxx 就好了。

根据二级唯一键更新普通字段

update t_dupp set a=1 where age=2 and name='b';

理论上只改主键索引,不改二级索引。

测试

事务信息

---TRANSACTION 13227909, ACTIVE 2 sec
lock struct(s), heap size 11362 row lock(s), undo log entries 1
MySQL thread id 3616101, OS thread handle 139677278025472query id 63629499 localhost admin
TABLE LOCK table `test_zk`.`t_dupp` trx id 13227909 lock mode IX
RECORD LOCKS space id 2160 page no 4 n bits 72 index uk_age_name of table `test_zk`.`t_dupp` trx id 13227909 lock_mode X locks rec but not gap
Record lockheap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 80000002; asc     ;;
 1: len 1; hex 62; asc b;;
 2: len 4; hex 80000002; asc     ;;

RECORD LOCKS space id 2160 page no 3 n bits 72 index PRIMARY of table `test_zk`.`t_dupp` trx id 13227909 lock_mode X locks rec but not gap
Record lockheap no 3 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
 0: len 4; hex 80000002; asc     ;;
 1: len 6; hex 000000c9d785; asc       ;;
 2: len 7; hex 24000000312c4c; asc $   1,L;;
 3: len 4; hex 80000002; asc     ;;
 4: len 1; hex 62; asc b;;
 5: len 4; hex 80000001; asc     ;;

其中:

  • 二级唯一索引 X 型 record lock 2b2 ,相比于根据主键更新普通字段的场景新增的锁;
  • 主键索引 X 型 record lock id=2。


堆栈信息

首先给二级索引加锁

MySQL update 语句加锁分析

然后给主键加锁

MySQL update 语句加锁分析

因此加锁两次:

  • update 根据二级唯一键更新普通字段时调用row_search_mvcc函数给二级唯一索引加锁;
  • 由于要更新主键中的普通字段,因此调用row_sel_get_clust_rec_for_mysql函数回表给主键加锁。

分析

row_search_mvcc函数中判断是否需要回表获取完整的聚簇索引记录。

 // 判断是否需要回表
 if (index != clust_index && prebuilt->need_to_access_clustered) {
    
  // 回表
  err = row_sel_get_clust_rec_for_mysql(prebuilt, index, rec,
            thr, &clust_rec,
            &offsets, &heap,
            need_vrow ? &vrow : NULL,
            &mtr);
  }

其中是否需要回表的判断条件为:

  • 定位的是二级索引记录
  • 有回表需求,具体如何判断暂未分析

调用row_sel_get_clust_rec_for_mysql函数回表,其中判断是否需要给主键加锁。

 if (prebuilt->select_lock_type != LOCK_NONE) {
  /* Try to place a lock on the index record; we are searching
  the clust rec with a unique condition, hence
  we set a LOCK_REC_NOT_GAP type lock */


  err = lock_clust_rec_read_check_and_lock(
   0, btr_pcur_get_block(prebuilt->clust_pcur),
   clust_rec, clust_index, *offsets,
   static_cast<lock_mode>(prebuilt->select_lock_type),
   LOCK_REC_NOT_GAP,
   thr);

  switch (err) {
  case DB_SUCCESS:
  case DB_SUCCESS_LOCKED_REC:
   break;
  default:
   goto err_exit;
  }
 }

其中:

  • 如果不是快照读就需要给主键加锁,具体是 record lock。


需要注意的是,即使是对于覆盖索引的场景下,如果我们想对记录加 X 型锁(也就是使用 SELECT … FOR UPDATE、DELETE、UPDATE 语句时)时,也需要对二级索引记录执行回表操作,并给相应的聚簇索引记录添加正经记录锁

MySQL update 语句加锁分析

这里可以提出一个问题,就是根据二级唯一键更新普通字段时是否需要更新二级索引,实际上是不需要的,下文中将介绍。

根据主键更新二级唯一键

update t_dupp set age=3,name='b' where id=2;

理论上既改主键索引,也改二级索引。

测试

事务信息

---TRANSACTION 13227911, ACTIVE 3 sec
lock struct(s), heap size 11361 row lock(s), undo log entries 1
MySQL thread id 3616101, OS thread handle 139677278025472query id 63629503 localhost admin
TABLE LOCK table `test_zk`.`t_dupp` trx id 13227911 lock mode IX
RECORD LOCKS space id 2160 page no 3 n bits 72 index PRIMARY of table `test_zk`.`t_dupp` trx id 13227911 lock_mode X locks rec but not gap
Record lockheap no 3 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
 0: len 4; hex 80000002; asc     ;;
 1: len 6; hex 000000c9d787; asc       ;;
 2: len 7; hex 250000001d0110; asc %      ;;
 3: len 4; hex 80000003; asc     ;;
 4: len 1; hex 62; asc b;;
 5: len 4; hex 80000000; asc     ;;

其中:

  • 主键索引 X 型 record lock id=2。

加锁与 根据主键更新普通字段 相同。

注意其中更改唯一索引但并没有给该索引加锁,下面查看堆栈信息。


堆栈信息

更改主键时加锁

MySQL update 语句加锁分析

删除唯一键时加锁

MySQL update 语句加锁分析

其中:

  • 删除二级唯一键加锁的信息在事务信息中没有,表明两者加锁信息不一致;
  • 插入二级唯一键没有加锁的原因是唯一键不冲突因此使用隐式锁,具体下文中将分析。

分析

更新流程

下面分析根据主键更新二级唯一键时是否需要更改二级索引。

参考八怪大佬的文章,根据主键更新时的流程可以简化为以下四步:

1)判断是否需要修改数据,获取到需要更改的字段对应的值和字段号

2)判断修改的字段中是否包含二级索引

3)更改主键索引

4)判断是否需要更改每个二级索引

由于根据主键更新,因此主键索引都是要更新的,实际上主键更新也分为更新主键索引字段以及普通字段,下文中将介绍。

其他三步都需要判断,下面是各自的判断条件。


数据是否被修改的判断条件为:

  • 长度是否更改了(len)
  • 实际值更改了(memcmp 比对结果)

当然在此之前分别保存新老数据,在对比完成后将需要修改的字段的值和字段号具体是保存在m_prebuilt->upd_node->update数组中。


修改的字段中是否包含二级索引的判断条件为:

  • 如果为 delete 语句显然肯定包含所有的二级索引;
  • 如果为 update 语句,根据前面数组中字段的号和字典中字段是否排序进行比对,因为二级索引的字段一定是排序的。

如果两个条件都不满足,说明没有任何二级索引在本次修改中需要修改,设置本次 update 的标记为 UPD_NODE_NO_ORD_CHANGE,UPD_NODE_NO_ORD_CHANGE 则代表不需要修改任何二级索引字段。注意这里还会转换为 innodb 的行格式(row_mysql_store_col_in_innobase_format函数)。

简单说,只有当主键的排序字段(n_uniq)或二级索引的排序字段被修改时才需要更改二级索引

否则就需要更改二级索引,但其实目前不确定具体修改哪个二级索引,因此依次扫描字典中的每个二级索引判断是否被修改。


循环字典中本二级索引的每个字段判定时判断二级索引是否被更改的判断条件为:

  • 如果本字段不在m_prebuilt->upd_node->update数组中,直接进行下一个字段,说明本字段不需要修改;
  • 如果本字段在m_prebuilt->upd_node->update数组中,则调用dfield_datas_are_binary_equal比较实际的值是否更改。

如果两个条件都不满足,说明没有任何二级索引在本次修改中需要更改,否则更改二级索引。


下面是对应源码。

row_upd函数中分别调用row_upd_clust_steprow_upd_sec_step函数更改主键索引与二级索引。

row_upd(
/*====*/
 upd_node_t* node, /*!< in: row update node */
 que_thr_t* thr) /*!< in: query thread */
{
  if (UNIV_LIKELY(node->in_mysql_interface)) {

  /* We do not get the cmpl_info value from the MySQL
  interpreter: we must calculate it on the fly: */


  // 判断修改的字段是否包含二级索引,有两个条件
  // 如果为delete语句显然肯定包含所有的二级索引
  if (node->is_delete
   // 如果为update语句,根据前面数组中字段的号和字典中字段是否排序进行比对,因为二级索引的字段一定是排序的
      || row_upd_changes_some_index_ord_field_binary(
       node->table, node->update)) {
   node->cmpl_info = 0;
  } else {
   // 如果两个条件都不满足,这说明没有任何二级索引在本次修改中需要修改,设置本次update为UPD_NODE_NO_ORD_CHANGE
   // UPD_NODE_NO_ORD_CHANGE则代表不需要修改任何二级索引字段
   node->cmpl_info = UPD_NODE_NO_ORD_CHANGE;
  }
 }

 switch (node->state) {
 case UPD_NODE_UPDATE_CLUSTERED:
 case UPD_NODE_INSERT_CLUSTERED:
  if (!dict_table_is_intrinsic(node->table)) {
   log_free_check();
  }
  // 修改主键
  err = row_upd_clust_step(node, thr);
 }
  
  do {
  if (node->index->type != DICT_FTS) {
   // 修改二级索引
   err = row_upd_sec_step(node, thr);

  node->index = dict_table_get_next_index(node->index);
  // 如果需要更改二级索引,依次扫描字典中的每个二级索引循环开启
 } while (node->index != NULL);
}

row_upd_sec_step函数中调用row_upd_changes_ord_field_binary函数判断是否需要更改二级索引并更改二级索引。

row_upd_sec_step(
/*=============*/
 upd_node_t* node, /*!< in: row update node */
 que_thr_t* thr) /*!< in: query thread */
{
 ut_ad((node->state == UPD_NODE_UPDATE_ALL_SEC)
       || (node->state == UPD_NODE_UPDATE_SOME_SEC));
 ut_ad(!dict_index_is_clust(node->index));

 // 确认修改的二级索引字段是否在本索引中
 if (node->state == UPD_NODE_UPDATE_ALL_SEC
  // 循环二级索引中的每个字段是否需要修改
     || row_upd_changes_ord_field_binary(node->index, node->update,
      thr, node->row, node->ext)) {
  // 更新二级索引
  return(row_upd_sec_index_entry(node, thr));
 }

 return(DB_SUCCESS);
}

回到文中的测试用例,根据主键更新二级唯一索引时由于符合上面三个判断条件,因此在更改主键后将同步更改二级索引。

更新二级索引

row_upd_sec_index_entry函数中将二级索引的 update 转换成 delete + insert。

 // 根据唯一键查到对应行记录
 search_result = row_search_index_entry(index, entry, mode,
            &pcur, &mtr);

 switch (search_result) {
    case ROW_FOUND: // 唯一键冲突
  ut_ad(err == DB_SUCCESS);

  /* Delete mark the old index record; it can already be
  delete marked if we return after a lock wait in
  row_ins_sec_index_entry() below */

  // delete 二级索引
  if (!rec_get_deleted_flag(
       rec, dict_table_is_comp(index->table))) {
   err = btr_cur_del_mark_set_sec_rec(
    flags, btr_cur, TRUE, thr, &mtr);
   if (err != DB_SUCCESS) {
    break;
   }
  }
 }

 /* Build a new index entry */
 entry = row_build_index_entry(node->upd_row, node->upd_ext,
          index, heap);
 ut_a(entry);

 /* Insert new index entry */
 // insert 二级索引
 err = row_ins_sec_index_entry(index, entry, thr, false);

其中:

  • 调用btr_cur_del_mark_set_sec_rec函数将旧记录标记为 delete-marked;
  • 调用row_ins_sec_index_entry函数向二级索引中插入一条新纪录。


插入前首先进行唯一性检查。

row_ins_sec_index_entry -> row_ins_sec_index_entry_low -> row_ins_scan_sec_index_for_duplicate函数中进行唯一性检查。

 // 索引中需要用几个(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)) {

    // 如果会导致冲突,会对冲突记录加锁
  /* 加Next-Key锁 */
  err = row_ins_scan_sec_index_for_duplicate(
   flags, index, entry, thr, check, &mtr, offsets_heap);

 }

 // 通过二分法没有找到匹配的唯一索引记录
 if (!(flags & BTR_NO_LOCKING_FLAG)
     && dict_index_is_unique(index) // 是唯一索引
     && thr_get_trx(thr)->duplicates // 是否为REPLACE或者ON DUPLICATE语句
  // 隔离级别可重复读以上
     && thr_get_trx(thr)->isolation_level >= TRX_ISO_REPEATABLE_READ) {

  /* When using the REPLACE statement or ON DUPLICATE clause, a
  gap lock is takenrow_ins_duplicate_error_in_ on the position of the to-be-inserted record,
  to avoid other concurrent transactions from inserting the same
  record. */


  const rec_t* rec = page_rec_get_next_const(
   btr_cur_get_rec(&cursor));

  offsets = rec_get_offsets(rec, index, offsets,
       ULINT_UNDEFINED, &offsets_heap);

  /* 加Gap锁 */
  err = row_ins_set_exclusive_rec_lock(
   LOCK_GAP, btr_cur_get_block(&cursor), rec,
   index, offsets, thr);
    
 }

其中:

  • 唯一键冲突时调用row_ins_scan_sec_index_for_duplicate函数给冲突记录加 next-key lock;
  • 唯一键不冲突但是使用的是 REPLACE 或者 ON DUPLICATE 语句时给下一条记录加 gap lock。

执行 update 时打印变量。

(lldb) p cursor.low_match
(ulint) $1 = 0
(lldb) p cursor.up_match
(ulint) $2 = 1
(lldb) p n_unique
(ulint) $3 = 2

因此:

  • 二级索引中包括唯一键 (age,name);
  • 由于不满足cursor.low_match >= n_unique || cursor.up_match >= n_unique条件因此唯一键不冲突;
  • SQL 是 update 语句,不是 REPLACE 或者 ON DUPLICATE 语句。

显然新插入的唯一键 3b 不冲突,因此唯一性检查阶段不加锁。


然后插入二级索引。

插入前调用lock_rec_insert_check_and_lock函数检查事务 ,主要是判断是否会发生插入意向锁等待。

其中判断 cursor 定位的下一个 rec_t 上当前有没有 gap 锁,有的话加上带有 gap 的插入意向的 X 锁(LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION )的显示锁,来等待其他 gap 锁释放,确保要插入的区间没有其他事务访问。

MySQL update 语句加锁分析

其中:

  • 调用page_rec_get_next_const函数获取下一条记录;
  • 调用lock_rec_get_first函数判断下一条记录上是否有锁;
  • 如果下一条记录没有锁,不加锁,也就是使用隐式锁,具体对于主键索引不处理,对于二级索引直接更新所在 page 的 max_trx_id;
  • 如果下一条记录有锁,进一步判断是否与插入意向锁冲突,如果冲突则该事务锁等待,否则同样不加锁。
 // 下一条记录上有锁,然后判断是否有锁冲突
 // 插入意向锁
 const ulint type_mode = LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION;

 // 判断是否与插入意向锁冲突,也就是检查是否有间隙锁
 const lock_t* wait_for = lock_rec_other_has_conflicting(
    type_mode, block, heap_no, trx);

 // 有冲突时进入锁等待
 if (wait_for != NULL) {

  RecLock rec_lock(thr, index, block, heap_no, type_mode);

  trx_mutex_enter(trx);
  
  // 如果与插入意向锁有冲突,创建一个插入意向锁,加到事务锁列表中去,插入等待队列中
  err = rec_lock.add_to_waitq(wait_for);

  trx_mutex_exit(trx);

 } else {
  err = DB_SUCCESS;
 }

由于 3b 的下一条记录是 3c,该记录上没有锁,因此插入二级索引 3b 使用隐式锁并更新所在 page 的 max_trx_id。

根据二级唯一键更新主键

update t_dupp set id=5 where age=2 and name='b';

理论上既改主键索引,也改二级索引。

测试

事务信息

--TRANSACTION 13227913, ACTIVE 2 sec
lock struct(s), heap size 11365 row lock(s), undo log entries 2
MySQL thread id 3616101, OS thread handle 139677278025472query id 63629508 localhost admin
TABLE LOCK table `test_zk`.`t_dupp` trx id 13227913 lock mode IX
RECORD LOCKS space id 2160 page no 4 n bits 72 index uk_age_name of table `test_zk`.`t_dupp` trx id 13227913 lock_mode X locks rec but not gap
Record lockheap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
 0: len 4; hex 80000002; asc     ;;
 1: len 1; hex 62; asc b;;
 2: len 4; hex 80000002; asc     ;;

RECORD LOCKS space id 2160 page no 3 n bits 72 index PRIMARY of table `test_zk`.`t_dupp` trx id 13227913 lock_mode X locks rec but not gap
Record lockheap no 3 PHYSICAL RECORD: n_fields 6; compact format; info bits 32
 0: len 4; hex 80000002; asc     ;;
 1: len 6; hex 000000c9d789; asc       ;;
 2: len 7; hex 260000002b0243; asc &   + C;;
 3: len 4; hex 80000002; asc     ;;
 4: len 1; hex 62; asc b;;
 5: len 4; hex 80000000; asc     ;;

RECORD LOCKS space id 2160 page no 4 n bits 72 index uk_age_name of table `test_zk`.`t_dupp` trx id 13227913 lock mode S
Record lockheap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 32
 0: len 4; hex 80000002; asc     ;;
 1: len 1; hex 62; asc b;;
 2: len 4; hex 80000002; asc     ;;

Record lockheap no 4 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 80000003; asc     ;;
 1: len 1; hex 63; asc c;;
 2: len 4; hex 80000003; asc     ;;

RECORD LOCKS space id 2160 page no 4 n bits 72 index uk_age_name of table `test_zk`.`t_dupp` trx id 13227913 lock mode S locks gap before rec
Record lockheap no 6 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 80000002; asc     ;;
 1: len 1; hex 62; asc b;;
 2: len 4; hex 80000005; asc     ;;

其中:

  • 二级唯一索引 X 型 record lock (2, ‘b’, 2)
  • 主键索引 X 型 record lock id=2
  • 二级唯一索引 S 型 next-key lock (2, ‘b’, 2)
  • 二级唯一索引 S 型 next-key lock (3, ‘c’, 3)
  • 二级唯一索引 S 型 record lock (2, ‘b’, 5)

注意这里出现了前几种测试场景中从未出现的 next-key lock,原因是这里出现了唯一键冲突,下面将详细介绍。


堆栈信息

根据二级唯一键加锁读取定位到 2b2 记录

MySQL update 语句加锁分析

由于更改主键因此需要回表锁定主键索引 id=2 记录

MySQL update 语句加锁分析

主键的更改操作被转换成 delete + insert,其中 delete 时由于已加锁因此不用重复加锁,insert 时由于唯一键不冲突因此使用隐式锁。

同样二级唯一键的更改操作也被转换成 delete + insert,delete 时加锁。

MySQL update 语句加锁分析

二级唯一键插入前唯一性检查加锁 2b2

MySQL update 语句加锁分析

二级唯一键插入前唯一性检查加锁 3c3

MySQL update 语句加锁分析

其中:

  • 更新主键后需要更新唯一键,但是唯一键不支持更新,因此首先删除唯一键;
  • 插入唯一键时唯一性检查加两组 S 型 next-key lock,具体是给 2b2 以及下一行 3c3 加锁。

分析

主键更新的函数是row_upd_clust_step ,其中调用row_upd_changes_ord_field_binary函数判断本次 update 是否更新了主键中的字段,判断条件与判断二级索引是否需要更新都是相同的,因此就不重复了。

 // 如果本次update是否更新了主键中的字段,调用 row_upd_clust_rec_by_insert
 if (row_upd_changes_ord_field_binary(index, node->update, thr,
          node->row, node->ext)) {

  /* Update causes an ordering field (ordering fields within
  the B-tree) of the clustered index record to change: perform
  the update by delete marking and inserting.

  // 如果update语句改变了主键索引定义中指明的那些列的值,那么走delete+insert
  err = row_upd_clust_rec_by_insert(
   flags, node, index, thr, referenced, &mtr);

  node->state = UPD_NODE_UPDATE_ALL_SEC;
 } else {
  // 如果没有更新排序字段,那么都是采用直接更新现有记录或复用delete-marked记录
  err = row_upd_clust_rec(
   flags, node, index, offsets, &heap, thr, &mtr);

  node->state = UPD_NODE_UPDATE_SOME_SEC;
 }

其中:

  • 如果 update 操作修改主键的排序字段,需要先删除后插入,即 delete + insert,对应根据主键更新普通字段或二级唯一键的场景;
  • 如果 update 操作仅修改主键的普通字段,直接更新现有记录或复用 delete-marked record,对应根据二级唯一键更新主键的场景。

update 操作修改主键的排序字段与普通字段分别对应是否需要更改二级索引:

  • UPD_NODE_UPDATE_ALL_SEC,因此更改主键的排序字段(n_uniq)时需要更改全部二级索引;
  • UPD_NODE_UPDATE_SOME_SEC,因此更改主键的普通字段时仅需要更改部分二级索引。

因此对于根据二级唯一键更新主键的场景,主键与二级唯一键都是 delete + insert


主键唯一性检查时由于新主键 5 没有唯一键冲突,因此不加锁。

插入主键时由于新主键的下一行 supremum 上没有锁,因此同样使用隐式锁。

MySQL update 语句加锁分析

更新主键后更新二级唯一键,同样走 delete + insert 的流程,但是与根据主键更新二级唯一键不同,由于插入的二级唯一键冲突,因此冲突检测加锁。

二级唯一键唯一性检查的函数是row_ins_scan_sec_index_for_duplicate,关键代码如下所示,注意其中循环加锁。

 do {
  const rec_t*  rec = btr_pcur_get_rec(&pcur);
  const buf_block_t* block = btr_pcur_get_block(&pcur);
  const ulint  lock_type = LOCK_ORDINARY;

    // 加锁
    if (allow_duplicates) {
      // X 型 next-key lock
   err = row_ins_set_exclusive_rec_lock(
    lock_type, block, rec, index, offsets, thr);
  } else {
      // S 型 next-key lock
   err = row_ins_set_shared_rec_lock(
    lock_type, block, rec, index, offsets, thr);
  }
  
  // 判断唯一键 key 是否重复,注意是先加锁后比较,cmp=0 表示重复
  cmp = cmp_dtuple_rec(entry, rec, offsets);

  // 唯一键重复,并且不允许重复
    // 和变量 allow_duplicates 的含义不同,if (!is_next && !index->allow_duplicates) 中的 index->allow_duplicates 表示唯一索引是否允许存在重复记录
  // 对于 MySQL 内部临时表的二级索引,index->allow_duplicates = true。
  // 对于其它表,index->allow_duplicates = false。
  if (cmp == 0 && !index->allow_duplicates) {
   
   // 判断冲突行是否已经删除,如果已经删除,则不冲突,返回 false, 如果不是删除的行,则冲突,返回 true
   if (row_ins_dupl_error_with_rec(rec, entry,
       index, offsets)) {
        
    // 报错唯一键冲突
    err = DB_DUPLICATE_KEY;
        
    goto end_scan;
   }
  } else {
   // ut_a 断言,表达式不为真时退出
   // 如果不重复或者重复但是允许重复时,执行下一行退出循环
   ut_a(cmp < 0 || index->allow_duplicates);
   goto end_scan;
  }
 // 扫描下一个记录,直到遇到第一个不同的记录
 } while (btr_pcur_move_to_next(&pcur, mtr));

其中:

  • 无论哪个事务隔离级别,insert on duplicate 或 replace into 检测到二级唯一键冲突时加 X 型 next-key lock;
  • 无论哪个事务隔离级别,insert 检测到二级唯一键冲突时加 S 型 next-key lock;
  • 如果冲突行已删除则认为不冲突,扫描下一行继续加锁然后判断发现不冲突最后退出循环。

因此,根据唯一键更新主键时由于更新了唯一键中的主键,将唯一键对更新操作转换成 delete + insert 操作。

插入新唯一键 2b5 时由于与设置为 delete-mark 的记录 2b2 唯一键冲突,因此扫描并给下一行 3c3 加锁,具体是 S 型 next-key lock。


插入时发现下一条记录上有锁,但是没有与插入意向锁冲突的锁,选择使用隐式锁。

MySQL update 语句加锁分析
image-20230926150623491

其中:

  • lock_rec_other_has_conflicting函数用于判断是否存在锁冲突;
  • 下一条记录上持有的锁 type_mode=34,检测是否与间隙锁冲突,对应 type_mode=2563,结果是没有冲突。

type_mode 是 mode, gap_mode 的组合,表示锁的模式,使用 32 位整型字段存储,具体存储方式见下图。

MySQL update 语句加锁分析

有关 type_mode 与锁冲突检测相关知识点待后续学习。

知识点

更新流程

更改主键索引的场景分为两种:

  • 更改排序字段,将 update 转换成 delete + insert;
  • 更改普通字段,直接更新现有记录或复用 delete-marked record。

更改二级索引时,都会将 update 转换成 delete + insert。

更改二级索引的场景同样分为两种:

  • 更改主键的排序字段(n_uniq)
  • 更改二级索引的排序字段

因此,更新操作中需要依次进行以下判断:

  • 判断是否需要修改数据,获取到需要更改的字段对应的值和字段号
  • 判断修改的字段中是否包含二级索引
  • 判断是否需要更改主键索引
  • 判断是否需要更改每个二级索引

加锁环节

通过调试,发现以下四个操作中都可能加锁:

  • 锁定读取记录
  • 插入唯一键前唯一性检查
  • 插入唯一键时检查是否与插入意向锁冲突
  • 删除数据

因此后续对于事务信息中的锁,分析前首先可以进行分类。

隐式锁

前文中提到,插入主键索引或二级索引时如果下一条记录没有锁或有锁但是与插入意向锁不冲突时使用隐式锁,其中:

  • 对于主键索引,由于记录上有隐藏字段 trx_id,因此插入时将行数据的 trx_id 设置为当前事务 ID;
  • 对于二级索引,由于记录上没有隐藏字段 trx_id,因此插入时需要更新所在 page 的 max_trx_id。


插入过程中通过使用隐式锁可以在不会产生冲突时降低锁竞争,但是如果会产生锁冲突时怎么办,因此引出以下两个问题:

  • 如何判断隐式锁是否存在
  • 如何将隐式锁转换成显式锁

具体将单独一篇文章中讲解。

重要函数

调试过程中发现以下几个重要函数:

  • row_search_mvcc函数,用于读取行记录,其中包含了处理一条记录的各种手段;
  • row_sel_get_clust_rec_for_mysql函数,用于回表;
  • row_mysql_store_col_in_innobase_format函数,用于转换行记录格式;
  • row_upd_changes_some_index_ord_field_binary函数,用于判断是否更新了指定索引;
  • sel_set_rec_lock函数,用于加锁;
  • lock_rec_add_to_queue函数,用于加锁。

结论

update 语句加锁分析前首先需要确认是更改主键索引还是二级索引,还是都更改。

具体主键与二级唯一键的更改操作其实很简单,如果更新了索引中的排序字段,就会将 update 转换成 delete + insert。

二级索引中只有排序字段,显然二级索引的 update 都会被转换成 delete + insert。


具体加锁主要关注以下两个阶段:

  • 插入唯一键前的唯一性检查
  • 插入唯一键时检查是否与插入意向锁冲突

其他阶段不是不用加锁,而是相对来说变量少。


对于二级唯一键,需要关注是否存在 delete-marked record。

原因是在显式 update 或 delete + insert 操作中,由于冲突行被标记为 delete-marked,因此二级唯一键的唯一性检查时都会给冲突行与下一行加锁。其中:

  • 对于 insert,加 S 型 next-key lock;
  • 对于 insert on duplicate 或 replace into,加 X 型 next-key lock。


对于唯一键都需要关注下一条记录是否与插入意向锁冲突。

原因是插入操作使用隐式锁,在检测到冲突时会主动给导致冲突的记录加锁,也就是将隐式锁转换成显式锁,因此会导致锁变多。

待办

  • MVCC
  • 是否回表判断条件
  • type_mode
  • 隐式锁
  • cursor.low_matchcursor.up_match

参考教程

  • Innodb到底是怎么加锁的
https://juejin.cn/post/7028435335382040589
  • MySQL:每次update一定会修改数据吗?
https://www.jianshu.com/p/22104333a4bb
  • MySQL RC级别下并发insert锁超时问题 – 源码分析
https://zhuanlan.zhihu.com/p/62246137
  • REPLACE语句死锁与官方修复剖析
https://zhuanlan.zhihu.com/p/527813412
  • Innodb 中的 Btree 实现 (一) · 引言 & insert 篇
https://zhuanlan.zhihu.com/p/594678689
https://cloud.tencent.com/developer/article/1745809

原文始发于微信公众号(丹柿小院):MySQL update 语句加锁分析

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/178462.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!