一次典型的 Spring 事务与异步任务冲突 Bug 排查实录
在开发一个在线考试系统时,我们遇到了一个隐蔽但严重的业务逻辑错误:学生提交答案后,系统打分结果偶尔不正确。经过深入排查,最终定位到一个经典问题——Spring 事务与异步任务的上下文隔离问题。本文将完整还原这次 Bug 的发现、分析与修复过程。
📌 前提说明:本系统数据库(MySQL)的隔离级别已显式设置为
READ COMMITTED(读已提交)。
一、现象:打分结果“有时对,有时错”
业务流程很简单:
- 学生提交答案;
- 系统保存答案;
- 异步给学生打分(计算得分并更新成绩表)。
但测试中发现:部分学生的得分基于旧答案计算,导致分数错误。更诡异的是,这个问题不是每次都出现,具有随机性。
二、初步怀疑:并发 or 缓存?
首先想到两个常见原因:
-
数据库并发读写冲突?
检查了 MySQL 隔离级别,确认为READ COMMITTED,理论上不会读到未提交数据(避免脏读),但允许不可重复读。 -
应用层缓存未更新?
检查代码,发现打分逻辑直接查询数据库,无缓存介入。
排除这两点后,问题指向更深层的机制。
三、关键线索:异步任务执行时机
查看相关代码(简化版):
@Service
public class ExamService {
@Transactional
public void updateAnswer(Answer answer) {
answerRepo.save(answer); // 1. 保存新答案
gradeService.grade(answer.getId()); // 2. 异步打分
}
}
@Service
public class GradeService {
@Async
@Transactional
public void grade(Long answerId) {
Answer ans = answerRepo.findById(answerId); // 3. 读取答案
int score = calculate(ans); // 4. 计算得分
scoreRepo.save(new Score(score)); // 5. 保存成绩
}
}
注意到两个关键注解:
@Transactional:开启数据库事务;@Async:在新线程中异步执行。
直觉告诉我们:问题可能出在“事务未提交”和“异步读取”之间的时间差上。
四、深入原理:ThreadLocal 与事务上下文
4.1 Spring 事务如何工作?
Spring 使用 ThreadLocal 绑定事务上下文:
- 当
updateAnswer()被调用时,Spring 开启一个事务,并将数据库连接存入当前线程的 ThreadLocal。 - 同一线程内的所有 DAO 操作都复用这个连接,处于同一事务中。
4.2 @Async 做了什么?
gradeService.grade()被@Async标记,会交给线程池中的新线程执行。- 新线程的 ThreadLocal 是空的,无法感知主线程的事务。
- 尽管
grade()也加了@Transactional,但它会开启一个全新的、独立的事务。
4.3 问题本质:可见性缺失(即使 READ COMMITTED)
执行时序如下:
| 时间 | 主线程(事务 T1) | 异步线程(事务 T2) |
|---|---|---|
| t1 | BEGIN | |
| t2 | UPDATE answers SET content='B' | |
| t3 | 调用 grade() | BEGIN |
| t4 | SELECT content FROM answers → 读到 'A'(旧值) | |
| t5 | COMMIT |
💥 关键点:
在READ COMMITTED隔离级别下,事务 只能读取其他事务已经 COMMIT 的数据。
T2 执行 SELECT 时,T1 尚未 COMMIT,因此 T2 只能看到 COMMIT 前的快照(即旧答案 'A')。
这与REPEATABLE READ在此场景下的行为完全一致!
✅ 结论:
即使隔离级别是READ COMMITTED,只要主事务未提交,异步任务就绝对读不到其修改!
五、验证假设:日志 + 断点调试
我们在 grade() 方法中添加日志:
log.info("开始打分,读取到的答案内容: {}", ans.getContent());
同时在 updateAnswer() 提交前加断点。
结果证实:
- 日志中打印的是旧答案内容;
- 断点处数据库尚未提交;
- 异步任务总是在主事务提交前完成查询。
Bug 原因确认!
六、修复方案:确保“先提交,再异步”
方案一:拆分方法,显式提交事务
@Service
public class ExamService {
// 不加事务
public void updateAnswer(Answer answer) {
self.updateAndSave(answer); // 通过代理调用,确保事务提交
gradeService.grade(answer.getId()); // 再异步
}
@Transactional
public void updateAndSave(Answer answer) {
answerRepo.save(answer);
}
@Autowired
private ExamService self; // 自注入以走代理
}
✅ 优点:简单直观。
⚠️ 注意:必须通过 Spring 代理调用 updateAndSave(),否则 @Transactional 失效。
方案二:使用事务同步回调(推荐)
@Transactional
public void updateAnswer(Answer answer) {
answerRepo.save(answer);
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
gradeService.grade(answer.getId()); // 100% 在 COMMIT 后执行
}
}
);
}
✅ 优点:
- 无需拆分方法;
- 严格保证在事务成功提交后触发异步;
- 即使后续代码抛异常(但在 commit 之后),也能执行。
七、经验总结
-
事务是线程绑定的
Spring 事务依赖ThreadLocal,跨线程即失效。 -
@Async 与 @Transactional 天然冲突
异步任务无法看到主事务中未提交的数据,这是数据库事务的基本语义,与隔离级别无关(除非使用READ UNCOMMITTED)。 -
READ COMMITTED 不能解决此问题
它只保证不读“未提交数据”,但不等于能读“同应用未提交数据”。 -
正确做法:先提交,再异步
要么拆分方法确保事务提交,要么使用afterCommit回调。 -
传播机制在此无效
事务传播只在同一线程内生效,异步场景下无需考虑REQUIRES_NEW等设置。
八、延伸思考
此类问题不仅出现在打分场景,还常见于:
- 用户注册后发送欢迎邮件;
- 支付成功后更新积分;
- 订单创建后推送消息。
只要涉及“异步任务需要读取刚修改的数据”,就必须警惕事务提交时机!
📌 记住这条黄金法则:
异步任务若需读取最新数据,必须确保其触发时机在主事务 COMMIT 之后。
否则,你面对的将是一个“偶发性、难复现、高危害”的生产事故。
通过这次排查,我们不仅修复了一个 Bug,更深入理解了 Spring 事务、数据库隔离级别与并发模型的本质。这正是高质量系统开发的必经之路。