异常与事务的三个小坑(开发中常见)

在实际项目中,使用事务是很简单的,例如在Spring Boot项目(或者说是Spring项目)中,一个@Transactional 注解就可以解决。但是事务有很多小坑在等着我们。

1. 异常没有被捕获到。

在开发过程中,业务层已经考虑到了异常,或者编辑器已经提示我们抛出异常了,但是,并不是我们把异常抛出来了,有异常了事务就会回滚。例子。

@Service
public class UserServiceImpl implements UserService {

   @Resource
   private UserMapper userMapper;
   
   @Override
   @Transactional
   public void isertUser(User user) throws Exception {
       // 插入用户信息
       userMapper.insertUser(user);
       // 手动抛出异常
       throw new SQLException("数据库异常");
   }
}

手动抛出一个 SQLException 来模拟实际中操作数据库发生的异常,在这个方法中,既然抛出了异常,那么事务应该回滚,实际却不如此,读者可以自己测试一下就会发现,仍然是可以往数据库插入一条用户数据的。

那么问题出在哪呢?因为 Spring Boot 默认的事务规则是遇到运行异常(RuntimeException)和程序错误(Error)才会回滚。比如上面我们的例子中如果抛出的 RuntimeException 就没有问题,但是抛出 SQLException 就无法回滚了。

针对非运行时异常如果要进行事务回滚的话,可以在 @Transactional 注解中使用 rollbackFor 属性来指定异常,比如:

@Transactional(rollbackFor = Exception.class)

这样就没有问题了,所以在实际项目中,一定要指定异常,这是大部分开发人员不注意的地方。

2. 异常被吃掉了

在现实项目中,我们在处理异常时,有两种方式,要么抛出去,让上一层来捕获处理;要么把异常 try...catch 掉,在异常出现的地方给处理掉。就因为有这个 try...catch,所以导致异常被 “吃” 掉,事务无法回滚。我们还是看上面那个例子,只不过简单修改一下代码:

@Service
public class UserServiceImpl implements UserService {

   @Resource
   private UserMapper userMapper;

   @Override
   @Transactional(rollbackFor = Exception.class)
   public void isertUser(User user) {
       try {
           // 插入用户信息
           userMapper.insertUser(user);
           // 手动抛出异常
           throw new SQLException("数据库异常");
       } catch (Exception e) {
           // 异常处理逻辑
       }
   }
}

测试发现,仍是可以插入一条数据的,说明事务并没有因为异常抛出而回滚。这就是 try...catch 把异 “吃” 掉了,这个细节往往比上面那个坑更难以发现,因为我们的思维方式很容易导致 try...catch 代码的产生,一旦出现这种问题,往往排查起来比较费劲。这个就是很明显的自己给自己挖坑,而且自己掉进去之后,还出不来。

那这种怎么解决呢?直接往上抛,给上一层来处理即可,千万不要在事务中把异常自己 ”吃“ 掉

3. 别忘了事务是有范围的

事务范围这个东西比上面两个坑埋的更深!我之所以把这个也写上,是因为这是我之前在实际项目中遇到的,该场景我就不模拟了,我写一个 demo 让大家看一下,把这个坑记住即可,以后在写代码时,遇到并发问题,如果能想到这个坑,那么这篇文章也就有价值了。

@Service
public class UserServiceImpl implements UserService {

   @Resource
   private UserMapper userMapper;

   @Override
   @Transactional(rollbackFor = Exception.class)
   public synchronized void isertUser4(User user) {
       // 实际中的具体业务……
       userMapper.insertUser(user);
   }
}

可以看到,因为要考虑并发问题,在业务层代码的方法上加了个 synchronized 关键字。我举个实际的场景,比如一个数据库中,针对某个用户,只有一条记录,下一个插入动作过来,会先判断该数据库中有没有相同的用户,如果有就不插入,就更新,没有才插入,所以理论上,数据库中永远就一条同一用户信息,不会出现同一数据库中插入了两条相同用户的信息。 

但是在压测时,就会出现上面的问题,数据库中确实有两条同一用户的信息,那说明 synchronized 并没有起到作用。分析其原因,在于事务的范围和锁的范围问题。

从上面方法中可以看到,方法上是加了事务的,那么也就是说,在执行该方法开始时,事务启动,执行完了后,事务关闭。但是 synchronized 没有起作用,其实根本原因是因为事务的范围比锁的范围大。也就是说,在加锁的那部分代码执行完之后,锁释放掉了,但是事务还没结束,就在此时另一个线程进来了,事务没结束的话,第二个线程进来时,数据库的状态和第一个线程刚进来是一样的。即由于mysql Innodb引擎的默认隔离级别是可重复读(在同一个事务里,SELECT的结果是事务开始时时间点的状态),线程二事务开始的时候,线程一还没提交完成,导致读取的数据还没更新。第二个线程也做了插入动作,导致了脏数据。

这个问题可以避免,第一,把事务去掉即可(不推荐);第二,在调用该 service 的地方加锁,保证锁的范围比事务的范围大即可。

写在后面:

 这三个小坑在实际开发中经常遇到,希望能给读者一些启发,如果你觉得有用,请转发给更多的人。

 


转自:微信公众号《程序人生》---- 你的异常别被自己 “吃” 掉了都不知道!

你可能感兴趣的:(日积月累)