一个Spring Boot订单系统,原本通过一个事务完成订单创建、库存记录更新和业务状态写入。为了缩短接口响应时间,团队让Codex把其中一段耗时操作改成异步执行。
改造前,接口需要等待所有数据库操作完成才能返回。改造后,接口响应明显变快,单元测试也全部通过。
但上线几天后,生产环境开始出现异常:部分订单已经显示创建成功,库存记录却没有更新;另一些订单的主表数据已经提交,关联业务记录却一直缺失。查看应用日志,异步线程中偶尔出现数据库异常,但原始HTTP请求早已返回成功。
开发人员检查了数据库事务配置,没有发现明显错误。订单服务仍然有@Transactional,异步方法也没有删除事务注解,为什么数据还是会出现部分提交?
真正的问题不一定是事务注解失效,而是Agent改变了业务操作的执行边界,却没有同步重新设计事务边界、异常传播和失败恢复机制。
在Spring Boot中,事务、异步执行和业务成功并不是同一个概念。方法从当前线程切换到线程池后,原有事务通常不会自动传播过去。如果一个完整业务流程被拆成多个独立执行阶段,那么每个阶段都可能有自己的提交结果。
这也是AI生成代码时容易被忽略的工程风险:代码看起来只是增加了异步能力,实际改变的却可能是数据一致性的保证方式。
一、为什么加了异步执行,原来的事务就不一定管用了?
先看一个典型的订单流程。
原本,订单服务在一个事务中完成订单记录和相关数据库状态的更新。只有所有必要操作成功,事务才提交;如果中间发生符合回滚规则的异常,整个事务可以回滚。
为了提高响应速度,Agent把库存处理改为异步执行。新的执行顺序可能变成:
-
主线程创建订单记录。
-
主线程提交异步任务。
-
主线程继续执行并提交自己的事务。
-
异步线程开始处理库存相关操作。
-
异步线程遇到数据库异常,任务执行失败。
此时,订单事务可能已经成功提交,而异步操作还没有完成。
这不是数据库突然失去了事务能力,而是原本看似完整的一组操作,已经被拆成了不同的执行阶段。
Spring常规的命令式事务管理通常将事务上下文绑定到当前执行线程。新线程不会自动继承原线程的事务上下文。因此,异步线程中的数据库操作不能仅凭调用方存在@Transactional,就被认为属于同一个事务。
需要注意,异步方法本身仍然可以通过独立的事务配置开启自己的事务。问题在于,这个新事务与调用方的事务不是天然的同一个事务。
事务注解仍然存在,不代表原有的业务原子性仍然存在。
二、第一种部分提交:主事务成功,异步任务失败
这是最容易在生产环境出现的情况。
假设创建订单需要完成三个动作:写入订单主表、更新库存数据、记录订单状态变更。
改造前,三个数据库操作都在同一个事务中。如果库存更新失败,符合回滚规则的异常可以让整个事务回滚。
改造后,主线程先创建订单,再把库存更新交给异步线程执行。
此时可能出现以下结果:
-
订单主表已经提交。
-
库存更新任务已经进入线程池。
-
异步任务执行时发生数据库异常。
-
调用方却已经向用户返回成功。
从HTTP接口来看,请求成功了;从业务整体来看,订单却处于不完整状态。
更重要的是,异步任务失败不一定会让调用方知道。对于返回void的@Async方法,异常通常无法直接传播给原始调用方,需要配置异步异常处理机制。即使异常被记录在日志里,也不代表系统会自动重新执行任务或修复数据。
如果业务没有任务状态、失败重试和补偿机制,这类数据就可能长期处于不一致状态。
因此,异步化之前必须回答一个问题:调用方返回成功时,业务究竟承诺了什么?
如果接口承诺订单和库存都已完成,就不能仅仅因为异步任务成功提交到线程池,就提前返回完整业务成功。
如果业务允许订单先创建、库存稍后处理,那么就必须明确这种中间状态,并提供状态查询、失败恢复和最终一致性机制。
三、第二种部分提交:主事务回滚了,异步操作却已经完成
还有一种情况更隐蔽。
主线程开始创建订单,并提交一个异步任务。异步线程执行得很快,已经完成库存记录或其他数据库写入。
随后,主线程执行剩余操作时发生异常,订单事务回滚。
最终数据库可能变成:
订单不存在,但库存侧已经产生对应记录。
这就是执行时序造成的另一种不一致。
原来的同步流程可以利用一个事务协调同一数据库事务范围内的写操作;异步化之后,主线程和异步线程的提交时间不再同步。
即使两个线程分别使用事务,也不代表它们会共同提交或共同回滚。
这也是为什么不能简单地把耗时操作放进线程池,就认为事务会在后台继续保持原样。
对于同一个数据库中的强一致业务,如果这些写操作必须一起成功或一起失败,通常应该先保留在同一个事务边界内,再考虑优化SQL、缩短事务时间或调整业务流程。
如果确实需要拆成异步步骤,就必须接受并设计相应的一致性机制,而不是依赖原来的@Transactional注解。
四、第三种问题:@Transactional与@Async同时存在,为什么仍然容易出错?
一些项目会直接在同一个方法上同时添加@Transactional和@Async,认为这样既能异步执行,又能保留事务。
但这两个注解解决的是不同的问题。
@Transactional声明事务语义,由Spring事务基础设施在适当的方法调用边界上管理事务;@Async则将方法调用交给异步执行器处理。
在典型的代理模式下,两个拦截机制如何作用于方法调用,以及实际执行的方法是否经过Spring代理,都会影响最终行为。
例如,如果一个类中的普通方法直接调用同一个类里标有@Async的方法,这属于自调用。默认代理模式下,调用没有经过相应代理拦截器时,异步执行可能根本没有生效。
@Transactional也存在类似限制:同一个类内部直接调用标注了@Transactional的另一个方法,默认代理模式通常不会因此开启新的事务拦截。
另外,即使异步方法确实运行在独立线程中,也要确认事务注解实际作用于这个异步执行的方法,而不是仅仅存在于外层调用方。
因此,不能只检查代码里有没有两个注解,还需要确认实际调用链、线程切换位置和事务创建位置。
注解声明的是意图,代理拦截和运行时执行路径才决定这些机制是否真正生效。
五、为什么异步任务抛出异常,接口仍然返回成功?
这通常不是事务本身的问题,而是异常传播方式改变了。
同步调用时,如果业务方法抛出异常,调用方可以捕获异常、记录错误,或者让异常继续向外传播。在事务边界内,符合回滚规则的异常还可能触发事务回滚。
但异步调用不同。
如果任务通过线程池执行,原始HTTP请求和后台任务已经分离。后台任务执行时发生异常,通常不会像同步调用那样直接返回到已经结束的请求线程。
尤其是返回void的异步方法,如果没有配置合适的异常处理机制,异常可能只被记录下来,而调用方无法通过普通返回值判断任务是否成功。
如果异步方法返回Future或CompletableFuture,调用方可以通过结果对象观察执行结果,但这也不意味着原始事务会自动延长到异步任务完成。
更不能为了获取异常而在主线程中立即阻塞等待异步结果,否则可能失去异步执行原本希望获得的响应时间收益。
正确做法是根据业务语义选择结果管理方式:
-
不要求立即完成的后台任务,可以使用持久化任务状态和独立的失败处理机制。
-
必须确认操作完成后才能返回成功的业务,应保留同步确认或采用明确的协调流程。
-
允许最终一致的业务,则应提供重试、幂等和补偿能力。
接口返回成功、任务提交成功和业务处理成功,是三个不同的状态。
六、为什么简单增加重试,可能让数据问题更严重?
发现异步任务失败后,Agent有时会顺手增加重试逻辑。
但重试只有在失败原因可以恢复、重复执行安全的情况下,才可能真正改善可靠性。
假设异步任务更新库存时,数据库已经完成提交,但客户端在收到响应前发生网络超时。应用无法确定这次操作是否成功,于是重新执行任务。
如果没有幂等控制,就可能重复扣减库存或重复写入业务记录。
另一个问题是,重试不能自动修复主事务和异步任务之间的顺序错误。如果订单已经回滚,但异步操作仍然执行,那么重复执行只会让不一致状态持续存在。
因此,异步任务至少需要明确三个条件:
第一,任务是否可以安全地重复执行。
第二,任务失败后如何确定当前业务状态。
第三,重试达到上限后,任务进入什么状态,由谁负责恢复。
可以使用业务唯一键、任务ID、幂等记录或数据库唯一约束,避免重复处理同一业务操作。具体方案应结合业务模型设计,不能仅仅在方法外层加一个重试注解。
七、怎样判断这次改造是否真的破坏了事务边界?
排查时,不要一开始就调整事务传播级别,或者把所有方法都标注为@Transactional。
建议先恢复实际执行顺序。
第一,确认主线程在哪个位置开启事务、执行写入和提交事务。
第二,确认异步任务在什么时候被提交到线程池,以及任务真正开始执行的时间。
第三,确认异步线程是否经过Spring代理,以及对应的事务管理器是否创建了独立事务。
第四,检查异常发生时,主事务和异步事务分别处于什么状态。
第五,检查业务成功的判断条件,确认是否把任务入队误认为任务执行完成。
如果使用的是同一个数据库,可以结合事务日志、SQL日志和任务执行记录验证各阶段的提交情况。
如果异步任务还调用了HTTP接口、消息队列或其他外部系统,就需要进一步确认哪些操作不受当前数据库事务控制。
例如,数据库事务回滚并不能自动撤销已经发送的HTTP请求,也不能保证已经被其他系统消费的消息自动消失。
真正的排查重点,是确定每一个副作用发生在哪里、由哪个事务控制,以及失败后是否存在恢复路径。
八、正确的改造方向:先确定哪些操作必须一起成功
并不是所有业务都应该禁止异步化。真正需要避免的是,在没有重新定义业务一致性要求的情况下,直接改变原有执行方式。
如果订单创建和某些数据库写入必须一起成功,就应尽量将这些必要写操作保留在同一个事务中。
如果某个操作只是发送通知、生成报表或触发后续处理,可以在业务事务成功后再异步执行。
对于只需要"提交任务后稍后处理"的业务,也可以明确返回处理中状态,而不是提前承诺所有业务步骤已经完成。
一个常见的可靠设计,是把核心业务写入与待处理事件记录放在同一个数据库事务中。事务提交后,由后台任务读取事件并执行后续处理。
这样,即使后台任务暂时失败,待处理事件仍然保存在数据库中,可以通过重试和任务状态管理继续处理。
这类设计通常称为Transactional Outbox。
它的价值在于:核心业务数据与待发送事件可以在同一个数据库事务中提交,避免出现业务已提交、事件却因为进程崩溃而完全丢失的窗口。
但Outbox也不是自动保证端到端恰好执行一次。后台任务可能重复投递事件,消费端仍然需要幂等处理,并设计失败重试、积压监控和过期事件清理机制。
九、用一个指标判断异步改造是否成功
这篇只突出一个主指标:业务操作完整成功率。
它衡量业务流程中所有必需步骤最终都完成的比例,而不是仅仅统计HTTP请求成功率或异步任务提交成功率。
例如,订单接口返回成功1000次,但其中有20笔订单缺少必要的库存记录,那么如果这些记录属于业务成功的必要条件,完整成功率就不是100%。
这个例子用于说明指标定义,不代表真实项目的测量结果。
验证时应提前明确哪些步骤属于必需步骤、哪些步骤允许延迟完成,以及什么状态才算最终成功。
如果业务允许订单先进入处理中状态,再异步完成后续操作,那么只要系统能够可靠跟踪并完成这些任务,就不应该把正常处理中状态直接算作失败。
反过来,如果接口已经对外承诺整个业务完成,就必须把后续操作失败纳入业务失败判断。
异步改造的目标不是让接口更快地返回成功,而是在缩短响应时间的同时,仍然兑现业务承诺。
十、怎样让Codex安全地改造事务代码?
不要只给Codex一句"把这个@Transactional方法改成异步,提高接口性能"。
更合理的任务应该先要求Agent梳理业务操作的依赖关系,并标明每一步的副作用、事务边界和失败后果。
接着,要求它分别说明哪些操作必须原子提交,哪些操作允许异步完成,以及每个异步步骤是否支持幂等重试。
如果原有业务必须保持强一致性,就优先优化事务内部的数据库访问和耗时操作,而不是直接把关键写操作移出事务。
如果业务允许最终一致性,则应设计任务状态、可靠事件记录、失败恢复和监控机制,再实施异步改造。
改造完成后,需要验证正常路径、主事务回滚、异步执行失败、任务重复执行、线程池拒绝任务以及应用重启等场景。
还要检查事务代理是否生效、异步异常是否被记录,以及任务状态能否准确反映最终业务结果。
让Agent修改执行方式之前,先让它证明业务一致性不会因为线程切换而被破坏。
十一、Plus和Pro怎么判断?
如果平时主要使用ChatGPT、Codex排查单个Spring Boot服务的事务问题,检查@Transactional、@Async、自调用和异常传播,Plus通常够用。
如果日常需要跨多个微服务分析事务边界、消息投递、Outbox、幂等机制和故障恢复,并持续开展多轮代码改造、集成测试与生产验证,Pro更适合高频、长任务的工程工作流。
套餐选择仍应结合实际任务规模、使用频率和可用额度。无论使用Plus还是Pro,都不能替代事务边界分析、故障注入测试和业务一致性验证。
最后
Agent把@Transactional方法改成异步执行,为什么数据会出现部分提交?
因为异步执行改变了原有调用时序和事务边界。主线程可能已经提交订单,异步任务却仍未完成;异步操作也可能先于主事务提交,导致主事务回滚后留下额外数据。
正确的工程路径是:
明确业务成功条件 → 梳理事务边界 → 确认线程切换 → 设计异常与重试机制 → 建立可靠任务记录 → 验证最终业务一致性。
Spring事务、异步执行和业务一致性分别解决不同问题。不能因为方法仍然标注@Transactional,就认为整个业务流程仍然具有原来的原子性。
随着ChatGPT、Codex越来越擅长重构业务代码,工程风险也会从语法错误转向更深层的执行机制变化。
真正可靠的Agent工程实战,不只是让接口更快,而是让系统在异步、失败、重试和重启等条件下,仍然能够给出正确且可验证的业务结果。
持续分享 Codex、大模型开发与 AI 编程实战内容
也整理了稳定的 Plus/Pro 会员订阅渠道,有需要可自取。