MySQL 频繁死锁?掌握这些日志分析方法轻松搞定

引言

MySQL 死锁是线上经常遇到的现象,但是死锁分析却并不总是件容易的事情,本文介绍 MySQL 死锁日志的分析方法,帮助研发从日志中快速提取有效信息,从而提高死锁原因分析的效率。

死锁介绍

触发条件

死锁的触发条件包括以下四个:

  • 互斥
  • 占有且等待
  • 不可抢占
  • 循环等待

如下图所示,两个事务加锁顺序不同会导致死锁

发生死锁后,只需破坏发生死锁的四个条件中的任意一个,就能够解除死锁状态。在数据库层面,有两种策略可用于打破死锁状态:

  • 被动策略:设置事务等待锁的超时时间,当事务锁等待超时后自动回滚。默认超时时间为 50 秒;
  • 主动策略:开启主动死锁检测,检测到死锁后回滚其中一个事务。默认处于开启状态。

其中默认采用的是第二种策略,即检测到死锁后立即回滚,以此解除死锁状态。所以,发生死锁时,业务可能会报错死锁,但不会报错锁等待超时。

死锁检测

死锁检测本质上是一个搜索问题,5.7 中使用深度优先算法实现,具体是判断锁等待关系图中是否有环。

innodb_deadlock_detect 参数用于控制是否开启死锁检测,该参数是 5.7.15 中引入,因此判断在此之前的版本无法关闭死锁检测。

mysql> select @@innodb_deadlock_detect;
+--------------------------+
| @@innodb_deadlock_detect |
+--------------------------+
|                        1 |
+--------------------------+
1 row in set (0.00 sec)

高并发场景下可以考虑关闭死锁检测,原因是如果锁等待队列很长,死锁检测成本高,会导致实例性能下降。但是前提是应用层面可以避免死锁,因此通常不建议关闭。

下面通过介绍一个死锁案例对死锁日志的格式与分析方法有一个感性认识。

死锁案例

日志

------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-04-14 08:07:05 0x7fb6d39a6700
*** (1) TRANSACTION:
TRANSACTION 13020605130, ACTIVE 25 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 33 lock struct(s), heap size 3520, 33 row lock(s), undo log entries 34
MySQL thread id 2343498932, OS thread handle 140424015394560, query id 28769967039 x.x.x.x xwms_rw updating
UPDATE stock_occupy
        SET update_time = NOW()
        ,update_user = 'WAPS'
        ,qty_out_occupy=qty_out_occupy + 12.0000
        WHERE map_area_id = 608
        AND goods_no='EMG4418433215231'
        AND owner_no='0'
        AND lot_no='-1'
        AND product_level='100'
            AND org_no = '10'
            AND distribute_no = '10'
            AND warehouse_no = '126'
            AND map_area_id = 608
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 127 page no 5255 n bits 272 index idx_map_goods_product_lot_owner of table `xwms`.`stock_occupy` trx id 13020605130 lock_mode X locks rec but not gap waiting
Record lock, heap no 53 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
 0: len 8; hex 8000000000000260; asc        `;;
 1: len 16; hex 454d4734343138343333323135323331; asc EMG4418433215231;;
 2: len 3; hex 313030; asc 100;;
 3: len 2; hex 2d31; asc -1;;
 4: len 1; hex 30; asc 0;;
 5: len 8; hex 8000000000042de4; asc       - ;;

*** (2) TRANSACTION:
TRANSACTION 13020606128, ACTIVE 10 sec starting index read
mysql tables in use 1, locked 1
10 lock struct(s), heap size 1136, 7 row lock(s), undo log entries 8
MySQL thread id 2343006037, OS thread handle 140423210886912, query id 28769967052 x.x.x.x xwms_rw updating
UPDATE stock_occupy
        SET update_time = NOW()
        ,update_user = 'WAPS'
        ,qty_out_occupy=qty_out_occupy + 11.0000
        WHERE map_area_id = 608
        AND goods_no='EMG4418442253742'
        AND owner_no='0'
        AND lot_no='-1'
        AND product_level='100'
            AND org_no = '10'
            AND distribute_no = '10'
            AND warehouse_no = '126'
            AND map_area_id = 608
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 127 page no 5255 n bits 272 index idx_map_goods_product_lot_owner of table `xwms`.`stock_occupy` trx id 13020606128 lock_mode X locks rec but not gap
Record lock, heap no 53 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
 0: len 8; hex 8000000000000260; asc        `;;
 1: len 16; hex 454d4734343138343333323135323331; asc EMG4418433215231;;
 2: len 3; hex 313030; asc 100;;
 3: len 2; hex 2d31; asc -1;;
 4: len 1; hex 30; asc 0;;
 5: len 8; hex 8000000000042de4; asc       - ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 127 page no 5276 n bits 240 index idx_map_goods_product_lot_owner of table `xwms`.`stock_occupy` trx id 13020606128 lock_mode X locks rec but not gap waiting
Record lock, heap no 38 PHYSICAL RECORD: n_fields 6; compact format; info bits 0
 0: len 8; hex 8000000000000260; asc        `;;
 1: len 16; hex 454d4734343138343432323533373432; asc EMG4418442253742;;
 2: len 3; hex 313030; asc 100;;
 3: len 2; hex 2d31; asc -1;;
 4: len 1; hex 30; asc 0;;
 5: len 8; hex 8000000000044335; asc       C5;;

*** WE ROLL BACK TRANSACTION (2)

其中:

  • 加锁索引相同,都是二级索引;
  • 两个事务中三个锁对应两个主键,包括 8000000000044335(279349)/ 8000000000042de4(273892);
  • binlog 中显示提交事务也就是事务 1 中先后 update 279349 与 273892,因此判断死锁原因是交叉更新。

表结构

 `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增id',
 `map_area_id` bigint(20) NOT NULL COMMENT '地图区域ID',
  `goods_no` varchar(50) NOT NULL COMMENT '商品编号',
 `product_level` varchar(50) NOT NULL COMMENT '商品等级',
  `lot_no` varchar(50) NOT NULL COMMENT '批次号',
  `owner_no` varchar(50) NOT NULL COMMENT '货主编号',
 PRIMARY KEY (`id`),
  UNIQUE KEY `idx_map_goods_product_lot_owner` (`map_area_id`,`goods_no`,`product_level`,`lot_no`,`owner_no`)

其中:

  • 加锁索引是二级联合唯一索引;
  • update 根据二级唯一索引更新非索引字段,因此执行时具体是原地更新主键索引,二级索引不变,且加锁类型是 X 型 record lock;
  • 综合以上信息,判断死锁原因是两个事务交叉更新同一张表的两行数据导致死锁。

下面介绍如何从死锁日志中获取有效信息,并分析其中最重要的信息-锁,包括锁的类型、不同类型锁的兼容性、常见加锁规则。

死锁分析方法

日志格式

简化后的死锁日志格式如下所示。

InnoDB: *** (1) TRANSACTION:
InnoDB: *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
InnoDB: *** (2) TRANSACTION:
InnoDB: *** (2) HOLDS THE LOCK(S):
InnoDB: *** (2) WAITING FOR THIS LOCK TO BE GRANTED:
InnoDB: *** WE ROLL BACK TRANSACTION (1)

其中主要信息包括:

  • 两个事务
  • 两条 SQL
  • 三部分锁信息

其中存在的问题包括:

  • 两个事务有等锁 SQL,没有可能存在的持锁 SQL;
  • 事务 1 缺少持锁类型,8.0 中已提供;
  • SQL 超长时自动截断;
  • 加锁行数据是十六进制,因此需要根据字段的数据类型转换成对应格式,比如十进制或字符串。

其中前两种信息的缺失直接导致死锁分析的难度增大,因此死锁原因分析通常需要反推来处理,也就是从等锁类型判断持锁类型。

缺少部分可以参考以下两种常规的分析方法:

  • binlog,可以获取提交事务中已执行的 SQL 以及可能存在的更新前的记录;
  • general log,可以获取提交事务与回滚事务中已执行的 SQL,包括已执行无更新的操作,比如删除不存在的记录。

锁信息

锁信息

在 MySQL 中,锁的粒度涵盖实例、表、行,其中后两种均可能引发死锁,本文假定均为行粒度,即行锁。

需要注意的是,行锁是给表的索引记录加锁,并且是给访问过的对象加锁。

死锁日志中与锁相关的信息包含:

  • 锁所属表,例如分区表与非分区表的加锁规则存在差异;

  • 锁所属索引,比如唯一键与非唯一键的加锁规则有所不同;

  • 锁类型,其中不同类型的锁其兼容性各异;

  • 锁定数据行,其中:

    • 不同行的加锁类型可能有所不同,比如右边界记录(supremum pseudo-record)的 next-key lock 无法退化;
    • 数据行是否标记删除可能对后续加锁产生影响,一个字节中的第六位表示是否标记删除(info bits),因而十进制 32 表示标记删除。比如在二级唯一索引的唯一性检查时,如果发现冲突行已标记删除,将会循环给下一行加锁,直至数据不再冲突。

当然,还有其他因素会影响加锁的类型,主要包括:

  • 数据库版本,例如在 5.7.26 中针对 replace / insert duplicate 语句的加锁进行了优化,在唯一键不冲突时不会加间隙锁;
  • 事务隔离级别,比如在 RC 中不存在间隙锁;

上述这些信息均可视为死锁案例的特征,其中锁类型是最为重要的特征

锁类型

锁类型(type_mode)主要涵盖以下三部分关键信息:

  • lock_mode:此部分用于表明锁的模式,常见的模式有 IS、IX、S、X、AUTO_INC 等。不同的锁模式在数据库操作中发挥着不同的作用,例如控制数据的访问权限与并发操作的协调性。
  • lock_type:它代表锁的粒度,主要有 RECORD 和 TABLE 两种,分别对应行锁与表锁。锁粒度的选择会影响到数据库并发操作的性能与数据一致性,行锁更精细地控制单个数据行的访问,而表锁则对整个表进行锁定。
  • rec_lock_type:专门针对行锁的类型进行定义,包含 record lock、gap lock、next-key lock、insert intention lock 等。其中:
    • gap lock:这是在事务隔离级别 RR(Repeatable Read)中,为有效解决幻读问题而引入的一种锁类型。它通过锁定数据间隙,防止在该间隙内插入新数据,从而避免幻读现象的发生。
    • insert intention lock:属于一种特殊的 gap lock,表达了插入操作的意向。当插入操作面临 gap lock 时,该锁用于表示等待状态,确保插入操作能够在合适的时机进行,同时维护数据的一致性。

以死锁日志中显示的锁类型 “lock_mode X locks rec but not gap waiting” 为例,对其各部分信息解读如下:

  • lock_mode:值为 X,表明当前锁的模式为排他锁(Exclusive Lock),持有该锁的事务独占对数据的访问权,其他事务无法同时获取相同资源的锁。
  • lock_type:值为 RECORD,意味着锁的粒度为行锁,即针对特定的数据行进行锁定。
  • rec_lock_type:值为 record lock,代表行锁的具体类型为记录锁,直接锁定特定的数据记录。
  • lock_status:值为 WAITING,说明当前锁的状态处于等待中,该事务尚未获取到所需的锁资源,正在等待其他事务释放相关锁。

需要注意的是,锁的状态主要分为两种:已获取到(GRANTED),表示事务成功获取到锁资源,可以对数据进行相应操作;等待中(WAITING),则如上述例子所示,事务正在等待获取锁,暂时无法执行相关操作。

死锁通常由两组锁等待构成,而锁等待现象发生在锁冲突的情况下。锁冲突的判定依据是锁兼容矩阵,接下来将详细介绍锁兼容矩阵。

锁兼容矩阵

不同类型行锁的兼容性见下表,其中第一行表示已有的锁,第一列表示要加的锁,❌ 表示锁冲突。

锁类型 record gap next-key insert intention
record
gap
next-key
insert intention

其中:

  • insert intention 不影响其他事务加任何类型的锁;
  • gap lock 只和 insert intention 冲突,用于防止其他事务在间隙中插入记录导致幻读,与其他锁不冲突;
  • 如果已有的锁是等待状态,要加的锁与该锁冲突,要加的锁同样会发生锁等待。

常见加锁规则

加锁场景:

  • 查询(数据定位),注意不是 MVCC,而是加锁读,包括根据二级索引回表给主键索引加锁;

  • 更新,下面是部分场景与对应加锁类型:

    • 为防止脏写,record lock;
    • 为防止幻读,gap lock;
    • 为防止唯一键冲突,next-key lock。

加锁类型

在数据库的锁机制中,主要存在以下两种加锁类型:

  • 显式锁:这是一种明确施加于数据对象上的锁,通过特定的指令或操作进行设置,以确保对相关数据的访问控制。
  • 隐式锁:例如在执行insertupdatedelete等语句时,如果当时不存在锁冲突,数据库不会立即添加显式锁。然而,在必要情况下,这些隐式锁会转换为显式锁,以此来维护数据的一致性和并发访问的正确性。

数据库加锁的基本单位是 next - key lock,但在部分特定场景下,这种锁会发生退化,具体如下:

  • 退化为 record lock(记录锁)的场景:当在唯一索引上进行等值查询时,next - key lock 会退化为 record lock。这是因为唯一索引确保了查询结果的唯一性,在这种情况下,仅需锁定特定的记录即可满足数据一致性要求,无需对记录之间的间隙进行锁定。
  • 退化为 gap lock(间隙锁)的场景:在非唯一索引的等值查询过程中,当向右遍历到第一个不满足等值条件的记录时,next - key lock 会退化为 gap lock。此时,为了防止在该间隙插入新数据导致幻读等问题,数据库会采用间隙锁来锁定这个数据间隙 。

不退化的场景

在数据库操作中,存在一些特定场景,锁的行为不会发生退化,具体如下:

  • supremum pseudo-record:在数据库索引结构中,supremum pseudo-record是一种特殊的虚拟记录,它在特定的索引操作和锁机制中扮演着独特角色,保证了索引边界检查和相关操作的正确性,维持锁的正常作用而不发生退化。
  • insert duplicate / replace 语句中根据唯一键定位数据:当执行insert duplicate或replace语句时,数据库会依据唯一键来定位数据。在这个过程中,锁的行为保持稳定,不会出现退化现象。这是因为唯一键的约束特性使得数据定位明确,相关的锁操作能够准确执行,确保数据一致性和并发操作的正确性。
  • 分区表(低版本 bug 场景):在低于5.7.23版本的数据库中,分区表存在一个特殊的问题。在唯一索引的等值查询时,当遍历到第一个不满足等值条件的记录时,会加锁next-key lock。这一异常加锁行为虽然是由于版本缺陷导致,但在这种场景下,锁的作用机制并未像正常情况那样发生退化,而是按照特定的错误逻辑进行锁定,影响着数据的并发访问和事务处理。

接下来,介绍一种在数据库操作中高频出现的锁冲突场景,即 插入时唯一键冲突加锁。值得注意的是,这种情况下的加锁类型与事务隔离级别无关,这也是在Read Committed(RC)隔离级别中少见的使用gap lock的场景。

当事务遇到与未提交事务的唯一键冲突时,会有如下处理机制:

  • 未提交事务:如果该事务中存在隐式锁,数据库会将其转换为显式锁,具体类型为X型record lock。这种转换是为了明确锁的状态和作用范围,便于其他事务识别和处理,维护数据一致性。
  • 冲突事务:此时会等待获取S型next-key lock。这是因为在唯一键冲突的情况下,冲突事务需要等待未提交事务完成相关操作并释放锁资源,S型next-key lock可以确保在等待期间,不会有其他事务对冲突数据进行不符合一致性要求的操作。

此外,在insert以及insert duplicate / replace语句中,进行唯一性检查时,加锁模式存在差异:

  • insert语句:在执行唯一性检查时,使用S型锁。这种锁类型允许其他事务对数据进行并发读取,但限制写入操作,确保在插入数据时,唯一性约束不会被破坏。
  • insert duplicate / replace语句:进行唯一性检查时,使用X型锁。X型锁是排他锁,在使用这类语句时,为了确保数据替换或重复插入操作的原子性和正确性,需要排他性地锁定相关数据,防止其他事务干扰 。

常见解决方案

常见的解决方案包括:

  • 修改事务隔离级别,其中:

    • 有效场景,比如更新不存在的场景时加锁 gap lock,从 RR 改为 RC 时不加锁;
    • 无效场景,比如插入唯一键前的唯一性检查依然加锁 gap lock;
  • 修改 SQL,比如将 insert duplicate 改写为 insert,唯一键不冲突时前者加锁 gap lock,后者不加锁;

  • 数据库版本升级,比如 5.7.26 中删除 insert duplicate / replace 唯一键不冲突时的加锁 gap lock;

日志分析工具

如下所示,自动分析死锁日志并将提取出来的特征显示在表格中,这里分析的是另一个死锁案例。

结论

MySQL 死锁日志中的主要信息包括两个事务、两条 SQL、三部分锁信息。

其中锁信息又包括表、索引、锁类型、数据行。

此外,还包括实例级别的信息,包括数据库版本与事务隔离级别。

上述因素都会影响加锁规则,因此提供日志分析工具用于信息的自动提取。

参考教程