likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 15

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

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

前言

上一期 从线上死锁分析到 Next-Key Lock 理解 已经通过线上一个死锁问题引出了 Next-Key Lock,今天接着再通过一个常见的业务操作导致的死锁情景进行切入,发散多种 SQL 执行中的 Next-Key Lock,对 Next-Key Lock 的上锁过程进行进一步分析理解。

以下案例和分析都基于可重复读的事务隔离级别

场景再现

在业务上,经常会出现如果存在则更新,反之插入的场景,通常这种场景就是需要保证业务表里该数据的唯一,并且更新的数据是正常的,那么就需要保证在并发的场景不会出现重复插入以及更新异常的情况。 通常使用以下两种解决方案处理 (这里也不展开讨论各方案的优劣,这里仅仅分析数据库操作时可能导致的问题)

  1. 通过分布式锁进行控制整块业务同步进行
  2. 数据库行锁层面控制

通常在非高并发场景我们可能会选择简单的数据库层面进行控制,今天我们就来剖析一下,通过方案 2 解决这种常见业务,将会出现的死锁问题。

伪代码示例

以取 businessCode 业务流水编号为例,存在 +1,不存在初始化

​
  //通过businessCode找对应的配置是否存在
  ConfigQueryDTO query = new ConfigQueryDTO();
  query.setBusinessCode(businessCode);
​
  //排他行锁获取 
  Config config = configDao.selectForUpdate(query);
​
  //配置为空的判断处理
  ConfigDO configData = new ConfigDO();
  configData.setBusinessCode(businessCode);
  if (config == null) {
      configData.setConfigValue(initData);
      configDao.insert(configData);
  } else {
      configData.setConfigValue(config.getConfigValue+1);
      configDao.update(configData);
  }
​

上面的业务代码比较简单,这边将上面的伪代码转换成 SQL

-- 不存在
BEGIN;
​
select * from config where business_code = businessCode for update;
insert into config  (business_code,conf_value) values (businessCode,initData);
​
COMMIT;
​
-- 存在
BEGIN;
​
select * from config where business_code = businessCode for update;
update  config  set conf_value = confValue where business_code = businessCode ;
​
COMMIT;
 

把场景梳理一下:

  1. 通过 select ... for update 查询是否存在数据,存在的话上排他行锁。
  2. 通过 insert 进行数据插入或更新,因为表的唯一索引会保障只有一个插入成功,所以存在会插入触发主键冲突 。
  3. 通过 update 进行更新,因为排他行锁锁住避免同时出现并发更新,所以更新的数据正常。

(有些人可能会说通过 insert ... on duplicate key update ... 替代 insertupdate 的过程,这样可以解决主键冲突报错问题又能更新,实际原理和上面类似,简单来说是统一进行 insert 后如果出现主键冲突之后转变为 update 语句,具体不展开)

这么看下来看起来好像挺正常,那会有什么问题吗?

上手试验 (建议实操加深印象)

创建一个简单的表

CREATE TABLE `config` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `business_code` int(11),
  `conf_value` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_business_code` (`business_code`)
) ENGINE=InnoDB;
​
insert into config values(0,1,1),(2,5,1),(3,10,1);

模拟一个简单并发场景 。同时两个 Session 进入 ,处理 bussiness_code = 3 的数据。

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

-- SessionA
begin;
select * from config where business_code = 3 for update;
-- 休眠模拟时间差
SELECT sleep(5) FROM config limit 1;
insert into config (business_code,conf_value) values (3,1);
commit ; 
​
-- SessionB
begin;
select * from config where business_code = 3 for update;
insert into config (business_code,conf_value) values (3,1) ;
-- 休眠模拟时间差
SELECT sleep(10) FROM config limit 1;
commit ;
​

按照上面流程来看,因为 business_code = 3 并不存在,SessionB 应该会很快插入成功,SessionA 会应该因为主键冲突失败。

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

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

但实际 SessionB 出现了阻塞, SessionA 出现了死锁。

分析 ☆☆☆☆

刚从实际操作来和我们的预期不太符合。那么既然从业务流程上看来没问题,那么我们就从 SQL 的执行上锁过程来拆解分析一下。

温顾 Next-Key Lock

分析之前把上一期的 Tips 信息我们再回顾一下

  1. MySQL 的锁单位是 Next-Key Lock,也就是 行锁+间隙锁。间隙锁和间隙锁之间无冲突,只有行锁和行锁之间有冲突。 Next-Key Lock 是前开后闭区间。
  2. 间隙锁:锁的就是两个值之间的空隙,跟间隙锁存在冲突关系的,是“往这个间隙中插入一个记录”这个操作

SQL执行上锁

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

  1. 加锁是的基本单位是 Next-Key Lock, 同时是锁是加在索引上面的,business_code 上有一个唯一索引 ,那么 SessionA 的 select ... for update 上锁也就是 ( 1 , 5 ], 但由于 business_code = 3 不存在 ,变为间隙锁 ( 1 , 5 )。
  2. SessionB 同理 select ... for update 也在 ( 1 , 5 ) 中上了自己的间隙锁。因为间隙锁和间隙锁之间无冲突,所以它也能上锁成功。
  3. 当执行 SessionB 的 insert 时,因为 SessionA 的 ( 1 , 5 ) 间隙锁,它无法进行插入,因为和间隙锁存在冲突关系的,是“往这个间隙中插入一个记录” 。所以阻塞住等待 SessionA 释放间隙锁。
  4. SessionA 执行 insert 时,因为 SessionB 的 ( 1 , 5 ) 间隙锁,它也无法进行插入,也需要等待 SessionB 释放,这时候出现了循环等待,MySQL 检测出死锁 。中断 SessionA 。
  5. SessionA 中断 ,释放出了 SessionA 上的 ( 1 , 5 ) 间隙锁,那么 SessionB 的 insert 就能继续执行。

问题思考

既然这样可能会出现死锁问题,那应该怎么样正确的处理这种业务呢?

从上面的分析中可以知道,该死锁原因是因为 select ... for update 在没有数据的时候也会加上间隙锁,从而影响了 insert

反过来思考,实际上我们做 insert 的时候无需上锁,上锁是为了更新的时候保证同一个时刻只有一个在进行业务。

那么我们实际上只要把上锁动作后置,也就是将需要上锁的业务范围缩小就能解决。同时通过主键进行加行锁,也能避免间隙锁的产生导致其他可能出现的业务死锁问题。

​
  //通过businessCode找对应的配置是否存在
  ConfigQueryDTO query = new ConfigQueryDTO();
  query.setBusinessCode(businessCode);
​
  //获取数据 
  Config config = configDao.select(query);
​
  //配置为空的判断处理
  ConfigDO configData = new ConfigDO();
  configData.setBusinessCode(businessCode);
  if (config == null) {
      configData.setConfigValue(initData);
      configDao.insert(configData);
  } else {
      //后置排他行锁获取 
      ConfigQueryDTO queryLock = new ConfigQueryDTO();
      queryLock.setId(config.getId);
      config = configDao.selectForUpdate(queryLock);
      configData.setConfigValue(config.getConfigValue+1);
      configDao.update(configData);
  }

场景升级

上面通过 SQL 的执行等一系列的分析,已经解释了为什么两个插入会导致死锁问题。回溯一下从已举的列子里,可以发现基本上都是4条 SQL 出现 相互交叉依赖等待从而出现死锁,那是不是一定要 4 条 SQL 才会出现死锁呢?

示例实操 (建议实操加深印象)

还是刚那张表,稍作改动将 business_code 修改为普通索引

DROP INDEX idx_business_code ON config;
ALTER table config ADD INDEX idx_business_code(business_code);
​
​
-- SessionA
begin;
select * from config where business_code = 10 for update;
-- 休眠模拟时间差
SELECT sleep(5) FROM config limit 1;
insert into config (business_code,conf_value) values (9,1);
commit ; 
​
-- SessionB
begin;
update  config  set conf_value = conf_value + 1 where business_code = 10 ;
-- 休眠模拟时间差
SELECT sleep(10) FROM config limit 1;
commit ;

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

思考一下会发生什么?看起来没有发生交叉依赖。

SessionB 会因为 SessionA 的排他行锁 进行阻塞。SessionA 能执行 ,SessionB 等待 SessionA 执行完成后 执行成功。

会是这样吗?看看结果

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

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

SessionA 确实成功了 ,但是 SessionB 触发了死锁

再次分析 ☆☆☆☆

上面的结果对我们的猜想确实有冲击,为什么3条 SQL 也会触发死锁。

还是一样回归本质,从上锁的过程进行分析。

  1. SessionA 的 select ... for update ,先基于 business_code 的索引上了 ( 5 , 10] 的 Next-Key Lock ,又因为非唯一索引还需要向后扫描到第一个不符合并退化成间隙锁 ,且后面已经无数据。 所以该 SQL 在索引 idx_business_code 上加了 Next-Key Lock ( 5, 10 ] 和 间隙锁 ( 10 , +suprenum ) 。
  2. 同理 SessionB 的 update 语句也要在索引 idx_business_code 上加 Next-Key Lock ( 5 , 10 ],进入锁等待;
  3. 然后 SessionA 要再插入 (9,1) 这一行,被 SessionB 的间隙锁锁住。由于出现了死锁,InnoDB 让 Session B 回滚。

SessionB 的 Next-Key Lock 不是还在锁等待并没有申请成功吗?

其实这就是举这个例子的目的

我们在分析加锁规则的时候知道可以用 Next-Key Lock 作为加锁的基本单位。但是实际具体执行的时候,是分成间隙锁和行锁分别来加锁的处理,这实际也是 Next-Key Lock 本质,它是由 间隙锁 + 行锁 构成的 。

理解上面的 Tips 之后我们再来看

实际 Session B 的加 Next-Key Lock ( 5 , 10 ] 操作,实际上分成了两步,先是加 ( 5 , 10 ) 的间隙锁,因为间隙锁和间隙锁之间无冲突 ,可以加锁成功。然后加 business_code=10 的行锁,这时候才被阻塞住的。

也就是说 SessionB 那拿着自己的间隙锁,等待 SessionA 持有的行锁释放。 SessionA 又拿着行锁,等待SessionB 的间隙锁释放之后, 插入一条数据。

这样就又发生了交叉依赖,那么就出现了死锁的情况。

问题分析

该类死锁问题实际是经典的业务死锁场景之一,不论 SQL 有几条,问题的本质都是 updatedelete 语句的非唯一索引条件导致产生未考虑到的间隙锁。最简单的解决方案就是在操作前先通过查询到业务主键,通过主键进行 updatedelete 即可。

-- 将 SessionB 业务改为如下,就可以正常执行
begin;
select id from config where  where business_code = 10 ;
update  config  set conf_value = conf_value + 1 where id = xxxx;
commit ;

总结

上面分析了上锁,分析了问题,也产生了很多新的结论和知识点。下面我们就来进行一些总结。

首先在碰到死锁问题的时候,需要由表及里。由业务流程剖解 深入到 SQL 执行上锁 ,返璞归真分析上锁的过程才能根本解决死锁问题,要会用加锁规则去判断语句的加锁范围。大部分业务场景死锁都是因为 updatedelete 因为条件导致有过大范围的间隙锁。正如第一篇建议,在做业务上的 updatedelete 时尽量通过 id 去做操作,这样不仅仅能缩短 SQL执行时间从而提升业务接口的响应时间,也能避免掉一些复杂的死锁场景

其次死锁也并不是至少 4 条 SQL 才会产生,具体的产生需要分析具体的上锁过程。并不是复杂场景、复杂 SQL 才会产生死锁问题。本文举的2个例子都是简单的场景下触发的死锁。

最后也是本篇幅的新产出的重点,我们在分析加锁规则的时候虽然可以用 Next-Key Lock 作为加锁的基本单位来分析,但是要知道,具体执行的时候,是要分成间隙锁和行锁两段来执行的。

推荐阅读

Spring Boot 优雅停机

浅谈大数据指标体系建设流程

Spock单元测试框架简介及实践

算法应用之搜推系统的简介

开源框架APM工具--SkyWalking原理与应用

招贤纳士

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

微信公众号

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

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