• MySQL中更新不存在的记录导致死锁的问题
  • 发布于 2个月前
  • 466 热度
    0 评论
  • 怪咖豆
  • 0 粉丝 27 篇博客
  •   
引言
本文首先介绍一个更新不存在的记录导致死锁的案例,然后在测试过程中发现 update + insert 存在的记录时新增 gap lock,因此结合源码分析了锁分裂的原理。个人能力有限,欢迎批评指正。

现象
时间:2023-12-12 11:17:11
域名:mysql-cn-north.rds.jdcloud.com
现象:两个事务分别更新存在与不存在的记录后插入导致死锁

分析
死锁日志
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-12-11 17:55:14 7f6de3c0a700
*** (1) TRANSACTION:
TRANSACTION 67324219989, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1184, 4 row lock(s), undo log entries 2
MySQL thread id 79690810, OS thread handle 0x7f6de0b4a700, query id 200590503593 x.x.x.x tms_boss_rw update
INSERT INTO delivery_packing_package_info (waybill_code, packing_code, packing_name, packing_type, packing_type_name,
       packing_volume, volume_coefficient, packing_specification, packing_unit, packing_number,init_packing_number,
       packing_charge,package_code,remark,
       real_packing_number,
       package_auto_match_flag,
       create_user_code,
       create_user_name,
       create_time,
       update_time,
       yn)
      VALUES
        
        ('xxxx00590163185', ...
        '2023-12-11 17:55:13.622',
        '2023-12-11 17:55:13.622',
        1)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 341 page no 6195 n bits 480 index `idx_waybill_code` of table `tms_boss`.`delivery_packing_package_info` trx id 67324219989 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 386 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 15; hex 4a444b413030353930313639383937; asc xxxx00590169897;;
 1: len 8; hex 800000000005d001; asc         ;;

*** (2) TRANSACTION:
TRANSACTION 67324219985, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1184, 2 row lock(s), undo log entries 1
MySQL thread id 79691432, OS thread handle 0x7f6de3c0a700, query id 200590503599 x.x.x.x tms_boss_rw update
INSERT INTO delivery_packing_package_info (waybill_code, packing_code, packing_name, packing_type, packing_type_name,
       packing_volume, volume_coefficient, packing_specification, packing_unit, packing_number,init_packing_number,
       packing_charge,package_code,remark,
       real_packing_number,
       package_auto_match_flag,
       create_user_code,
       create_user_name,
       create_time,
       update_time,
       yn)
      VALUES
        
        ('xxxx00590163967', ...
        '2023-12-11 17:55:13.822',
        '2023-12-11 17:55:13.822',
        1)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 341 page no 6195 n bits 480 index `idx_waybill_code` of table `tms_boss`.`delivery_packing_package_info` trx id 67324219985 lock_mode X locks gap before rec
Record lock, heap no 386 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 15; hex 4a444b413030353930313639383937; asc xxxx00590169897;;
 1: len 8; hex 800000000005d001; asc         ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 341 page no 6195 n bits 480 index `idx_waybill_code` of table `tms_boss`.`delivery_packing_package_info` trx id 67324219985 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 386 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 15; hex 4a444b413030353930313639383937; asc xxxx00590169897;;
 1: len 8; hex 800000000005d001; asc         ;;

*** WE ROLL BACK TRANSACTION (2)
其中:
根据锁等待的关系判断是典型的 update 或 delete 不存在的行导致死锁的场景
表结构
mysql> show create table `tms_boss`.`delivery_packing_package_info` \G
*************************** 1. row ***************************
       Table: delivery_packing_package_info
Create Table: CREATE TABLE `delivery_packing_package_info` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `waybill_code` varchar(32) DEFAULT NULL COMMENT '运单号',
  `packing_code` varchar(32) DEFAULT NULL COMMENT '包装编号',
  `packing_name` varchar(32) DEFAULT NULL COMMENT '包装名称',
  `packing_type` varchar(32) DEFAULT NULL COMMENT '包装耗材类型',
  `packing_type_name` varchar(32) DEFAULT NULL COMMENT '包装耗材类型名称',
  `packing_volume` double DEFAULT NULL COMMENT '包装耗材体积',
  `volume_coefficient` double DEFAULT NULL COMMENT '包装耗材体积系数',
  `packing_specification` varchar(32) DEFAULT NULL COMMENT '包装规格',
  `packing_unit` varchar(32) DEFAULT NULL COMMENT '包装耗材单位',
  `packing_number` double DEFAULT NULL COMMENT '包装耗材数量',
  `init_packing_number` double DEFAULT NULL COMMENT '包装耗材录入数量',
  `real_packing_number` int(11) DEFAULT NULL COMMENT '真实耗材数',
  `packing_charge` double DEFAULT NULL COMMENT '包装耗材单个价格',
  `package_code` varchar(32) DEFAULT NULL COMMENT '包裹号/包裹编号',
  `remark` varchar(3000) DEFAULT NULL COMMENT '合打/单打备注',
  `package_auto_match_flag` int(11) DEFAULT NULL COMMENT '包裹号自动匹配flag。1-支持自动匹配 2-只支持扫描',
  `create_user_code` varchar(32) DEFAULT NULL COMMENT '创建人账号',
  `create_user_name` varchar(32) DEFAULT NULL COMMENT '创建人姓名',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `yn` int(11) DEFAULT NULL COMMENT '可用状态: 1 有效, 0无效',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `idx_waybill_code` (`waybill_code`) USING BTREE,
  KEY `idx_create_time` (`create_time`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=383557 DEFAULT CHARSET=utf8 COMMENT='包裹维度包装耗材信息表'
1 row in set (0.01 sec)
其中:
主键索引 + 非唯一二级索引
binlog
#231211 17:55:14 server id 1683069784  end_log_pos 307231023 CRC32 0x1f48ba3e   GTID    last_committed=0        sequence_number=0       rbr_only=no
SET @@SESSION.GTID_NEXT= 'da4da38e-d71b-11ec-ad8b-fa163ed04e5b:12597525219'/*!*/;
# at 307231023
#231211 17:55:14 server id 1683069784  end_log_pos 307231086 CRC32 0xcfeee4b9   Query   thread_id=79690810      exec_time=0     error_code=0
SET TIMESTAMP=1702288514/*!*/;
SET @@session.pseudo_thread_id=79690810/*!*/;
SET @@session.foreign_key_checks=1, @@session.sql_auto_is_null=0, @@session.unique_checks=1, @@session.autocommit=1/*!*/;
SET @@session.sql_mode=524288/*!*/;
SET @@session.auto_increment_increment=1, @@session.auto_increment_offset=1/*!*/;
/*!\C utf8 *//*!*/;
SET @@session.character_set_client=33,@@session.collation_connection=33,@@session.collation_server=33/*!*/;
SET @@session.lc_time_names=0/*!*/;
SET @@session.collation_database=DEFAULT/*!*/;
BEGIN
/*!*/;
# at 307231086
#231211 17:55:14 server id 1683069784  end_log_pos 307231264 CRC32 0xe9813982   Rows_query
# UPDATE delivery_packing_package_info
#         SET update_time = '2023-12-11 17:55:14.313', yn = 0
#         WHERE waybill_code = 'xxxx00590163185' AND yn = 1
# at 307231264

#231211 17:55:14 server id 1683069784  end_log_pos 307232524 CRC32 0x0cab5bb1   Rows_query
# INSERT INTO delivery_packing_package_info (waybill_code, packing_code, packing_name, packing_type, packing_type_name,
#        packing_volume, volume_coefficient, packing_specification, packing_unit, packing_number,init_packing_number,
#        packing_charge,package_code,remark,
#        real_packing_number,
#        package_auto_match_flag,
#        create_user_code,
#        create_user_name,
#        create_time,
#        update_time,
#        yn)
#       VALUES
#
#         ('xxxx00590163185', ...
#         '2023-12-11 17:55:13.622',
#         '2023-12-11 17:55:13.622',
#         1)
# at 307232524
其中:
.业务逻辑中一个事务先 update yn = 0 后 insert yn = 1,waybill_code 相同,表明是逻辑删除;
.binlog 中有 update 操作表明数据存在,因此不是两个事务均更新不存在的记录,这种场景也会产生死锁吗?
数据分布
mysql> select id,waybill_code,yn,create_time from `tms_boss`.`delivery_packing_package_info` where waybill_code<='xxxx00590169897' order by waybill_code desc limit 8;
+--------+-----------------+------+---------------------+
| id     | waybill_code    | yn   | create_time         |
+--------+-----------------+------+---------------------+
| 380934 | xxxx00590169897 |    1 | 2023-12-11 17:50:56 |
| 380933 | xxxx00590169897 |    1 | 2023-12-11 17:50:56 |
| 380930 | xxxx00590169897 |    0 | 2023-12-11 17:50:47 |
| 380929 | xxxx00590169897 |    0 | 2023-12-11 17:50:47 | <--
| 382335 | xxxx00590166721 |    1 | 2023-12-12 11:14:18 |
| 380963 | xxxx00590163185 |    1 | 2023-12-11 17:55:14 |
| 380944 | xxxx00590163185 |    0 | 2023-12-11 17:52:41 | <--
| 382673 | xxxx00590162878 |    1 | 2023-12-12 12:47:19 |
+--------+-----------------+------+---------------------+
8 rows in set (0.00 sec)
其中:
xxxx00590169897 相同的有 4 条记录
xxxx00590163185 0 逻辑删除
xxxx00590166721 死锁发生后新数据,因此与死锁无关
因此两个事务是在 xxxx00590163185 - xxxx00590169897 之间插入数据。
mysql> select count(*) from `tms_boss`.`delivery_packing_package_info` where waybill_code='xxxx00590163967';
+----------+
| count(*) |
+----------+
|        0 |
+----------+
1 row in set (0.00 sec)
其中:
.没有满足条件的记录,因此判断更新不存在的记录 xxxx00590163967 时加 gap lock。
.下面测试验证两个事务分别更新存在与不存在的记录后插入是否会导致死锁。

测试
准备数据
mysql> show create table tb \G
*************************** 1. row ***************************
       Table: tb
Create Table: CREATE TABLE `tb` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `a` int(11) NOT NULL DEFAULT '0',
  `b` int(11) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`),
  KEY `idx_a` (`a`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8
1 row in set (0.00 sec)

mysql> insert into tb(id,a,b) values(1,1,1),(5,5,5),(9,9,9);
Query OK, 3 rows affected (0.01 sec)
Records: 3  Duplicates: 0  Warnings: 0

mysql> select * from tb;
+----+---+---+
| id | a | b |
+----+---+---+
|  1 | 1 | 1 |
|  5 | 5 | 5 |
|  9 | 9 | 9 |
+----+---+---+
3 rows in set (0.00 sec)
其中:主键索引 + 非唯一二级索引
更新不存在的记录
操作流程见下表,其中两个事务分别更新存在与不存在的记录。
session 1 session 2
begin;

begin;
update tb set b=4 where a=5;

update tb set b=5 where a=6;
insert into tb(a,b) values(5,5);
Blocked


insert into tb(a,b) values(6,6);
Deadlock found

死锁复现

------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-12-12 17:14:16 0x7fdeb5059700
*** (1) TRANSACTION:
TRANSACTION 132925, ACTIVE 19 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 4 row lock(s), undo log entries 2
MySQL thread id 70, OS thread handle 140594496775936, query id 9070 127.0.0.1 admin update
insert into tb(a,b) values(5,5)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132925 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000009; asc     ;;
 1: len 4; hex 80000009; asc     ;;

*** (2) TRANSACTION:
TRANSACTION 132926, ACTIVE 11 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 71, OS thread handle 140594496509696, query id 9071 127.0.0.1 admin update
insert into tb(a,b) values(6,6)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132926 lock_mode X locks gap before rec
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000009; asc     ;;
 1: len 4; hex 80000009; asc     ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132926 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000009; asc     ;;
 1: len 4; hex 80000009; asc     ;;

*** WE ROLL BACK TRANSACTION (2)
其中:
.事务 2 更新不存在的记录后插入时触发死锁后回滚
一个更新不存在的记录会导致死锁,那么两个更新均存在的记录会导致死锁吗?

更新存在的记录
先插入要更新的记录
mysql> insert into tb(id,a,b) values(6,6,6);
Query OK, 1 row affected (0.00 sec)

mysql> select * from tb;
+----+---+---+
| id | a | b |
+----+---+---+
|  1 | 1 | 1 |
|  5 | 5 | 5 |
|  6 | 6 | 6 |
|  9 | 9 | 9 |
+----+---+---+
4 rows in set (0.00 sec)
操作流程见下表,其中两个事务均更新存在的记录。
session 1 session 2
begin;

begin;
update tb set b=4 where a=5;

update tb set b=5 where a=6;
insert into tb(a,b) values(5,5);
Blocked


insert into tb(a,b) values(6,6);
Query OK, 1 row affected (0.00 sec)
没有发生死锁,而是发生锁等待。
查看事务信息。
---TRANSACTION 132938, ACTIVE 16 sec
4 lock struct(s), heap size 1136, 4 row lock(s), undo log entries 2
MySQL thread id 71, OS thread handle 140594496509696, query id 9092 127.0.0.1 admin
TABLE LOCK table `test_zk`.`tb` trx id 132938 lock mode IX
RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132938 lock_mode X
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000006; asc     ;;
 1: len 4; hex 80000006; asc     ;;

RECORD LOCKS space id 480 page no 3 n bits 80 index PRIMARY of table `test_zk`.`tb` trx id 132938 lock_mode X locks rec but not gap
Record lock, heap no 5 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000006; asc     ;;
 1: len 6; hex 00000002074a; asc      J;;
 2: len 7; hex 7000000024030e; asc p   $  ;;
 3: len 4; hex 80000006; asc     ;;
 4: len 4; hex 80000005; asc     ;;

RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132938 lock_mode X locks gap before rec
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000009; asc     ;;
 1: len 4; hex 80000009; asc     ;;

Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000006; asc     ;;
 1: len 4; hex 80000014; asc     ;;

---TRANSACTION 132937, ACTIVE 25 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 4 row lock(s), undo log entries 2
MySQL thread id 70, OS thread handle 140594496775936, query id 9091 127.0.0.1 admin update
insert into tb(a,b) values(5,5)
Trx read view will not see trx with id >= 132937, sees < 132937
------- TRX HAS BEEN WAITING 9 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132937 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000006; asc     ;;
 1: len 4; hex 80000006; asc     ;;

------------------
TABLE LOCK table `test_zk`.`tb` trx id 132937 lock mode IX
RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132937 lock_mode X
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000005; asc     ;;
 1: len 4; hex 80000005; asc     ;;

RECORD LOCKS space id 480 page no 3 n bits 80 index PRIMARY of table `test_zk`.`tb` trx id 132937 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000005; asc     ;;
 1: len 6; hex 000000020749; asc      I;;
 2: len 7; hex 6f000000292dc9; asc o   )- ;;
 3: len 4; hex 80000005; asc     ;;
 4: len 4; hex 80000004; asc     ;;

RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132937 lock_mode X locks gap before rec
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000006; asc     ;;
 1: len 4; hex 80000006; asc     ;;

RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132937 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000006; asc     ;;
 1: len 4; hex 80000006; asc     ;;
其中:
事务 2 sql 1 update 持有三组锁,包括非唯一索引 next-key lock (5,6] + 非唯一索引 gap lock (6,9) + 主键索引 record lock;
事务 1 sql 2 insert 5,但是事务 2 持有非唯一索引 gap lock (5,6) ,因此锁等待;
事务 2 sql 2 insert 6 新增非唯一索引 gap lock ((6,6), (6,14))。
因此问题是新增 gap lock 的原因是什么?

堆栈
单独执行 update + insert。
update tb set b=5 where a=6;
insert into tb(a,b) values(6,6);
给函数lock_rec_add_to_queue设置断点,执行 insert 语句时查看堆栈。

其中:
.row_ins_sec_index_entry 函数,用于插入二级索引;
.lock_rec_inherit_to_gap_if_gap_lock 函数,用于锁分裂。
下面根据源码分析锁分裂的原理。

原理
锁分裂
lock_rec_inherit_to_gap_if_gap_lock 函数的主体代码如下所示。
static
void
lock_rec_inherit_to_gap_if_gap_lock(
  /*================================*/
 const buf_block_t* block,  /*!< in: buffer block */
 ulint   heir_heap_no, /*!< in: heap_no of
      record which inherits */
 ulint   heap_no) /*!< in: heap_no of record
      from which inherited;
      does NOT reset the locks
      on this record */
{
 lock_t* lock;
  ...
 // 堆代码 duidaima.com
 // 遍历记录上的所有锁
 for (lock = lock_rec_get_first(lock_sys->rec_hash, block, heap_no);
      lock != NULL;
      lock = lock_rec_get_next(heap_no, lock)) {
  
      // 如果不是插入意向锁
      if (!lock_rec_get_insert_intention(lock)
          && (heap_no == PAGE_HEAP_NO_SUPREMUM
        // 是LOCK_GAP或者NEXT-KEY LOCK(没有设置LOCK_REC_NOT_GAP标记)
        || !lock_rec_get_rec_not_gap(lock))) {

        // 给事务增加一个新的锁对象,锁的类型为LOCK_REC | LOCK_GAP
        // 所有符合条件的会话都继承了这个新的GAP,避免之前的GAP锁失效
        lock_rec_add_to_queue(
          LOCK_REC | LOCK_GAP | lock_get_mode(lock),
          block, heir_heap_no, lock->index,
          lock->trx, FALSE);
  }
 }
  ...
}
其中:
.lock_rec_inherit_to_gap_if_gap_lock 函数传参中有两个 heap_no,包括 heir_heap_no 与 heap_no,分别对应新插入行与下一行;
.for 循环调用 lock_rec_get_next 函数获取下一行记录上的所有锁;
.满足不是插入意向锁也没有设置 LOCK_REC_NOT_GAP 标记条件时发生锁分裂;
.调用 lock_rec_add_to_queue 函数给满足条件的事务都继承新的 gap lock,避免之前的 gap lock 失效。
因此如果插入记录的间隙存在 gap lock,将这个 gap lock 分裂为两个 gap lock。假设 (1,5) 之间有间隙锁,插入记录 3,函数中会遍历记录 5 上的所有锁,如果有间隙锁,就新增间隙锁,范围是 (1,3)。

lock_update_insert 函数中调用 lock_rec_inherit_to_gap_if_gap_lock 函数的主体代码如下所示。
/*************************************************************//**
Updates the lock table when a new user record is inserted. */
void
lock_update_insert(
/*===============*/
 const buf_block_t* block, /*!< in: buffer block containing rec */
 const rec_t*  rec) /*!< in: the inserted record */
{
 ulint receiver_heap_no;
 ulint donator_heap_no;
 
 // block->frame 中保存数据页的真实内容
 ut_ad(block->frame == page_align(rec));

 /* Inherit the gap-locking locks for rec, in gap mode, from the next
 record */
 
 // 获取 heap_no
  ...
  receiver_heap_no = rec_get_heap_no_new(rec);
  donator_heap_no = rec_get_heap_no_new(
    page_rec_get_next_low(rec, TRUE));
  ...

 // heir_heap_no=receiver_heap_no,heap_no=donator_heap_no
 lock_rec_inherit_to_gap_if_gap_lock(
  block, receiver_heap_no, donator_heap_no);
}
其中:
.首先从 Record Header 中解析获取到新插入行与下一行的 heap_no;
.然后调用 lock_rec_inherit_to_gap_if_gap_lock 函数,其中传参 heir_heap_no=receiver_heap_no,heap_no=donator_heap_no。

是否调用锁分裂函数
乐观插入 btr_cur_optimistic_insert 与悲观插入 btr_cur_pessimistic_insert 函数中均判断是否调用 lock_update_insert 函数。
以 btr_cur_optimistic_insert 函数为例说明。
  if (!(flags & BTR_NO_LOCKING_FLAG) && inherit) {

    lock_update_insert(block, *rec);
  }
其中:
inherit = 1 时调用 lock_update_insert 函数;
lock_update_insert 函数有两个入参,包括 block 与 *rec。
block 变量通过 cursor 获取,原因是 tree cursor 中 page cursor 中保存 block。
  // 通过 cursor 获取 block
  block = btr_cur_get_block(cursor);
  // buf_block_t::frame 中保存 page
  page = buf_block_get_frame(block);
  index = cursor->index;
inherit 变量在初始化后修改。
  ibool  inherit = TRUE;

  err = lock_rec_insert_check_and_lock(
    flags, rec, btr_cur_get_block(cursor),
    index, thr, mtr, inherit);
其中:
.inherit 初始化为 1;
.lock_rec_insert_check_and_lock 函数中判断并修改 inherit 变量。

lock_rec_insert_check_and_lock 函数的主体代码如下所示:
dberr_t
lock_rec_insert_check_and_lock(...){
  ...
 ibool  inherit_in = *inherit;
 trx_t*  trx = thr_get_trx(thr);

 // 获取下一条记录
 const rec_t* next_rec = page_rec_get_next_const(rec);
 ulint  heap_no = page_rec_get_heap_no(next_rec);

 // 判断下一条记录上是否有锁
 lock = lock_rec_get_first(lock_sys->rec_hash, block, heap_no);

 // lock_rec_get_first返回 NULL 表示下一个记录上没有锁,因此使用隐式锁
 if (lock == NULL) {
    
  if (inherit_in && !dict_index_is_clust(index)) {
   /* Update the page max trx id field */
   // 直接更新二级索引trx id
   // 更新页的最大事务ID
   page_update_max_trx_id(block,
            buf_block_get_page_zip(block),
            trx->id, mtr);
  }

  // 不需要锁分裂
  *inherit = FALSE;

  return(DB_SUCCESS);
 }

 // 可能需要锁分裂
 *inherit = TRUE;

 /* If another transaction has an explicit lock request which locks
 the gap, waiting or granted, on the successor, the insert has to wait.*/

 // 下一条记录上有锁,然后判断是否有锁冲突
 // 插入意向锁
 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);

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

 } else {
  err = DB_SUCCESS;
 }

 switch (err) {
 case DB_SUCCESS_LOCKED_REC:
  err = DB_SUCCESS;
  /* fall through */
 case DB_SUCCESS:
  if (!inherit_in || dict_index_is_clust(index)) {
   break;
  }

  /* Update the page max trx id field */
  // 如果没有冲突,直接更新页的最大事务ID,然后返回成功
  page_update_max_trx_id(
   block, buf_block_get_page_zip(block), trx->id, mtr);
 default:
  /* We only care about the two return values. */
  break;
 }
  ...
}
其中:
如果下一条记录上没有锁,insert 语句使用隐式锁;
如果下一条记录上有锁,判断是否与插入意向锁冲突。其中如果已加锁和待加锁是同一个事务,认为不冲突;
如果冲突,插入锁等待队列,也就是插入意向锁等待,否则同样使用隐式锁。具体隐式锁的实现与索引类型有关;
对于主键索引,将插入的行数据的 trx_id 设置为当前事务 id,对于二级索引,需要更新所在 page 的 max_trx_id;
如果下一个记录上没有锁,认为不需要锁分裂,将 inherit 变量从 1 改为 0;
lock_rec_insert_check_and_lock 函数中一方面判断是否发生插入意向锁等待,另一方面判断是否发生锁分裂。
因此:
如果插入数据行的下一条记录上没有锁或有锁但是与插入意向锁不冲突,均使用隐式锁;
如果插入数据行的下一条记录上没有锁,不需要锁分裂,否则就需要调用 lock_rec_inherit_to_gap_if_gap_lock 函数,其中判断如果有 gap lock,就将其分裂为两个 gap lock;

同一个事务持有 gap lock 的前提下插入数据,会发生锁分裂,如果是其他事务持有 gap lock,会发生插入意向锁等待。


heap_no
前文中 update 持有间隙锁 ((6,6), (9,9)),insert 6 时新增 gap lock ((6,6), (6,14))。
事务信息中显示新增的 gap lock 对应 heap no 6。
RECORD LOCKS space id 480 page no 4 n bits 72 index idx_a of table `test_zk`.`tb` trx id 132938 lock_mode X locks gap before rec
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000009; asc     ;;
 1: len 4; hex 80000009; asc     ;;

Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
 0: len 4; hex 80000006; asc     ;;
 1: len 4; hex 80000014; asc     ;;
测试过程中数据插入的顺序如下所示。
insert into tb(id,a,b) values(1,1,1),(5,5,5),(9,9,9);
insert into tb(id,a,b) values(6,6,6);
insert into tb(a,b) values(6,6); // (id,a,b)=(14,6,6)
idx_a 索引各记录的 heap_no 值如下所示。
infimum:0
supremum:1
a = 1:2(id = 1)
a = 5:3(id = 5)
a = 9:4(id = 9)
a = 6:5(id = 6)
a = 6:6(id = 14)
显然 heap_no 的顺序与索引值的顺序不同,那么什么是 heap 和 heap_no?

参考《MySQL 运维内参》:

heap_no 保存在 Record Header 中,占用 13 比特,表示当前记录在页面堆中的相对位置。


堆作为一种数据结构,常用于快速找出一个集合中的最小值 / 最大值。那么,什么是 页面堆 呢?个人理解,B+ 树用于组织数据页,查询效率高,页面堆用于组织数据行,插入性能高。

参考 chatgpt:
页面堆的工作原理:当一个新记录被插入到页中时,它会被分配一个heap_no。这个编号是该页中记录的唯一标识,并且会跟随记录,即使在行数据由于更新变得更大或更小而在页中移动时也是如此;
heap_no并不是行的物理位置,它只是一个逻辑顺序编号。它代表了每条记录在页内部的相对顺序,或者可以被视为指向记录的指针;
InnoDB 使用页面堆来快速定位记录,特别是在没有主键或其他二级索引可用的情况下进行全表扫描时;
页面堆还允许 InnoDB 有效地进行垃圾收集和页重新组织,以解决由于频繁更新和删除操作导致的页面碎片化。
Page Header 中有以下几个字段:
PAGE_HEAP_TOP,占用 2 字节,表示还未使用的空间最小地址,也就是说从该地址之后就是Free Space;
PAGE_FREE,占用 2 字节,表示第一个已经标记为删除的记录地址,记录被 purge 线程彻底删除后会放到这个链表头上,delete-marked 的记录不在这里;
PAGE_N_HEAP,占用 2 字节,表示本页中的记录的数量(包括最小和最大记录以及标记为删除的记录);
PAGE_N_RECS,占用 2 字节,表示该页中记录的数量(不包括最小和最大记录以及被标记为删除的记录);
PAGE_MAX_TRX_ID,占用 8 字节,表示修改当前页的最大事务 ID,该值仅在二级索引中定义。
插入记录过程中分配空间时:
如果 PAGE_FREE 链表中的头记录的空间大于需要插入的记录的空间,就复用这块空间(包括 heap_no);
否则从 PAGE_HEAP_TOP 分配空间,如果都没有空间,返回空。

如下是表中行格式示意图,由 Extra Info 和 User Records 两部分组成。

其中:
Extra Info 中包括 Record Header 等;
Record Header 中包括 heap_no、next_record、delete_mask 等。
如下是多条数据行格式示意图。

其中:

每个页有两条伪记录,分别为最小记录与最大记录,单独保存在 Infimum + Supremum Record,对应 heap_no 分别为 0 和 1,表示位置最靠前;
每条数据的 next_record 表示从当前记录的真实数据到下一条记录的真实数据的地址偏移量,下一条记录指得并不是按照插入顺序的下一条记录,而是按照主键值由小到大的顺序的下一条记录,图中用箭头表示指针。其中最大记录的 next_record 的值为 0。
因此:
heap_no 不是主键的顺序,原因是主键可以不指定也可以乱序指定;
heap_no 不是插入的数据,原因是如果记录被删除或移动,heap_no 可以复用;

heap_no 尽管只是一个逻辑顺序编号,但是每条记录按照主键从小到大的顺序组成一个有序单向链表。


结论
两个事务均更新不存在的记录时会导致死锁,本案例显示即使一个事务更新不存在的记录也会导致死锁。死锁原因是先更新后插入同一个 code,其中事务 2 sql1 更新不存在的记录因此持有间隙锁,同一个间隙中有两组间隙锁导致死锁。测试显示两个事务均更新存在的记录不会导致死锁,而是导致锁等待,但是事务信息显示 update + insert 会导致增加一个 gap lock。

原因是发生了锁分裂,如果插入的记录的间隙存在 gap lock,此时此 gap lock 需分裂为两个 gap lock。

相关处理逻辑总结如下:
如果插入数据行的下一个记录上没有锁,认为不需要锁分裂;
如果插入数据行的下一条记录上没有锁或有锁但是与插入意向锁不冲突,均使用隐式锁;
如果插入数据行的下一条记录上有锁,认为可能锁分裂,需要调用锁分裂函数;
锁分裂函数中判断如果下一条记录上有 gap lock,就将其分裂为两个 gap lock;
同一个事务持有 gap lock 的前提下插入数据,会发生锁分裂,如果是其他事务持有 gap lock,会发生插入意向锁等待。

B+ 树用于组织数据页,查询效率高,页面堆用于组织数据行,插入性能高。heap_no 不是主键的顺序,也不是插入的数据,只是一个逻辑顺序编号,但是每条记录按照主键从小到大的顺序组成一个有序单向链表。
用户评论