Spring 事务与 @Async 的致命邂逅:一次生产级数据不一致问题复盘

127 阅读5分钟

一次典型的 Spring 事务与异步任务冲突 Bug 排查实录

在开发一个在线考试系统时,我们遇到了一个隐蔽但严重的业务逻辑错误:学生提交答案后,系统打分结果偶尔不正确。经过深入排查,最终定位到一个经典问题——Spring 事务与异步任务的上下文隔离问题。本文将完整还原这次 Bug 的发现、分析与修复过程。

📌 前提说明:本系统数据库(MySQL)的隔离级别已显式设置为 READ COMMITTED(读已提交)。


一、现象:打分结果“有时对,有时错”

业务流程很简单:

  1. 学生提交答案;
  2. 系统保存答案;
  3. 异步给学生打分(计算得分并更新成绩表)。

但测试中发现:部分学生的得分基于旧答案计算,导致分数错误。更诡异的是,这个问题不是每次都出现,具有随机性。


二、初步怀疑:并发 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)
t1BEGIN
t2UPDATE answers SET content='B'
t3调用 grade()BEGIN
t4SELECT content FROM answers → 读到 'A'(旧值)
t5COMMIT

💥 关键点
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 之后),也能执行。

七、经验总结

  1. 事务是线程绑定的
    Spring 事务依赖 ThreadLocal,跨线程即失效。

  2. @Async 与 @Transactional 天然冲突
    异步任务无法看到主事务中未提交的数据,这是数据库事务的基本语义,与隔离级别无关(除非使用 READ UNCOMMITTED)。

  3. READ COMMITTED 不能解决此问题
    它只保证不读“未提交数据”,但不等于能读“同应用未提交数据”

  4. 正确做法:先提交,再异步
    要么拆分方法确保事务提交,要么使用 afterCommit 回调。

  5. 传播机制在此无效
    事务传播只在同一线程内生效,异步场景下无需考虑 REQUIRES_NEW 等设置。


八、延伸思考

此类问题不仅出现在打分场景,还常见于:

  • 用户注册后发送欢迎邮件;
  • 支付成功后更新积分;
  • 订单创建后推送消息。

只要涉及“异步任务需要读取刚修改的数据”,就必须警惕事务提交时机!


📌 记住这条黄金法则
异步任务若需读取最新数据,必须确保其触发时机在主事务 COMMIT 之后。
否则,你面对的将是一个“偶发性、难复现、高危害”的生产事故。

通过这次排查,我们不仅修复了一个 Bug,更深入理解了 Spring 事务、数据库隔离级别与并发模型的本质。这正是高质量系统开发的必经之路。