慎用@Transactional声明式事务

2023-04-16
0 503

最近在使用产品是遇见了一个奇怪的问题,在使用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、两相结合下就出现了问题,方法内部在释放锁之后,事务还没有提交,其他线程这时候就读到了旧数据,所以也执行了更新操作
spring事务&锁重复写

三、解决方案

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;
        });
    }

四、心得

声明式事务尽量在简单的场景下使用,尽可能少用。除了这次遇到的问题,还有一堆事务失效问题需要避免,也遇到过好几次。现在我们的产品中全部用的是声明式事务,有一些事务失效的场景,还有一些事务成功但是不是预期的地方,比如说事务会回滚,但是是因为内部方法抛异常导致的外部事务回滚,而真正的内部方法上的事务其实是失效的。