@Transactional事务的使用和失效场景分析

619 阅读11分钟

@Transactional事务的使用

在Service层中,给service类或者指定的方法添加@Transactional注解即可

@Transactional注解还有一些可以设置的属性,后面会说到

事务的失效场景

一、抛出检查异常

原因: 使用@Transactional注解的时候,没有加rollBackFor类型

rollBackFor默认抛出的是RuntimeException异常(查看Spring源码注释得知),所以当没有声明rollBackFor类型,并且方法内抛出非RuntimeException异常时,是不会造成事务的回滚的,我们来测试一下

复现问题:

@Transactional
public void save(User user) throws IOException {
    userMapper.insert(user);
    throw new RuntimeException("模拟异常");
}
@Transactional
public void save(User user) throws IOException {
    userMapper.insert(user);
    throw new IOException("模拟异常");
}

调用方法验证:

@Test
public void test02() throws IOException {
    SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
    String name = format.format(new Date());
​
    User user = new User();
    user.setName(name);
    user.setAge(10);
    userService.save(user);
}

上面两个事务方法,只有第一个的事务会回滚,第二个不会,导致数据仍然被插入

解决办法:

  1. 声明rollBackFor类型为Exception,因为Exception是所有异常类的父类,可以对方法内抛出的任何异常进行捕获

    (注:Exception是所有异常类的父类,Error是所有错误类的父类,两者同时又是Throwable类的子类)

  2. 不声明rollBackFor类型的话,就要手动在方法内捕获异常,然后根据业务抛出RuuntimeException

另:想要看类的继承关系,在IDEA中点击类名,CTRL+H会看到继承结构,还能全选SHOW DIAGRAM转成UML图来看

二、业务方法内try-catch

原因: 异常在业务方法内被捕获了,导致Spring无法捕获异常,自然无法进行回滚

@Transactional
public void save03(User user) {
    userMapper.insert(user);
    try {
        throw new RuntimeException("模拟异常");
    } catch (Exception e) {
        e.printStackTrace();
    }
}

解决方法:

  1. 抛出异常让Spring捕获,从而进行回滚

  2. 不依赖Spring,手动进行回滚操作

    @Transactional
    public void save04(User user) {
        userMapper.insert(user);
        try {
            throw new RuntimeException("模拟异常");
        } catch (Exception e) {
            e.printStackTrace();
    ​
            // 手动进行回滚
            TransactionInterceptor.currentTransactionStatus().setRollbackOnly();
        }
    }
    

三、切面顺序

原因: 自定义切面和事务切面的执行顺序引起异常捕获冲突

自定义切面不声明切面优先级,那么优先级是和事务注解(@Transactional)的切面优先级一样,并且要注意的是,Spring会优先扫描事务相关,所以事务切面会先执行,然后执行自定义切面,加入自定义切面内进行了手动catch异常,那么事务切面调用的方法将不会有异常抛出,自然无法回滚。

上面这段话有点绕,因为我们需要掌握AOP切面的基础知识才可以搞懂其中的逻辑关系,这里写一个例子,声明两个切面,他们的工作内容都是一样的:

复现问题:

@Test
public void test06() {
    SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
    String name = format.format(new Date());
​
    User user = new User();
    user.setName(name);
    user.setAge(10);
    userService.save06(user);
}
@Aspect
@Component
public class YAopAspect {
    @Around(value = "execution(public * com.cc.mbg.UserService.*(..))")
    public Object around(ProceedingJoinPoint joinPoint) {
        System.out.println("切面YYYY");
        Object result = null;
        try {
            result = joinPoint.proceed();
        }catch (Throwable throwable) {
            System.out.println("切面YY捕获到了异常");
            throwable.printStackTrace();
        }
        return result;
    }
}
@Aspect
@Component
public class ZAopAspect {
    @Around(value = "execution(public * com.cc.mbg.UserService.*(..))")
    public Object around(ProceedingJoinPoint joinPoint) {
        System.out.println("切面ZZZZ");
        Object result = null;
        try {
            result = joinPoint.proceed();
        }catch (Throwable throwable) {
            System.out.println("切面ZZ捕获到了异常");
            throwable.printStackTrace();
        }
        return result;
    }
}

userService的方法:

public void save(User user) {
    userMapper.insert(user);
    throw new RuntimeException("模拟异常");
}

Y和Z这两个切面,都会在执行UserService方法的时候触发,并且因为他们都没有声明切面优先级,所以他们的切面优先级都是一样的:Integer.MAX_VALUE,又因为优先级相同,所以会根据切面字母顺序来执行,即YAopAspect先执行,然后是ZAopAspect

现在说一下他们的触发顺序:

  1. 调用userService.save(),在方法实际调用前触发了AOP切面,即此时方法尚未调用
  2. Y切面先执行around,输出:”切面YYYY”
  3. Y切面继续执行,一直到joinPoint.proceed();
  4. Y切面暂停,Z切面执行around,输出“切面ZZZZ”
  5. Z切面继续执行,try-catch捕获到userService.save()抛出的异常,输出:“切面ZZ捕获到了异常”,然后return,此时Z切面代码执行完毕
  6. Y切面继续执行:result = joinPoint.proceed();因为无异常抛出(被Z切面捕获了),所以跳过catch代码块,return,此时Y切面代码执行完毕

通过分析可以知道,我们自定义切面的时候,需要注意手动异常捕获,因为可能会影响到其他切面对抛出异常的处理。

由上应该就能理解,@Transactional注解的切面,为什么会有可能被自定义切面所影响,导致事务回滚失效了。

科普一下切面优先级:

  • AOP不指定Order,那么默认值是Integer.MAX_VALUE
  • Order越小,越优先执行
  • 当Order相同时,按照切面名称字母的顺序来执行

解决方法:

  1. 手动抛出异常,让Spring捕获到
  2. 手动回滚数据

四、事务方法的访问修饰符非public

原因: Spring为方法对象创建代理,添加切面的条件是方法必须是public的,并且不能是private、static、final的原因,可以看我的这篇文章看动态代理相关知识

解决方法: 使用public修饰方法,且注意不要用static和final修饰

五、使用this调用本类事务方法

原因: Spring的声明式事务是基于动态代理实现的,即Spring会创建一个代理对象去执行事务方法,而this是指向本身,即对象本身去执行方法,不是代理对象去执行,所以事务不会生效

复现问题:

在Service中编写代码,内部去调用事务方法:

public void save06(User user) {
    this.saveByInside(user);
}
​
@Transactional
public void saveByInside(User user) {
    userMapper.insert(user);
    throw new RuntimeException("模拟异常");
}

调用测试:

@Test
public void test06() {
    SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
    String name = format.format(new Date());
​
    User user = new User();
    user.setName(name);
    user.setAge(10);
    userService.save06(user);
}

此时事务不生效,数据仍被插入

解决办法:

  1. 通过注入自己来获得代理对象,再进行调用,不过这种方法Spring官方不提倡,建议是使用新的类

    改造后:

    @Autowired
    private UserService self;
    ​
    public void save06(User user) {
        self.saveByInside(user);
    }
    ​
    @Transactional
    public void saveByInside(User user) {
        userMapper.insert(user);
        throw new RuntimeException("模拟异常");
    }
    
  2. 通过ApplicationContext从容器中获取代理对象,再进行调用

    @Autowired
    ApplicationContext applicationContext;
    ​
    public void save06(User user) {
        UserService bean = applicationContext.getBean(UserService.class);
        bean.save06(user);
    }
    ​
    @Transactional
    public void saveByInside(User user) {
        userMapper.insert(user);
        throw new RuntimeException("模拟异常");
    }
    

六、多线程调用导致的事务不一致

复现问题:

@Autowired
private PriceService priceService;
​
@Transactional
public void save09(User user) {
    userMapper.insert(user);
    new Thread(() -> {
        priceService.save();
    });
}

原因: save09方法中调用了priceService的事务方法save(),因为两个事务不在同一个线程中,所以获取的数据库连接不一样,也就是两个不同的事务,假如priceService.save()抛出了异常,save09()是不能回滚的,只有在同一个数据库连接中才能同时提交和回滚。

解决办法:

使用线程安全的AtomicBoolean作为事务执行状态标记,等事务方法内所有线程执行完毕后根据该状态判断是否需要回滚:

AtomicBoolean isError = new AtomicBoolean(false);
...
if (isError) {
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
}

七、数据库引擎不支持事务

比如MySQL的MyISAM引擎不支持事务

八、错误的事务传播

在使用@Transactional注解时,是可以指定propagation参数的,该参数的作用是指定事务的传播特性,Spring目前支持7中传播特性:

  • REQUIRED(默认):如果当前上下文中存在事务,那么加入该事务,如果不存在事务,则创建一个事务
  • SUPPORTS:如果当前上下文中存在事务,那么加入该事务,否则使用非事务方式执行
  • MANDATORY:如果当前上下文存在事务,则抛出异常
  • REQUIRES_NEW:每次都会新建一个事务,并且将上下文中的事务挂起,执行完当前新建事务后,上下文事务再恢复执行
  • NOT_SUPPORTED:如果当前上下文存在事务,则挂起当前事务,然后新的方法再没有事务的环境中执行
  • NEVER:如果当前上下文中存在事务,则抛出异常,否则在无事务环境上执行
  • NESTED:如果当前上下文中存在事务,则嵌套事务执行,如果不存在事务,则新建事务

九、嵌套事务回滚

原因: 在事务方法内有两个修改操作,只希望回滚其中一个,但因为事务导致两个都回滚了

复现问题:

新建一个priceService模拟嵌套事务:

@Service
public class PriceService {
    ...
    @Transactional
    public void save() {
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss");
        String name = format.format(new Date());
        User user = new User();
        user.setName(name);
        user.setAge(10);
        userMapper.insert(user);
    
        // 这里为可插拔,需要的时候再抛出异常
        // throw new RuntimeException("模拟异常");
    }
}

然后是主函数:

@Transactional
public void save10(User user) {
    userMapper.insert(user);
    priceService.save();
}

解决方法: 不使用@Transactinal注解或者使用事务传播特性

我们来看看嵌套事务的各种场景:

场景一:

@Transactional
public void save10(User user) {
    userMapper.insert(user);
    priceService.save();
    if (true) {
        throw new RuntimeException("模拟异常");
    }
}

主函数插入数据成功,嵌套函数插入数据成功,但主函数抛出异常,此时所有数据都会回滚

场景二:

@Transactional
public void save10(User user) {
    userMapper.insert(user);
    if (true) {
        throw new RuntimeException("模拟异常");
    }
    priceService.save();
}

主函数插入数据成功,嵌套函数未执行前就抛出异常,所以嵌套函数没有插入数据,又因为主函数异常,所以主函数数据回滚

场景三:

嵌套函数内抛出异常:

@Service
public class PriceService {
    ...
    @Transactional
    public void save() {
        ...
        userMapper.insert(user);
        throw new RuntimeException("模拟异常");
    }
}
@Transactional
public void save10(User user) {
    userMapper.insert(user);
    priceService.save();
}

同场景一,数据都会回滚。

嵌套事务场景下,如何回滚指定事务

复用上面的三个场景,出现异常时,都是回滚所有数据,现在我们希望回滚指定事务,

比如主函数异常,则回滚主函数的数据修改,不回滚嵌套函数;

或者嵌套函数异常,则回滚嵌套函数的数据修改,不回滚主函数;

我们可以使用@Transactional注解的事务传播来做到这点:

  1. 主函数回滚,嵌套函数不回滚(前提是主函数抛出异常)

    将嵌套函数的事务传播设置为:

    @Transactional(propagation = Propagation.NOT_SUPPORTED)
    

    意思是当前上下文存在事务时,挂起当前事务,然后方法在没有事务的环境下执行

  2. 主函数不回滚,嵌套函数回滚(前提是嵌套函数抛出异常)

    主函数不使用@Transactional注解即可,则主函数没有开启事务,而嵌套函数是有事务的,所以会回滚

十、编程式事务

上面使用@Transactional注解的方式叫做声明式事务,除此之外,我们还可以使用Spring提供的另一种创建事务的方式,即通过手动编写代码实现事务,这种方式叫编程式事务

示例:

@Autowired
private TransactionTemplate transactionTemplate;
​
public void save11(User user) {
    // 带返回值的事务管理
    Object execute = transactionTemplate.execute(new TransactionCallback<Object>() {
        @Override
        public Object doInTransaction(TransactionStatus transactionStatus) {
            userMapper.insert(user);
            priceService.save();
​
            return null;
        }
    });
​
    // 不带返回值的事务管理
    transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        public void doInTransactionWithoutResult(TransactionStatus transactionStatus) {
            userMapper.insert(user);
            priceService.save();
        }
    });
}

编程式事务的好处:

  • 可以避免Spring AOP导致的事务失效问题
  • 能够以更小的粒度来控制事务范围

十一、事务中使用锁失效

原因: 锁的释放和事务的提交时间不一致,导致在并发场景下事务失效

复现问题:

假设用户1的余额只有100

@Transactional
public synchronized void save12(int userId) {
    User user = userMapper.selectByPrimaryKey(userId);
    // 用户有足够的余额可以扣款
    if (user.getPrice() >= 100.f) {
        user.setPrice(user.getPrice() - 100);
        userMapper.updateByPrimaryKey(user);
    } else {
        throw new RuntimeException("余额不足");
    }
}

这个例子并不是很恰当,但是也能体现出问题,synchroinzed给事务方法上了锁,按道理来说用户扣款只会触发一次,但是我们通过JMETER并发测试可以发现,方法给用户扣了多次款(其实和商品超卖是一个道理),这是为什么呢?

我们已经知道了AOP是基于动态代理实现的,该事务方法是由代理对象去执行,并且提交事务,可在上面的示例中,synchronized锁,锁的是事务方法,该方法执行完后锁就释放了,这时候在高并发场景下,有可能会出现锁释放了,但事务还没有提交的情况,这时候就会有另一条线程进入了这个事务方法,并且去执行业务逻辑,导致用户多次扣款(或者商品超卖)现象的出现。

额外说明:事务方法执行前,是先开启事务,再加的锁

解决办法:

在调用事务方法前加锁

public synchronized void save12(int userId) {
    save12Operation(userId);
}
​
@Transactional
public void save12Operation(int userId) {
    User user = userMapper.selectByPrimaryKey(userId);
    // 用户有足够的余额可以扣款
    if (user.getPrice() >= 100.f) {
        user.setPrice(user.getPrice() - 100);
        userMapper.updateByPrimaryKey(user);
    } else {
        throw new RuntimeException("余额不足");
    }
}

参考资料

Spring常见知识点——Spring事务失效及其原因

Spring事务失效的各种场景(13种)

深入理解TransactionTemplate编程式事务