Spring 事务(@Transactional)与异步(@Async / CompletableFuture)结合的陷阱与最佳实践

1. 遇到的问题 (The Problem)

在开发文件上传和异步处理功能时,遇到了两个核心问题,都导致了事务回滚(Rollback)失效:

  1. 现象一(try-catch 陷阱): 在一个标记了 @Transactional 的方法中,当核心业务(如 insertWordHeading)抛出异常时,虽然 catch 块捕获了异常并返回 false,但之前执行的数据库插入(documentMapper.insert没有回滚

  2. 现象二(异步陷阱): 将核心业务(formatFileDir)放入 CompletableFuture.supplyAsync() 中异步执行后,该方法内的 @Transactional 完全失效。不仅失败时不会回滚,而且其内部的数据库操作(如 insert)会立即提交,破坏了事务的原子性。

2. 根源分析 (The Root Cause)

2.1 陷阱一:try-catch 吞没了异常,导致 Spring AOP 误判
  • @Transactional 的原理: Spring 事务是通过 AOP 代理实现的。它在方法执行前启动事务,在方法执行后根据方法的退出状态 来决定 Commit 还是 Rollback

  • "成功"的假象:

    • 正常退出(return): Spring 认为业务成功,执行 Commit

    • 异常退出(throw Exception): Spring 捕获到异常,执行 Rollback

  • 问题所在: 我们的 catch 块捕获了 ServiceException 后,执行了日志记录,然后正常返回了 false 。对于 Spring 的 AOP 代理来说,这是一个"正常退出",它根本不知道 内部发生了错误,因此它继续执行了 Commit

2.2 陷阱二:异步执行破坏了事务的"两大支柱"

支柱一:事务的 ThreadLocal 隔离性

  • Spring 的事务上下文(Transaction Context)是存储在 ThreadLocal 变量中的。

  • 这意味着事务是与当前线程 (例如 Web 请求线程 Thread-A)严格绑定的。

  • 当你使用 CompletableFuture@Async 时,任务会被抛到**另一个线程(Thread-B)**中执行。

  • Thread-B 是一个"干净"的线程,它没有 Thread-A 的事务上下文。因此,Thread-B 上的所有操作都不在 Thread-A 的事务范围内。

支柱二:AOP 代理的"自调用(Self-Invocation)"失效

  • 问题: 为什么 formatFileDir 方法(它自己也标了 @Transactional)在 Thread-B 上没有启动一个事务呢?

  • 答案: 因为调用方式是 this.formatFileDir(...)

  • @Transactional 生效,依赖于 Spring AOP 代理对象。当你从外部(如 Controller)调用 Service 方法时,你调用的是代理对象,代理对象会先启动事务,再调用你的真实方法。

  • 但是,当你在一个类的内部 ,使用 this 来调用该类的另一个方法 时,你绕过了 AOP 代理 ,直接调用了真实对象的真实方法。

  • 结论: formatFileDir 方法上的 @Transactional 注解根本没有被触发 ,所以它运行在默认的 auto-commit(自动提交)模式下。这就是为什么 insert 会被立即提交。


3. 解决方案与最佳实践 (The Solution & Best Practices)

3.1 解决 try-catch 陷阱
  1. 方案一(推荐):在 catch 块中重新抛出运行时异常

    让 Spring AOP 知道"出错了"。
    Java

    复制代码
    } catch (ServiceException e) {
        log.error("解析文件目录失败");
        // ... 其他清理 ...
    
        // 关键:重新抛出,触发回滚
        throw new RuntimeException("文件处理失败,触发事务回滚", e);
    }
  2. 方案二(次选):手动标记事务回滚

    如果业务逻辑必须 catch 异常并正常返回。
    Java

    复制代码
    } catch (ServiceException e) {
        log.error("解析文件目录失败");
    
        // 关键:手动通知 Spring 回滚
        TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
    
        return false;
    }
3.2 解决"异步事务"陷阱(架构级)

核心原则:不要试图让一个事务跨越多个线程。 应该将业务解耦为两个独立的事务

最佳实践:使用 @Async + 独立 Bean

  1. 事务一(同步,主线程): 负责"接收"任务。

    • 执行快速的检查(如 MD5、容量)。

    • 将文件保存到磁盘。

    • 在数据库中插入主记录BidDocument),并将其状态设为 PROCESSING(处理中)

    • 提交事务一

  2. 调用异步服务(AOP 生效)

    • 将耗时的任务(processDocument)放到另一个 @Service Bean(例如 DocumentProcessorService)中。

    • 在主 Service 中注入调用 这个新 Service 的方法。这确保了调用是通过 AOP 代理的。

  3. 事务二(异步,子线程): 负责"执行"任务。

    • DocumentProcessorService 中的 @Async 方法上标记 @Transactional

    • Thread-B 执行此方法时,它会启动一个全新的事务(事务二)

    • 执行耗时的 insertWordHeading

    • 成功:更新 BidDocument 状态为 COMPLETED(完成)

    • 失败:@Transactional 捕获异常,自动回滚事务二insertWordHeading 中的所有操作)。

    • (推荐)在 catch 块中捕获异常,并以新事务@Transactional(propagation = Propagation.REQUIRES_NEW))更新主记录状态为 FAILED(失败)

示例代码结构:

Java

复制代码
// *** BidDocumentServiceImpl (主服务) ***
@Service
public class BidDocumentServiceImpl implements IBidDocumentService {
    
    @Autowired
    private DocumentProcessorService processorService; // 注入新服务
    @Autowired
    private DocumentMapper documentMapper;

    @Override
    @Transactional(rollbackFor = Exception.class) // 事务一
    public String uploadDocument(...) {
        // ... 检查、上传、容量计算 ...

        // 事务一:插入状态为 "处理中" 的记录
        BidDocument document = new BidDocument();
        document.setStatus(-1); // -1 = PROCESSING
        // ... set other fields ...
        documentMapper.insert(document);
        
        // 事务一:更新用户容量...
        userMapper.updateBidDocumentSizeByUserId(userId, fileSize);

        // 事务一 在此提交
        
        // 【关键】调用 另一个Bean 的 异步方法 (AOP会生效)
        processorService.processDocument(document.getId(), uploadPath, userId);

        return uuid;
    }
}

// *** DocumentProcessorService (异步处理服务) ***
@Service
public class DocumentProcessorService {

    @Async // 标记为异步
    @Transactional(rollbackFor = Exception.class) // 事务二
    public void processDocument(Long documentId, String uploadPath, Long userId) {
        try {
            // 事务二:执行所有耗时的、可能失败的 DB 操作
            int level = 10;
            insertWordHeading(uploadPath, documentId, userId, level);
            
            // 事务二:更新状态为 "完成"
            updateStatus(documentId, 0); // 0 = COMPLETED
            
            // 事务二 在此提交
        } catch (Exception e) {
            log.error("文档处理失败,ID: {}", documentId, e);
            // 事务二 已自动回滚
            
            // 独立事务:更新状态为 "失败"
            updateStatusInNewTransaction(documentId, -2); // -2 = FAILED
            
            // 必须抛出异常,否则 @Async 的 Future.get() 无法获知失败
            throw new RuntimeException("Processing failed", e); 
        }
    }
    
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void updateStatusInNewTransaction(Long documentId, int status) {
        // ... 更新状态 ...
    }
}

4. 总结与反思

@Transactional@Async 都是基于 AOP 代理。当它们结合使用时,AOP 的"自调用失效"和 Spring 事务的"线程绑定"特性是两个最容易被忽视的"陷阱"。

永远记住:

  1. try-catch 会阻止事务回滚,除非你重新抛出异常或手动标记回滚。

  2. this.method() 调用会绕过 AOP 代理,让 @Transactional@Async 失效。

  3. 事务不会跨线程传播。 异步处理必须设计为多个独立事务 ,并通过状态机(如 PROCESSING, COMPLETED, FAILED)和消息队列(或 @Async)来解耦。

相关推荐
m0_565611131 小时前
Java高级特性:单元测试、反射、注解、动态代理
java·单元测试·log4j
雾林小妖2 小时前
springboot实现跨服务调用/springboot调用另一台机器上的服务
java·spring boot·后端
百***58142 小时前
Windows操作系统部署Tomcat详细讲解
java·windows·tomcat
老葱头蒸鸡2 小时前
(4)Kafka消费者分区策略、Rebalance、Offset存储机制
sql·kafka·linq
Boop_wu2 小时前
[Java EE] 多线程 -- 初阶(3)
java·开发语言
员大头硬花生2 小时前
九、InnoDB引擎-MVCC
数据库·sql·mysql
一条闲鱼_mytube2 小时前
mvcc 简介
数据库
稻香味秋天2 小时前
单元测试指南
数据库·sqlserver·单元测试
JosieBook2 小时前
【数据库】Apache IoTDB数据库在大数据场景下的时序数据模型与建模方案
数据库·apache·iotdb