沉浸式体验事务的一周

26 阅读5分钟

背景

业务逻辑

单据A上做了审核逻辑,具体包含以下大逻辑:

  1. 占用单据B的额度,如果校验不通过则不允许审核通过;
  2. 回写单据A的状态;

调用逻辑

  1. 用户手工在前台页面点击提交,日常业务会大批量进行,故提出需求要求部分成功部分失败;
  2. 后台定时任务多线程调用;

问题现象

生产环境大批量出现单据B的额度占用,但是单据A的状态还是未审核;

代码逻辑


public class AServiceImpl implements AService {
  
    @Autowired
    private BService bService;
 
    @Transactional
    @Override
    public Map<String, Object> auditA(List<String> ids) throws Exception{
        try{
            //占用单据B的额度
            bService.occupyB(ids);
            //修改单据A的审核状态
            updateA(ids);
        }catch (Exception e) {
            //map放入错误信息
            return map;
        }); 
    }
    
    public void syncTaskExecute(){
        //取符合调度任务执行的数据
        List<String> id =  queryA();
        //创建线程池
        ExecutorService executor = Executors.newFixedThreadPool(5);
        List<Future<String>> futures = new ArrayList<>();
        for(String id : ids){
            Future<String> future = executor.submit(() -> {
                try{
                    this.auditA();
                    return id;
                }catch (Exception e) {
                   log.error("审核异常+++ ", e); 
                   //记录日志
                } finally {
                    return mainCode;
                } 

            });
            futures.add(future);
        }
        list.forEach(future -> {
            try {
                future.get();
            } catch (Exception e) {
               log.error("多线程执行任务发生异常+++ ", e); 
            }); 
        }
     }
}

public class BServiceImpl implements BService {
     
    public Map<String, Object> occupyB(List<String> ids) throws Exception{
        this.checkB(ids);
        this.saveB(ids);
    }
    
    private void checkB(List<String> ids) throws Exception{
         
    }
    
    private  void saveB(List<String> ids) throws Exception{
        this.checkB(ids);
        this.saveB(ids);
    }
}

处理步骤

DAY1

  1. 先提供SQL修改单据A的状态未已审核之后再手动取消审核,保证业务正常处理;
  2. 排查代码未发现明显的bug,由于是近期发现的偶发性问题,再观察观察;

DAY2

  1. 再次复现之后分析出来是 AService.auditA(ids) 为了实现部分成功部分失败把异常吃掉导致事务没有回滚
  2. 与用户沟通为了保障审核的安全性,将实现部分成功部分失败更改为一条校验不通过都不成功;
  3. 将catch里边的将异常throw出来; 测试通过后打补丁到生产上;

DAY3

    • 业务上发现大多是定时任务调用的单据出现的报错问题;
    • 怀疑到多线程提交的地方是按照单条去调用,且多线程内部将异常捕捉导致事务未回滚;
    • 修改AService.auditA(ids)方法单独开启事务,修改注解@Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED),做到多线程调用的时候每条是独立事务;

DAY4

  1. 将AService.auditA(ids)方法的占用单据B的额度和修改单据A的审核状态中间手动写了个异常(int i = 1/0;)热部署到服务上,执行业务操作复现了此问题,问题定位到事务上,于是 将BService.occupyB(ids)方法上加上注解 @Transactional; 重复业务操作后问题仍存在;
  2. 重点排查BService.occupyB(ids)的事务控制,发现存在方法内调的saveB(ids)是本方法的private修饰的,不参与事务控制,故修改BService.occupyB(ids)中调用this.saveB(ids)方法提到上层方法中;重复业务操作后问题仍存在;
  3. 后一直在排查事务方面的问题,包括框架是否有整体的事务控制等等。最终在绝望的时候发现是 异常抛的不对:
    throw new Exception("审核失败:"+e.getMessage());

总结

事务配置:

springBoot事务支持全注解和传统XML两种配置模式,一般项目上会统一配置;

排查思路

  1. 检查类或方法是否有 @Transactional 注解

    • 类级别:该类下所有 public 方法默认开启事务。 方法级别:仅对当前方法生效。
  2. 同一个类内直接调用非 public 方法,确保调用方式正确(避免内部调用绕过代理)

    • 原因: Spring 使用的是基于 AOP 的动态代理,默认只有通过外部调用才会触发事务控制。 类内部调用会绕过代理对象,导致事务失效。
    • 解决方案: 将 methodB() 提取到另一个 Service 中。 或者使用 AopContext.currentProxy() 获取代理对象调用。
  3. 检查异常是否被吞掉或捕获但未抛出:

    • 是否 catch 异常后没有重新 throw?
    • 默认情况下,Spring 只对 unchecked exception(RuntimeException 和 Error) 回滚。
    • 如果你抛出的是 checked exception(如 IOException),需要显式配置@Transactional(rollbackFor = Exception.class):
  4. 检查事务传播行为(propagation)

    propagation描述
    REQUIRED如果有事务则加入,没有则新建(默认)
    REQUIRES_NEW总是新建事务,挂起已有事务
    SUPPORTS支持事务,无事务则以非事务方式执行
  5. 是否使用了不支持事务的操作

    • 比如方法底层调用了RPC接口或者API接口,无法回滚;
  6. 检查事务配置是否启用

    • 在 Spring Boot 中,事务是默认启用的。但在 XML 配置或老项目中需要手动开启;

注意事项:

  • try-catch 异常后未抛出,不会触发事务回滚;

  • 非 public 方法使用 @Transactional 事务不会生效;

  • 同一个类中调用带事务的方法,由于代理机制,事务可能不会生效;简单来说就是AService下的A方法(public)和B方法(非 public)都有@Transactional注解,但是A方法调用B方法时,事务不生效;

  • Spring 中的事务管理是基于 AOP 实现的,默认只对 unchecked exceptions(非受检异常) 回滚,因此throw new Exception不会触发事务回滚:

    异常类型是否默认回滚示例
    RuntimeException 及其子类✅ 是RuntimeException 及其子类
    Error 及其子类✅ 是OutOfMemoryError, VirtualMachineError 等
    其他异常(如 Exception)❌ 否IOException, SQLException 等

    可以手动通过@Transactional 注解的 rollbackFor 让所有 Exception 都触发回滚: @Transactional(rollbackFor = Exception.class)