从线上死锁分析到 Next-Key Lock 理解

政采云技术团队.png

多典.png

背景

最近真线岛端环境发生死锁场景。本文通过实战一个死锁问题 ,从发现、排查,到思考、解决的过程中,以简单的方式 理解 MySQL 中 Next-Key Lock

本次死锁排查至解决的思路分析 ,也可以复制到今后处理死锁问题中。

线上问题排查回溯

线上业务发生处理异常,通过排查发现是死锁导致。

该业务比较简单,单业务应不会出现死锁问题。

出现死锁问题时应该想到死锁出现的4个必要条件

  1. 互斥条件:一个资源每次只能被一个进程使用;
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放;
  3. 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺;
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系;

开发 角度 反推 复现场景

  1. 出现多个事务线程。
  2. 不同线程都需要持有相同的锁。
  3. 持有锁时不会释放会等待。
  4. 获取的锁 顺序不是一致的。

获取的锁 顺序不是一致的,这点是非常重要的 ,这样才会造成循环等待条件。因为如果都是竞争相同的一个资源是不会死锁的,无非是一个时间内只有一个成功,其他的失败而已。

但如果一个线程 已经持有一个 ,再要获取另外一个,恰巧另一个线程 已经持有第一个线程需要获取的,再要获取第一线程持有的,这样一个相互循环等待 ,就产生死锁。

简化场景 :

A线程 :持有 temp1 表中 某个 Next-Key Lock (后面简称temp1表的锁),需要去获取 temp2 表 某个 Next-Key Lock (后面简称temp2 表的锁)。

B线程: 持有 temp2 表的那个 Next-Key Lock ,需要去获取 temp1 表的那个 Next-Key Lock

Tips: MySQL 的锁单位是 Next-Key Lock ,也就是 行锁+间隙锁。间隙锁和间隙锁之间无冲突,只有行锁和行锁之间有冲突

死锁日志

找 dba 查看死锁日志。分析死锁日志,查询对应具体业务的 A 线程 和 B 线程 ,分析死锁产生的具体表。要注意的是死锁日志并不能完成记录死锁过程,只是 MySQL 检测到死锁的那一瞬间,记录那一瞬间触发的 SQL,并不能记录到所有对应持有锁的 SQL

以上面的 简化场景为例,死锁日志并不能记录 A线程持有了 temp1表 ,B 线程 持有 temp2 表。 只会记录死锁触发瞬间,A 需要 temp2,B 需要 temp1。 但是通过那一瞬间的日志,通过死锁必要条件,反推也可以得出。A 肯定是拥有了 temp1 表,B拥有了 temp2 表。但具体的持有锁的 SQL,需要通过业务反查代码得出 。

从 MySQL 死锁日志中 主要关注

  1. 对应是哪两个业务操作,
  2. 那一瞬间哪两个表的 SQL 操作发生了死锁。

再通过业务操作流程,反查代码 ,找到一个每个业务 是通过什么 SQL 导致了循环等待。导致死锁

日志分析

通过死锁日志的主要关键可以看到造成死锁那一瞬间的 SQL 是什么,对应的表是什么。 通过业务上的 TraceId(链路监控) 去查找日志可以看到这个两个线程分别在执行什么业务逻辑。

-- temp1
update temp1 set update_at = now(),process_def_key='a_workflow',task_id='549620565',is_active=3
where process_def_key = 'a_workflow' and task_id = '549620565';

-- temp2
insert into temp2( form_id,file_id,`name`,file_type, `size`,creater_id, create_at )
values
( 1000001510745,'1049840804692.jpg','xxxxx.jpg','demandList',241528,2126000000059295,'2022-03-11 11:44:22' );

目前分析知道的 资源竞争 : temp1(任务状态表)、temp2 (附件表)。

a5aefacebe28bb5308e9de4a058c8ff8 业务持有 temp2,需要获取 temp1

439d29429ab0de0c4b6f61d3bcefab3b 业务持有 temp1,需要获取 temp2

现在只查看到 两个 线程,需要获取的资源的 SQL。我们还需要获取到 ,两个线程分别 已经获取到资源的SQL 。那就要通过日志 + 代码反查业务。

代码查找

上面已经 知道 是 temp1、temp2 这两个表。

那我们找代码的时候只要找这两个流程中,对这个两个表 进行 DML 操作的步骤即可。

通过代码查询出业务逻辑:

通过代码排查发现是两个毫无关系的业务,完全不会操作相同的行 ,互不干扰。只是都操作相同的两个通用的表,也没有共同的行,那就没有行锁冲突,为什么会死锁呢?

分析死锁

打破死锁的方式就是解决掉死锁的上面必要 4 个条件中的随便 1 个。

temp2 表由于 是在两个业务的开头和结尾,不太影响中间的操作。优先分析处理掉该 资源竞争,不让它产生互斥条件就可以避免死锁。

深入分析 死锁 SQL

捞出 purchaseplan_mul_files 操作

-- temp2 表结构
CREATE TABLE `temp2` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `form_id` bigint(20) NOT NULL COMMENT '业务id',
  `file_id` varchar(200) NOT NULL COMMENT '附件id',
  `name` varchar(255) NOT NULL COMMENT '附件名称',
  `type` varchar(128) DEFAULT NULL COMMENT '附件类型',
  `file_type` varchar(30) DEFAULT '' COMMENT '附件业务类型',
  `size` bigint(20) NOT NULL COMMENT '附件大小',
  `creater_id` bigint(20) DEFAULT NULL COMMENT '创建者',
  `create_name` varchar(255) DEFAULT NULL COMMENT '创建者',
  `create_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_form_id` (`form_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=129473 DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT COMMENT='附件表';


--  业务A  temp2
delete from temp2 where form_id = 1000001510741 and file_type = 'xxxxx';
--  业务B  temp2
insert into temp2( form_id,file_id,`name`,file_type, `size`,creater_id, create_at )
values
( 1000001510745,'1049840804692.jpg','xxxxx.jpg','demandList',241528,2126000000059295,'2022-03-11 11:44:22' );

可以看到就是一个删除和插入。看起来 毫无瓜葛,其实暗藏玄机

Next-Key Lock

以下分析基于可重复读的事务隔离级别 (这个死锁问题发生在岛端,岛端是 RR ,云真线是 RC)

前面说了加锁的基本单位是 Next-Key Lock

先简单 了解一下 Next-Key Lock

  1. 这个锁都是作用在索引上面的,不论是唯一索引、主键索引还是普通索引。

  2. 间隙锁和行锁合称 Next-Key Lock ,所以每个 Next-Key Lock 是前开后闭区间 (-∞,+suprenum]。

Tips

行锁:行锁只能锁住指定行 ,跟行锁有冲突关系的是“另外一个行锁”。

间隙锁:锁的就是两个值之间的空隙,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作

CREATE TABLE `test_table` (
  `id` int(11) NOT NULL,
  `name` int(11) DEFAULT NULL,
  `num` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_num` (`num`)
) ENGINE=InnoDB;

insert into test_table values
(0,'0',0),(5,'5',5),(10,'10',10),(15,'15',15),(20,'20',20),(25,'25',25);

我们的表 test_table 初始化以后,如果用 select * from test_table for update 要把整个表所有记录锁起来,就形成了7个 Next-Key Lock

分别是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +suprenum]。

因为+∞是开区间。实现上,InnoDB 给每个索引加了一个不存在的最大值 suprenum ,这样才符合我们前面说的“都是前开后闭区间”。

上锁过程 ☆☆☆☆

上面简单的描述了一下 Next-Key Lock 。回到死锁问题上,要分析为什么会死锁就要看为什么需要等待获取锁,也就是要分析它的上锁过程。

先看 删除 ,form_id 其实是一个业务上 自增 的序列号。发现删除 1000001510741 对应数据 时 最新的form_id 就是该记录值

delete from temp2 where form_id = 1000001510741 and file_type = 'xxxxx';

因为 form_id 是有索引,那么其实就是对form_id 索引上 Next-Key Lock

  1. 因为加锁的基本单位是 Next-Key Lock Next-Key Lock 是前开后闭区间。Next-Key Lock :(前一个form_id,1000001510741]
  2. 但是因为 form_id是非唯一索引,所以还需要向后扫描到第一个不符合的再返回,因为 目前form_id是最大的,那后面就是正无穷 。Next-Key Lock :(1000001510741, +suprenum]。
  3. 因为 向后扫描 到的第一个不是的值,也就是说无需要上行锁,便退化成 , (1000001510741, +suprenum) 间隙锁

所以 这一句 DELETE 语句并不是 只锁住了 1000001510741 这一行,而是锁住了 (前一个formId,1000001510741] , (1000001510741, +suprenum)

(Tips:suprenum 上面说了是 MySQL 内的自己的最大一个值,也就为了代替 正无穷大+∞ )

我们再看 插入

insert into temp2( form_id,file_id,`name`,file_type, `size`,creater_id, create_at )
values
( 1000001510745,'1049840804692.jpg','xxxxx.jpg','demandList',241528,2126000000059295,'2022-03-11 11:44:22' );

发现没有,需要插入的 form_id 正是在 (1000001510741, +suprenum) 之间

所以说 B业务 和 A 业务上 在 MySQL 锁上是有瓜葛的。 B业务需要 A业务释放 temp2 中 (1000001510741, +suprenum) 这段间隙的锁。

(以上每段代表间隙,重叠部分代表具体的行)

反向猜想 temp1 表也是如此, A业务也是需要 B业务释放 temp1 中的 Next-Key Lock 。从而产生循环等待 ,也照成死锁。

实验 (建议实操加深印象)

我们可以在Navicat中试验一下 ,看看 这样 DELETE 是否会阻塞 INSERT

还是上面的 test_table 表

注意 为了 保证实验的 条件相同 不被其他的干扰,在 执行前动作前先重建 test_table 表的 idx_num 索引

新增 SQL 窗口 ,线程1

BEGIN ;
DELETE FROM test_table WHERE num = 18;
-- 延迟 提交事务
SELECT sleep(10) FROM test_table limit 1;
COMMIT;

新增 SQL 窗口 ,线程2

BEGIN ;
INSERT INTO test_table (name,num) VALUES('xxxxx',21)
COMMIT;

测试 (18, +suprenum) 间隙锁:

先执行线程 1,后执行线程 2 。 查看线程 2 是否 被 BLOCK 住,直到 线程 1 执行完才执行。

结果和我们猜想的一致 。INSERT 一直等着 DELETE 事务的提交。也就证明了 (1000001510741, +suprenum) 上有间隙锁

测试 (8,18] Next-Key Lock

同理 我们也可以验证是不是 (前一个form_id,1000001510741] 也上锁了, 只要往前插入一个

把线程2 换为

BEGIN ;
INSERT INTO test_table (name,num) VALUES('xxxxx',9)
COMMIT;

一样发生了等待

解决死锁

上面已经分析了 照成死锁的原因就是 因为 DELETE 语句导致,那我们要怎么样删除呢?

刚在分析上锁的过程中,上间隙锁的原因 是因为他、它是非唯一索引 ,需要向后继续扫描到第一个不符合的。那如果它是唯一索引呢?

我们来分析一下通过唯一索引删除

delete from temp2 where id = 4567;
  1. 加锁的基本单位是 Next-Key Lock Next-Key Lock 是前开后闭区间。Next-Key Lock : (前一个id,4567]
  2. 因为 id 是唯一索引,就会退化成 4567 这一行 的行锁
  3. 而且唯一索引 ,限制了 不会再有其他数据了, 他不需要向后扫描了。

也就是说 通过唯一索引 只会上具体的行锁

那也就是 说 我们的删除 需要通过 SELECT 查询出 符合条件的 id ,在通过id 删除 即可 解决死锁问题

总结

死锁并不是说单业务才会发生。可能是因为后续的复杂业务导致 Next-Key Lock 交集 ,循环阻塞等待导致。每个业务单独拎出来执行都是没有问题的。就像这个例子,只有在死锁发生的时候 才能具体的发现。

同时 建议 在做业务上的 DELETE / UPDATE 时尽量通过 id 去做操作,这样不仅仅能缩短 SQL执行时间从而提升业务接口的响应时间,也能避免掉一些复杂的死锁场景

最后再简单列举上锁的一些原则:

  1. 加锁的基本单位是 Next-Key Lock Next-Key Lock 是前开后闭区间。

  2. 查找过程中访问到的对象才会加锁。

  3. 索引上的等值查询,给唯一索引加锁的时候, Next-Key Lock 退化为行锁。

  4. 索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候, Next-Key Lock 退化为间隙锁。

推荐阅读

深入理解 MyBatis

Linux 是如何启动的

ElasticSearch 磁盘 io 瓶颈问题解决方案探索

人工智能 NLP 简述

招贤纳士

政采云技术团队(Zero),一个富有激情、创造力和执行力的团队,Base 在风景如画的杭州。团队现有300多名研发小伙伴,既有来自阿里、华为、网易的“老”兵,也有来自浙大、中科大、杭电等校的新人。团队在日常业务开发之外,还分别在云原生、区块链、人工智能、低代码平台、中间件、大数据、物料体系、工程平台、性能体验、可视化等领域进行技术探索和实践,推动并落地了一系列的内部技术产品,持续探索技术的新边界。此外,团队还纷纷投身社区建设,目前已经是 google flutter、scikit-learn、Apache Dubbo、Apache Rocketmq、Apache Pulsar、CNCF Dapr、Apache DolphinScheduler、alibaba Seata 等众多优秀开源社区的贡献者。如果你想改变一直被事折腾,希望开始折腾事;如果你想改变一直被告诫需要多些想法,却无从破局;如果你想改变你有能力去做成那个结果,却不需要你;如果你想改变你想做成的事需要一个团队去支撑,但没你带人的位置;如果你想改变本来悟性不错,但总是有那一层窗户纸的模糊……如果你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的自己。如果你希望参与到随着业务腾飞的过程,亲手推动一个有着深入的业务理解、完善的技术体系、技术创造价值、影响力外溢的技术团队的成长过程,我觉得我们该聊聊。任何时间,等着你写点什么,发给 [email protected]

微信公众号

文章同步发布,政采云技术团队公众号,欢迎关注

政采云技术团队.png

猜你喜欢

转载自juejin.im/post/7125208237099450381