平常coding系列:记一次关于死锁的思考以及解决办法


github 地址

github 地址 里面有注释好的代码,下载下来可以方便阅读。

问题描述

近期写项目的时候,遇到一个bug,情况大概是这样子,我们有个数据流系统会把识别出来的车牌不断的上报,需要把上报的车牌信息做如下的处理

  1. 记录的车牌记录给保存在车辆档案表里面。
  2. 调用第三方案件系统,是否生成新的案件。
  3. 如果生成新的案件,更新案件数。

下面大概是这个表的结构

TruckRecord Long id //truckNum 已加索引 String truckNum Long caseCount

所以上面的流程包括三个数据库操作,

  1. select 操作,select truck_num from truck_recored where truck_num=?;
  2. 如果记录不存在,则新增一条TruckRecord 操作,insert…
  3. 调用第三方案件系统,如果是新的案件,则更新新的案件个数。

为了不引申其它问题进来,我们先暂定第三方案件系统是一个完美的系统,每次都会完美的给我返回正确的response,

死锁发生的原因

因为业务原因数据流并发推送上来了两个相同车牌号的数据,所以两个线程分别都做了save和update操作。
因为mysql使用的隔离级别是可重复读。
所以save的时候会产生一个插入意向锁
而update 是需要对单条记录做更新,因为当前记录未提交所以会产生一个gap锁。

而插入意向锁和普通gap锁是一个互斥关系,

在这里插入图片描述

死锁解决方案

因为都是单表操作,而且mysql 开了自动检测死锁的配置,或配置锁超时的之类的设置都能够有相应的日志可看。
要解决以上方案,即可让save操作独自变成一个新的事务,这样就不会引起死锁的产生。
或者将刚刚逻辑变为如下

  1. 提交给第三方系统返回结果。
  2. 保存或者更新记录(唯一不同的是当发现记录存在则做更新,不存在则做插入,但是插入信息里面已经带上了刚刚返回的案件信息)

这样就避免了由于同时存在gap锁和插入意向锁而导致的问题。
但是上面这个逻辑就真没问题了,情况显然不是

死锁之外的情况

因为mvcc的存在,我们去select 一条记录并不能查到未提交的记录,所以两条记录都会更新。
要解决这个问题。有以下几个方案。

  1. 调整mysql的隔离级别,变成读未提交的级别,这样就能够判断,先来后到的情况,但是一旦事务回滚,那可能导致的结果就是两条记录都同时失败,且调整mysql 隔离级别是影响全局的操作,显然不是一个good idea。
  2. 建立唯一索引,这样即使因为mvcc的存在,在并发是会有两条相同记录插入。也会唯一索引的机制会提示有重复键的异常抛出,但是唯一索引并不是一个性能特别好的存在因为会让插入操作变成单线程操作。在这里不单独分析,大体逻辑跟mysql的change buffer有关系。
  3. 对于当个truckNum建立分布式锁,锁的范围控制在第一次查询后,大概代码逻辑如下
public void saveOrUpdate(TruckRecord record){
    
    
	TruckRecord persist = selectByTruckNum(truckNum);
	if(persist==null){
    
    
			tryLock(record.getTruckNum());
			//这里要用new transaction 去查避免mvcc的可重复读属性
			persist=selectByTruckNum(truckNum);
			//双重检测
			if(persist==null){
    
    
				//new transaction 事务传播方式
			    saveRecord(record)
			}else{
    
    
				updateRecord(record);
			}
			unLock(record.getTruckNum());
    }

}
@Transactional(propagation = Propagation.REQUIRES_NEW)
TruckRecord selectByTruckNum(truckNum){
    
    
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
void saveRecord(truckNum){
    
    
}



这样的话在插入后也不会进入锁的状态,且能够保持记录唯一的办法,最大化控制了锁的范围。
当你天真的以为上面的代码没有坑的时候,其实坑自然会找上你了。

声明式事务失效

上面代码我使用了两个新的事务,第一个查询我保证查询的是最新的记录,而不是因为一致性读的情况下导致的旧的视图。第二个save用了新的事务,是保证在锁的期间内能把数据提交。从而能让第一个查询能及时查询到。

但是在spring boot 框架下,两个annotation 并不能起效果,声明式事务的本质上面是利用动态代理的方式,让本来的方法调用进入到代理方法的调用,进行一系列切面代码的运行,而通过上面的方式只能进入到本方法,所以并没有新的事务产生,在并发情况下因为一致性读的原因仍然会产生两条相同的记录

正确的方式:

public void saveOrUpdate(TruckRecord record){
    
    
	TruckRecord persist = selectByTruckNum(truckNum);
	
	if(persist==null){
    
    
			tryLock(record.getTruckNum());
			TruckService proxy = SpringUtils.getBean(TruckService.class);
			//这里要用new transaction 去查避免mvcc的可重复读属性
			persist=proxy.selectByTruckNum(truckNum);
			//双重检测
			if(persist==null){
    
    
				//new transaction 事务传播方式
			    proxy.saveRecord(record)
			}else{
    
    
				updateRecord(record);
			}
			unLock(record.getTruckNum());
    }

}

当然分布式锁里面也会因为分布式锁的实现,比如用redis分布式锁,出现的锁失效的情况下,在非常极端的情况下仍然可能会产生两个记录。建议对于数据非常精确的场景,以及插入操作并不是那么频繁的场景下,用唯一锁依然是一个很好的解决方案。

总结

这篇文章主要是重现一个在项目中一个小小的场景,然后深思了下情况,当然这里面还要考虑到第三方系统的幂等的情况,以及错误重试的情况,以及锁失效的补救方案等等,对于这些在不同系统里面其实没有唯一的答案,所以还是需要case by case的进行分析。

猜你喜欢

转载自blog.csdn.net/qq_33361976/article/details/109891654