间隙锁-记一次死锁原因分析

目录

一、场景概述

二、场景重现

1、数据库准备

2、同用户已有地址记录操作

3、同用户无地址记录操作

4、不同用户无地址记录操作

三、解决死锁


一、场景概述

这个死锁问题的出现是在一次电商系统的抢购活动中出现的(因为比较久了,当时没有事后写微博记录下,所以现在没有实际的日志来更好的展现,只能靠描述回忆,大家见谅)

  • 项目:电商系统中的用户系统(采用的是微服务框架:有门户、商品、用户、订单、支付、优惠、仓储等多个微服务系统)
  • 时间:抢购开始前半小时开始出现
  • 现象:用户系统的日志中,间歇性的出现数据库deadLock日志错误
  • 代码:通过日志中的mysql和日志,锁定实现代码是用户新增一条地址记录
    @Transactional
    public void addDefaultAddress(Address address) {
        // 根据用户ID修改默认地址为普通地址
        updateNormalAddress(address.getUserId());
        // 根据用户ID新增用户默认地址
        insertDefaultAddress(address)
    }
  • 场景:用户在抢购前对收货地址有三种情况,第一种是用户之前就已经有收货地址并且是想要的默认地址;第二种是有了两个收货地址,想修改另一个地址为默认地址;第三种是没有收货地址,需要先填写默认收货地址;从代码中看出是更新地址后新增了一条默认地址的操作引发的死锁
  • 分析:
    1. 现在知道是地址的新增修改出现问题,但是非抢购活动期间并没有出现死锁日志,由此推断是在高并发情况下才会出现的;
    2. 结合代码看,代码是根据userId这个用户ID进行操作的,只有这个用户登录后才能进行操作;所以情况有两种可能会导致在高并发情况下造成死锁,第一种是同一个用户同时调用了两次接口,而由于数据库繁忙时处理比较慢,导致两次事务同时执行;第二种就是非同一个用户同时调用接口导致;

二、场景重现

1、数据库准备

使用的数据库是mysql,客户端使用的是Navicat for MySQL;

1.1 创建数据表

CREATE TABLE `my_address` (
  `id` int NOT NULL AUTO_INCREMENT,
  `user_id` int NOT NULL COMMENT '用户ID',
  `is_default` tinyint NOT NULL COMMENT '是否默认地址,0=否,1=是',
  `province` varchar(32) DEFAULT NULL COMMENT '省',
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`) USING BTREE
) ENGINE=InnoDB;

        这个地址表模拟线上的表,user_id创建普通索引,只使用省字段代表地址,其他市级等省略

1.2 准备初始测试数据

2、同用户已有地址记录操作

这里对user_id=5进行操作,开启Navicat客户端开启 查询编辑器A和 查询编辑器B:

  • 查询编辑器A和B关闭自动提交,模拟事务开始
SET autocommit=0;
  • 模拟执行updateNormalAddress更新默认地址为普通地址,查询编辑器A和B分别执行
UPDATE my_address SET is_default = 0 WHERE user_id = 5;

        先执行编辑器A,这时显示成功:

        

        再执行编辑器B,这时为阻塞状态:(状态栏显示为:正在处理)

        

        过一段时间后,会显示失败,等待锁超时

        

        如果在编辑器B阻塞未超时阶段,这时编辑器A插入记录,并且提交事务:(模拟执行新增方法insertDefaultAddress)

INSERT INTO my_address(user_id, is_default, province) VALUES (5, 1, 'g1');

COMMIT;

         这时候看编辑器B,解除阻塞,获取到行锁,继续执行update成功,这时耗时4.316秒

        

         最后编辑器B执行新增(模拟执行新增方法insertDefaultAddress)

INSERT INTO my_address(user_id, is_default, province) VALUES (5, 1, 'g1');

COMMIT;

         添加成功,查看记录:

        

         总结分析:

            可以看到记录新增了两条,我们看下执行的时间图:

         编辑器A在执行update的时候,因为表中已经有user_id=5的地址记录,这时候获取到的是行锁(innodb数据库中的锁是基于索引的);

        接着,编辑器B执行update,也申请获取user_id=5的行锁,这时候由于编辑器A已经持有这个行锁,编辑器B只能阻塞等待编辑器A提交事务后释放行锁,才能继续执行;造成出现新增两条相同地址的情况。

        所以这种情况是由于行锁的等待,引发锁等待超时,但并不会引发死锁,所以排除这种情况。

3、同用户无地址记录操作

这里对user_id=6进行操作,数据库中无user_id=6的地址记录,开启Navicat客户端开启 查询编辑器A和 查询编辑器B:

  • 查询编辑器A和B关闭自动提交,模拟事务开始
SET autocommit=0;
  • 模拟执行updateNormalAddress更新默认地址为普通地址,查询编辑器A和B分别执行
UPDATE my_address SET is_default = 0 WHERE user_id = 6;

        先执行编辑器A,这时显示成功:

        

        再执行编辑器B,也显示执行成功:

        

  • 模拟执行insertDefaultAddress方法新增默认地址记录,查询编辑器A和B分别执行
INSERT INTO my_address(user_id, is_default, province) VALUES (6, 1, 'g1');

        先执行编辑器A,这时为阻塞状态:(状态栏显示为:正在处理)

        

         在执行编辑器B,这是立刻显示Deadlock日志;

        

         然后看编辑器A执行情况,继续执行,耗时16.714s,执行成功

        

         编辑器A执行commit后,查看记录,新增成功:

        

总结分析:

            我们看下执行的时间图:

         编辑器A在执行update的时候,因为user_id=6记录不存在,数据库将会采用间隙锁,

( 间隙锁顾名思义就是对一段区间进行加锁,如有user_id=1,4,5,这时候如果更新的是user_id=2,那么数据库将会对user_id=2和3的不存在索引记录进行加锁,这是锁的区间为[2,3],其他事务不能进行操作;如果更新的是user_id=6,那么锁的区间就是[max(user_id), +∞],即对[6, +∞],那么其他事务就不能操作大于等于6的的记录,这个机制是为了解决幻读问题引入的锁)

        所以编辑器A的事务已经申请了间隙锁[6, +∞],这时我们看编辑器B也可以执行update成功,也获得了间隙锁[6, +∞],(间隙锁是可以重叠获取持有的);

        这时候,编辑器A继续执行insert,因为编辑器B持有间隙锁[6, +∞],那么编辑器A会阻塞等待,接着编辑器B执行insert,因为编辑器A也持有间隙锁[6, +∞],数据库检测到形成死锁,直接报错Deadlock失败,并释放间隙锁[6, +∞],这是编辑器A可以继续执行insert成功,并提交。

4、不同用户无地址记录操作

这里对user_id=6和user_id=7分别进行操作,数据库中无user_id=6和7的地址记录,开启Navicat客户端开启 查询编辑器A和 查询编辑器B:

  • 查询编辑器A和B关闭自动提交,模拟事务开始
SET autocommit=0;
  • 模拟执行updateNormalAddress更新默认地址为普通地址,

          先执行编辑器A,更新user_id=6,显示成功:

UPDATE my_address SET is_default = 0 WHERE user_id = 6;

         再执行编辑器B,更新user_id=7,也显示成功:

UPDATE my_address SET is_default = 0 WHERE user_id = 7;

          编辑器A执行insert

INSERT INTO my_address(user_id, is_default, province) VALUES (6, 1, 'g1');

        编辑器A进入阻塞等待状态,编辑器B执行insert

INSERT INTO my_address(user_id, is_default, province) VALUES (7, 1, 'h1');

        编辑器B报错,出现死锁Deadlock

        

         编辑器A执行commit,成功,插入记录如下:

        

 

总结分析:

           跟上面同用户无地址操作一样,出现死锁,编辑器A持有间隙锁[6, +∞],编辑器B也获得了间隙锁[6, +∞],(间隙锁是从目前已有的最大user_id开始的,所以无论是user_id=6还是user_id=7,他们持有的间隙锁都是[6,+∞]),所以在编辑器B执行insert时会出现死锁。

三、解决死锁

        从上面的场景重现和分析,我们知道出现的情况都是因为用户操作时update不存在的记录,导致间隙锁设置的很大,针对这个问题,我们可以修改代码如下:

    @Transactional
    public void addDefaultAddress(Address address) {
        // 查询用户地址是否存在
        int count = countAddressByUserId(address.getUserId());
        if (count != 0) {
            // 根据用户ID修改默认地址为普通地址
            updateNormalAddress(address.getUserId());
        }
        // 根据用户ID新增用户默认地址
        insertDefaultAddress(address);
    }

        先根据用户ID判断是否有地址记录,如果有才执行update,这时候,update采用的是行锁,并不会使用间隙锁,使用的是行锁,所以不会出现死锁。

       

 

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值