1. 遇到的问题 (The Problem)
在开发文件上传和异步处理功能时,遇到了两个核心问题,都导致了事务回滚(Rollback)失效:
-
现象一(
try-catch陷阱): 在一个标记了@Transactional的方法中,当核心业务(如insertWordHeading)抛出异常时,虽然catch块捕获了异常并返回false,但之前执行的数据库插入(documentMapper.insert)没有回滚。 -
现象二(异步陷阱): 将核心业务(
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 陷阱
-
方案一(推荐):在
catch块中重新抛出运行时异常让 Spring AOP 知道"出错了"。
Java} catch (ServiceException e) { log.error("解析文件目录失败"); // ... 其他清理 ... // 关键:重新抛出,触发回滚 throw new RuntimeException("文件处理失败,触发事务回滚", e); } -
方案二(次选):手动标记事务回滚
如果业务逻辑必须
catch异常并正常返回。
Java} catch (ServiceException e) { log.error("解析文件目录失败"); // 关键:手动通知 Spring 回滚 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); return false; }
3.2 解决"异步事务"陷阱(架构级)
核心原则:不要试图让一个事务跨越多个线程。 应该将业务解耦为两个独立的事务。
最佳实践:使用 @Async + 独立 Bean
-
事务一(同步,主线程): 负责"接收"任务。
-
执行快速的检查(如 MD5、容量)。
-
将文件保存到磁盘。
-
在数据库中插入主记录 (
BidDocument),并将其状态设为PROCESSING(处理中)。 -
提交事务一。
-
-
调用异步服务(AOP 生效)
-
将耗时的任务(
processDocument)放到另一个@ServiceBean(例如DocumentProcessorService)中。 -
在主 Service 中注入 并调用 这个新 Service 的方法。这确保了调用是通过 AOP 代理的。
-
-
事务二(异步,子线程): 负责"执行"任务。
-
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 事务的"线程绑定"特性是两个最容易被忽视的"陷阱"。
永远记住:
-
try-catch会阻止事务回滚,除非你重新抛出异常或手动标记回滚。 -
this.method()调用会绕过 AOP 代理,让@Transactional和@Async失效。 -
事务不会跨线程传播。 异步处理必须设计为多个独立事务 ,并通过状态机(如
PROCESSING,COMPLETED,FAILED)和消息队列(或@Async)来解耦。