最近在使用产品是遇见了一个奇怪的问题,在使用mysql数据库时,数据表中会一次写两条相同的记录进去,最后定位到问题是由于方法加了事务,方法中又加了锁,在多线程的情况下,多个线程在事务没提交的情况下读取到了一份数据。
一、问题复现
1、伪代码:
@Transactional
public Integer getUserWithTransaction2(String name){
Integer ret = 0;
try {
if (lock.tryLock(10, TimeUnit.SECONDS)){
try{
User user = userMapper.selectById(1);
long current = System.currentTimeMillis();
if(current - user.getTime() > 1000){
// update user.time to current
// insert into logs values()
}
return ret;
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
整理一下这块代码的逻辑:
1、查询user
2、如果当前时间 - user.time > 1s,则更新user的time为当前时间,并写保存一条日志
整个逻辑很简单,如果在1s之内,则不会更新user。在查询user和更新user的操作上加了锁。并且整个方法是一个事务操作
2、现象:
mysql可重复读模式下:
1、加事务加锁,会写2次数据库
2、不加事务加锁,没问题。
mysql串行化模式下:
1、加事务加锁,没问题。
2、不加事务加锁,没问题。
二、原因分析
要弄清楚这个问题需要对spring事务和mysql的隔离级别有一定的了解。spring事务可以查看:Spring事务,mysql的等后面在写了。
掌握了这两个知识点后这个问题基本就清楚是怎么造成的了。
1、首先由于spring的事务的封装,我们自身方法执行结束后不是整个事务就提交了,中间会有一个间隔时间去等spring提交事务
2、由于mysql的默认隔离级别是可重复读,所以一个事务是不能读取到其他事务未提交的数据的。
综合这两个知识点,再来分析遇到的这个问题:
1、我们对方法中的写数据库操作加了锁,想保证一个线程更新了user.time之后另外的线程才能读取,这是初衷
2、想保证整个操作是事务操作,所以在方法上加了@Transactional注解
3、两相结合下就出现了问题,方法内部在释放锁之后,事务还没有提交,其他线程这时候就读到了旧数据,所以也执行了更新操作
三、解决方案
1、调整mysql隔离级别,读未提交和串行化理论上都行
2、将事务放到锁内部
最终选用的方案是2
最终伪代码如下:
public Integer getUserWithTransaction2(String name){
Integer ret = 0;
try {
if (lock.tryLock(10, TimeUnit.SECONDS)){
try{
User user = userMapper.selectById(1);
long current = System.currentTimeMillis();
if(current - user.getTime() > 1000){
ret = doGetUserWithTransaction(name, user, current);
}
if (user.getPassword().contains(name)){
// throw new RuntimeException("该回滚了...");
}
return ret;
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
public Integer doGetUserWithTransaction(String name, User user, long current) {
return transactionTemplate.execute(transactionStatus -> {
try{
// todo insert into logs values()
user.setPassword("test_123_" + name);
user.setTime(current);
return userMapper.updateById(user);
}catch (Exception e ){
transactionStatus.setRollbackOnly();
}
return 0;
});
}
四、心得
声明式事务尽量在简单的场景下使用,尽可能少用。除了这次遇到的问题,还有一堆事务失效问题需要避免,也遇到过好几次。现在我们的产品中全部用的是声明式事务,有一些事务失效的场景,还有一些事务成功但是不是预期的地方,比如说事务会回滚,但是是因为内部方法抛异常导致的外部事务回滚,而真正的内部方法上的事务其实是失效的。