XXL-Job 中 @Transactional 失效导致 afterCommit 注册失败

这次事故来自一个凌晨执行的定时任务。

表面现象是一条异常:

java.lang.IllegalStateException: Transaction synchronization is not active

真正的问题不是数据库更新失败,也不是外部系统推送失败,而是代码想注册“事务提交后再执行”的回调时,当前线程压根没有事务同步上下文。

这种问题挺有迷惑性,因为出问题的方法外面确实写了 @Transactional。代码看起来像是在事务里执行,实际在线上 XXL-Job 调度链路里,这个注解没有真正生效。

1. 问题是怎么发现的

2026-07-23 凌晨,线上 XXL-Job 执行项目任务状态变更定时任务失败,错误日志大致如下:

[KdbProjectTaskStatusChangeTask] 任务执行失败
java.lang.IllegalStateException: Transaction synchronization is not active
    at org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(...)
    at com.lsym.app.platform.job.KdbProjectTaskStatusChangeTask.lambda$updateProjectTaskStatus$6(...)
    at com.lsym.app.platform.job.KdbProjectTaskStatusChangeTask.updateProjectTaskStatus(...)
    at com.lsym.app.platform.job.KdbProjectTaskStatusChangeTask.executeTaskRule(...)
    at com.lsym.app.platform.job.KdbProjectTaskStatusChangeTask.executeTask(...)
    at com.lsym.infrastructure.schedule.support.BaseScheduleTaskHandler.execute(...)
    at com.xxl.job.core.thread.JobThread.run(...)

从堆栈看,异常发生在 KdbProjectTaskStatusChangeTask#updateProjectTaskStatus 中,出错点是:

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCommit() {
        pushWdzStoreCreate(storeIdForCreate);
    }
});

这个定时任务负责扫描项目任务规则,根据开始时间和截止时间推进任务状态。这次任务失败后,影响是部分任务状态没有按计划自动推进,相关的预警和外部系统同步也没有触发。

2. 最初容易误判的点

看到异常时,第一反应很容易是:

updateProjectTaskStatus 没有加 @Transactional,所以没有事务。

但这不是本质原因。

因为外层方法是这样的:

@Override
@Transactional(rollbackFor = Exception.class)
public void executeTask() {
    executeTaskRule();
    changeOverdueTaskStatus();
}

如果 executeTask() 的事务真的生效,那么它里面调用的普通方法 executeTaskRule()、updateProjectTaskStatus() 都应该在同一个事务上下文里。内层方法不需要每个都加 @Transactional。

也就是说,问题不是:

updateProjectTaskStatus 没有 @Transactional

而是:

executeTask 上的 @Transactional 在线上调度链路中没有真正生效

这两件事差别很大。

如果误判为“内层方法缺事务注解”,继续给 updateProjectTaskStatus() 加 @Transactional,大概率仍然解决不了问题。因为它还是可能被同类内部的 this.updateProjectTaskStatus(...) 调用,依然绕过 Spring AOP。

3. 根因:XXL-Job 调度链路绕过了 Spring AOP 事务代理

Spring 的 @Transactional 默认依赖 AOP 代理。

它生效的前提是:方法调用要经过 Spring 代理对象。

比如这种通常会生效:

@Service
public class DemoService {

    @Transactional(rollbackFor = Exception.class)
    public void doBiz() {
        // 数据库操作
    }
}

@Component
public class DemoJob {

    private final DemoService demoService;

    public void execute() {
        demoService.doBiz(); // 经过 Spring Bean 代理
    }
}

但这种就不可靠:

public void execute() {
    this.doBiz();
}

@Transactional(rollbackFor = Exception.class)
public void doBiz() {
    // 数据库操作
}

因为 this.doBiz() 是对象内部调用,不会经过 Spring AOP 代理。

本次定时任务的实际调用链路是:

XXL-Job JobThread
  -> BaseScheduleTaskHandler.execute()
  -> this.executeTask()
  -> executeTaskRule()
  -> updateProjectTaskStatus()
  -> TransactionSynchronizationManager.registerSynchronization(...)

关键点在:

BaseScheduleTaskHandler.execute() 内部通过 this.executeTask() 调用子类方法

这种父类内部调用子类方法的方式,可能绕过 Spring 事务代理。

所以 executeTask() 方法上虽然写了 @Transactional,但实际执行时没有事务拦截器介入,也就没有开启事务同步。

当代码执行到:

TransactionSynchronizationManager.registerSynchronization(...)

Spring 会检查当前线程是否已经开启事务同步。如果没有,就直接抛:

Transaction synchronization is not active

这就是整个事故的根因。

4. 为什么 registerSynchronization 必须依赖事务上下文

TransactionSynchronizationManager.registerSynchronization(...) 的语义是:

注册一个当前事务生命周期内的同步回调。

比如:

TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
    @Override
    public void afterCommit() {
        sendMessage();
    }
});

它表达的是:

数据库事务提交成功后,再发送消息

这个设计本身是合理的。

因为外部推送、MQ、企微、第三方系统同步这类动作,通常不能早于数据库提交。否则可能出现:

数据库最终回滚了
外部系统却已经收到成功消息

但它有一个前提:当前线程必须真的存在事务。

如果没有事务,Spring 不知道这个回调应该挂在哪个事务上,也不知道什么时候执行 afterCommit,所以只能抛异常。

这次出问题的代码正是:

想注册 afterCommit
但当前没有事务

5. 修复方案

本次采用最小改动修复:使用已有的 TransactionTemplate 显式开启事务。

修复前:

@Override
@Transactional(rollbackFor = Exception.class)
public void executeTask() {
    // 先进行流程引擎规则、再改变任务状态
    executeTaskRule();
    changeOverdueTaskStatus();
}

修复后:

@Override
public void executeTask() {
    // XXL-Job 由 BaseScheduleTaskHandler 内部 this.executeTask() 触发时可能绕过 Spring AOP,
    // 这里用显式事务兜住任务状态更新和 afterCommit 回调注册,避免 registerSynchronization 无事务上下文。
    transactionTemplate.executeWithoutResult(status -> {
        // 先进行流程引擎规则、再改变任务状态。
        executeTaskRule();
        changeOverdueTaskStatus();
    });
}

6. 这次事故的总结

这次事故最值得记住的不是某一行代码怎么改,而是这个判断:

看到 @Transactional,不等于真的有事务。

事务是否存在,要看调用链有没有经过 Spring 代理。

在 Web Controller 调 Service 的场景里,这件事通常不容易踩坑;但在定时任务、异步线程、消息消费、框架回调、父类模板方法这些场景里,调用链一变,事务注解就可能变成“看起来在,实际上没进去”。

以后遇到定时任务里需要“提交后再做某件事”的逻辑,优先问三个问题:

当前线程真的有事务吗?
这个方法调用经过 Spring 代理了吗?
如果数据库回滚,外部副作用会不会已经发生?

这三个问题问清楚,很多类似事故就能提前避开。