背景
企微报警群里连续发出生产环境报错警告,报错核心信息如下:
### Error updating database. Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry 'taskNoxxxxx' for key 'xxx_table.uk_task_no'
### SQL: INSERT INTO xxx_table ( task_no......) VALUES ( ?......)
### Cause: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry 'taskNoxxxxx' for key 'xxx_table.uk_task_no'
咋一看,就一个简单的写入时MySQL唯一键重复问题,加个Redis分布式锁即可解决。
可是当看到代码时,发现Redis分布式锁已经存在,那具体是怎么回事,下面具体分析。
分析
以下是简化后的伪代码
public class ServiceA{
private ServiceB serviceB;
@Transactional
public void doServiceA(String taskNo){
//...do business
Task task = loadTaskByNo(taskNo);
serviceB.doServiceB(task,taskNo);
//...do business
}
}
public class ServiceB{
@RedisLock(prefix="abc_")
public void doServiceB(Task task,@LockKey String taskNo){
//...do business
if(task == null){
insertTask(taskNo);
}else{
updateTask(taskNo);
}
}
}
@RedisLock是项目中自定义的分布式锁注解,可以指定分布锁前缀。@LockKey也是自定义注解,通过@RedisLock注解指定的前缀,拼接上被@LockKey所注解的字段值,形成分布式锁的key。例如,当taskNo值为taskNo001时,分布式锁key为abc_taskNo001。
伪代码整个调用时序图如下:
整个方法调用中,MySQL事务与Redis分布式锁的范围关系,他们关系如下图:
回顾报错信息,可以知道是执行insert语句时,出现唯一健冲突异常的,再结合以上时序图,可以得知是在步骤7发生的唯一健异常,由此往前推,即步骤4中,loadTaskByNo()方法获取到的Task对象为空。
在代码中,Redis分布式锁的生命周期,完全被包含在MySQL事务内。若是单线程执行该业务方法,其实并没有太大问题。但是当多线程并发时,多线程同时将在步骤4获取Task对象,此时若数据库未存在指定taskNo的数据时,多线程获取到的Task对象都将为null。紧接着多线程竞争Redis分布式锁后串行化调用步骤7,由于Task对象是在开启Redis分布式锁之前获取的,所以当多线程中Task对象为null时,都执行MySQL的insert操作写入相同的taskNo,最终导致MySQL报出唯一键异常。
解决方案
其实该问题是典型的分布式锁与事务的优先级问题。
针对以上的案例,解决办法如以下:
-
保持当前分布式锁与事务优先级不变,见分布式锁中判断Task对象的改成重新从数据库中获取再判断。(不推荐),不推荐的原因有以下
- 在每次写入操作时,都需要重新查询数据库,性能较差,且代码负责度更高,特别是对于大量写入操作的业务。
- 事务包含分布式锁,本质上将并发的事务一定程度上变成串行化,拿不到分布式锁的事务,将持有事务进行等待,当并发高或业务耗时较高时,可能会导致大量事务等待超时回滚,甚至系统数据库连接池被打满。
-
调整分布式锁与事务的优先级,使分布式锁包含整个事务(推荐),原因有以下
- 避免并发事务串行化执行导致事务等待甚至超时。
- 可以根据需要,将大事务裁剪成多个独立小事务,可以很大程度避免串行化导致大事务的产生。
总结
- 响应一下背景中提到的“Redis分布式锁即可解决”这句话,要具体分析,脱离实际代码、实际业务去定义解决方案,都是不可取的。
- 敬畏技术,知其然,而不知其所以然,终将导致严重事故和损失。
以上皆为个人观点。
如有不同观点或建议,欢迎各位看官指正。