目录
一、场景概述
二、场景重现
1、数据库准备
2、同用户已有地址记录 *** 作
3、同用户无地址记录 *** 作
4、不同用户无地址记录 *** 作
三、解决死锁
一、场景概述
这个死锁问题的出现是在一次电商系统的抢购活动中出现的(因为比较久了,当时没有事后写微博记录下,所以现在没有实际的日志来更好的展现,只能靠描述回忆,大家见谅)
- 项目:电商系统中的用户系统(采用的是微服务框架:有门户、商品、用户、订单、支付、优惠、仓储等多个微服务系统)
- 时间:抢购开始前半小时开始出现
- 现象:用户系统的日志中,间歇性的出现数据库deadLock日志错误
- 代码:通过日志中的mysql和日志,锁定实现代码是用户新增一条地址记录
@Transactional public void addDefaultAddress(Address address) { // 根据用户ID修改默认地址为普通地址 updateNormalAddress(address.getUserId()); // 根据用户ID新增用户默认地址 insertDefaultAddress(address) }
- 场景:用户在抢购前对收货地址有三种情况,第一种是用户之前就已经有收货地址并且是想要的默认地址;第二种是有了两个收货地址,想修改另一个地址为默认地址;第三种是没有收货地址,需要先填写默认收货地址;从代码中看出是更新地址后新增了一条默认地址的 *** 作引发的死锁
- 分析:
- 现在知道是地址的新增修改出现问题,但是非抢购活动期间并没有出现死锁日志,由此推断是在高并发情况下才会出现的;
- 结合代码看,代码是根据userId这个用户ID进行 *** 作的,只有这个用户登录后才能进行 *** 作;所以情况有两种可能会导致在高并发情况下造成死锁,第一种是同一个用户同时调用了两次接口,而由于数据库繁忙时处理比较慢,导致两次事务同时执行;第二种就是非同一个用户同时调用接口导致;
使用的数据库是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采用的是行锁,并不会使用间隙锁,使用的是行锁,所以不会出现死锁。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)