很多新手第一次把 AI 用到 Spring 项目里时,会遇到一个很常见的需求:
用户下单后,先保存订单;再异步发送通知、写操作日志、刷新统计数据。
主流程不要被这些非核心操作拖慢。
于是,AI 很容易给出类似写法:
@ServicepublicclassOrderService{@TransactionalpublicvoidcreateOrder(CreateOrderCommandcommand){Orderorder=orderRepository.save(Order.create(command));notificationService.sendOrderCreated(order.getId());orderRepository.markCreated(order.getId());}}通知服务写成:
@ServicepublicclassNotificationService{@AsyncpublicvoidsendOrderCreated(LongorderId){Orderorder=orderRepository.findById(orderId);messageSender.send("订单已创建:"+order.getOrderNo());}}从代码表面看,这个方案好像很合理:
- 创建订单放在事务里;
- 发通知放在异步线程里;
- 主线程不用等待;
@Async看起来已经处理了异步问题。
但线上跑一段时间后,你可能会遇到非常奇怪的现象:
- 异步通知偶尔查不到刚创建的订单;
- 订单事务最终回滚了,但通知已经发出;
- 异步任务执行失败,主流程完全不知道;
- 某些订单被正常创建,但统计数据没有更新;
- 本地测试正常,测试环境偶尔出错;
- 同一批数据在高并发下出现“已通知但订单不存在”的短暂状态。
这些问题的根源通常不是@Async写错了。
而是开发者误以为:
异步线程会自动继承调用它的事务。
实际上,它通常不会。
一、先理解一件事:事务和线程不是同一个东西
在常见的 Spring 使用方式里,事务上下文通常与当前执行线程绑定。
简化理解可以写成:
主线程 ↓ 开启事务 ↓ 执行数据库操作 ↓ 提交或回滚事务而@Async的逻辑会进入线程池中的另一个线程:
主线程: 开启事务 ↓ 保存订单 ↓ 提交事务 异步线程: 获取线程池线程 ↓ 执行通知逻辑 ↓ 查询订单 / 写日志 / 调用外部服务它们不是同一个线程。
也就意味着:
- 主线程里的事务不会自动搬到异步线程;
- 异步线程看到的数据,取决于主事务是否已经提交;
- 异步线程抛出的异常,不会自动让主事务回滚;
- 异步线程中的数据库操作,也不会自动纳入主事务。
如果把时序展开,问题会更直观。
T1:主线程开始事务 T2:主线程保存订单记录 T3:主线程提交异步任务 T4:异步线程开始执行 T5:异步线程查询订单 T6:主线程事务提交如果 T5 发生在 T6 之前,异步线程就可能查询不到这条订单。
因为从数据库可见性角度看,主事务还没有提交。
二、最常见的误区:只要加了@Async,逻辑就会自动可靠
很多 AI 生成的代码会默认做下面这件事:
@TransactionalpublicvoidcreateOrder(CreateOrderCommandcommand){Orderorder=orderRepository.save(Order.create(command));asyncService.sendOrderCreated(order.getId());}而异步方法则直接读取数据库:
@AsyncpublicvoidsendOrderCreated(LongorderId){Orderorder=orderRepository.findById(orderId);messageSender.send(order.getOrderNo());}问题在于,这里存在三个独立风险。
| 风险 | 为什么会发生 | 表现形式 |
|---|---|---|
| 事务未提交 | 异步线程先于主线程执行 | 查不到订单或读到旧数据 |
| 主事务回滚 | 异步任务已经启动 | 通知已发,但订单不存在 |
| 异步执行失败 | 异常不会自动回传主线程 | 主流程成功,后续动作丢失 |
很多开发者会想:
那我就在异步方法里加
@Transactional。
例如:
@Async@TransactionalpublicvoidsendOrderCreated(LongorderId){Orderorder=orderRepository.findById(orderId);notificationRepository.save(NotificationRecord.of(orderId));}这样做会开启一个新的事务,但它并不能“继承主事务”。
它只代表:
- 异步线程里的数据库操作,有自己独立的事务;
- 它和订单创建事务不是同一个原子单元;
- 两边谁先提交、谁后失败,依旧需要被单独设计。
三、另一个常见坑:同类内部调用时,@Async可能根本没生效
还有一种更隐蔽的情况。
开发者把方法写在同一个类里:
@ServicepublicclassOrderService{@TransactionalpublicvoidcreateOrder(CreateOrderCommandcommand){Orderorder=orderRepository.save(Order.create(command));sendOrderCreatedAsync(order.getId());}@AsyncpublicvoidsendOrderCreatedAsync(LongorderId){messageSender.send("order="+orderId);}}你可能以为sendOrderCreatedAsync()会进入异步线程。
但在很多基于代理的实现中,同类内部直接调用不会经过 Spring 代理。
结果就是:
createOrder() ↓ 直接调用 sendOrderCreatedAsync() ↓ 仍然运行在主线程这时不但没有异步,甚至可能出现:
- 主线程被发送通知阻塞;
- 事务持续时间变长;
- 外部调用耗时把数据库连接占住;
- 通知失败导致主事务是否回滚变得模糊;
- 代码和实际运行行为完全不一致。
所以,看到@Async不代表异步一定生效。
先要确认:
这个方法是否经过 Spring 代理调用? 它是否在另一个 Bean 中? 是否真的进入了线程池? 日志里的线程名是否发生变化?四、正确思路:把“事务完成”与“异步动作”显式连接起来
对于“订单创建成功后再发送通知”这类场景,比较清晰的方式是:
- 主事务只负责完成核心数据写入;
- 事务成功提交后,再触发后续动作;
- 后续动作失败时,有独立的记录、重试和监控;
- 外部通知不能默认和数据库事务天然一致。
例如,先定义事件:
publicrecordOrderCreatedEvent(LongorderId){}主事务内只保存订单并发布事件:
@ServicepublicclassOrderService{privatefinalApplicationEventPublishereventPublisher;@TransactionalpublicvoidcreateOrder(CreateOrderCommandcommand){Orderorder=orderRepository.save(Order.create(command));eventPublisher.publishEvent(newOrderCreatedEvent(order.getId()));}}然后监听事务提交后的事件:
@ComponentpublicclassOrderCreatedListener{@TransactionalEventListener(phase=TransactionPhase.AFTER_COMMIT)publicvoidonOrderCreated(OrderCreatedEventevent){notificationService.sendAsync(event.orderId());}}异步执行放到另一个 Bean 中:
@ServicepublicclassNotificationService{@AsyncpublicvoidsendAsync(LongorderId){Orderorder=orderRepository.findById(orderId);if(order==null){thrownewIllegalStateException("order not found: "+orderId);}messageSender.send("订单已创建:"+order.getOrderNo());}}这样至少保证了一点:
只有订单事务已经提交成功后,异步通知才会被触发。
但这里仍然有工程边界。
如果通知发送失败,订单并不会自动回滚。
因此,关键不是强行让所有动作塞进一个事务,而是明确哪些动作属于核心一致性,哪些动作属于可重试的后续处理。
五、不要把外部调用塞进数据库事务里
很多新手为了“保证一致性”,会把外部通知放进事务中:
@TransactionalpublicvoidcreateOrder(CreateOrderCommandcommand){Orderorder=orderRepository.save(Order.create(command));messageSender.send("订单已创建:"+order.getOrderNo());}这看似解决了“异步任务太早执行”的问题。
但会引入另一组风险:
- 外部服务超时,事务一直不提交;
- 数据库连接长期占用;
- 通知服务抖动,会拖慢订单主流程;
- 外部消息已经发送成功,但后续数据库事务失败;
- 事务重试时,外部通知可能被重复发送。
这说明:
外部调用和数据库事务不是简单地“放在一起就一致”。
更稳妥的方向通常是把后续动作变成可追踪事件。
例如使用 Outbox 模式:
订单表写入 + 事件表写入 ↓ 同一个数据库事务提交 ↓ 独立任务读取事件表 ↓ 发送消息或通知 ↓ 记录发送结果 ↓ 失败后重试或人工处理简化后的事件表可以这样定义:
CREATETABLEoutbox_event(idBIGINTPRIMARYKEYAUTO_INCREMENT,event_typeVARCHAR(64)NOTNULL,aggregate_idBIGINTNOTNULL,payloadTEXTNOTNULL,statusVARCHAR(32)NOTNULL,retry_countINTNOTNULLDEFAULT0,created_atDATETIMENOTNULL,sent_atDATETIMENULL);这不是说每一个异步动作都必须引入完整 Outbox。
而是要先判断:
- 通知丢失是否可以接受;
- 通知重复是否可以接受;
- 失败后是否可以人工补发;
- 是否需要记录完整发送历史;
- 是否属于资金、库存、权益等关键链路。
六、让 AI 先区分线程边界、事务边界和业务边界
如果只问 AI:
订单创建后怎么异步发通知?它很可能给你一段@Async代码。
这段代码可能能运行,但未必覆盖你真正需要的边界。
更有效的问法是:
你是 Spring 事务与异步任务评审助手。 场景: 订单创建成功后,需要异步发送通知并写入操作日志。 订单创建必须保证数据库事务一致; 通知允许延迟,但不能无记录丢失; 通知失败后需要可重试; 通知不能在订单事务回滚时提前发送。 请不要直接只给 @Async 代码。 请完成: 1. 区分主事务、异步线程、外部通知之间的边界; 2. 说明 @Async 是否会继承调用方事务; 3. 设计事务提交后触发后续动作的方式; 4. 判断是否需要事件表或 Outbox; 5. 列出异常、重试、重复发送和人工补发的处理方式; 6. 给出至少 6 个测试场景; 7. 标出需要由业务方确认的风险。这类 Prompt 的价值,不是让 AI 生成更多注解。
而是让它先把问题拆成:
线程问题 事务问题 消息可靠性问题 业务一致性问题对刚开始使用 ChatGPT Plus 做代码解释、事务排查和测试设计的开发者来说,工具接入准备不只是会不会复制一段异步代码,还包括能否明确线程边界、保留异常记录、验证失败路径和回看执行结果。
第一次把 AI 工具纳入开发工作流时,建议把使用说明、异常处理和信息留存方式一起整理;相关准备项可按实际需要参考:gpt328com
七、至少要补齐这些测试场景
事务和异步问题,最怕只验证“通知是否发送成功”。
更应该覆盖这些场景:
| 测试场景 | 预期结果 |
|---|---|
| 订单事务成功提交 | 异步通知在提交后触发 |
| 订单事务回滚 | 不触发通知 |
| 异步线程抢先执行 | 不会在提交前读取订单 |
| 通知发送失败 | 失败可记录、可重试 |
| 同一订单重复触发 | 不重复发送或有幂等控制 |
同类内部调用@Async | 能识别异步是否未生效 |
| 线程池拒绝任务 | 有明确异常与补偿路径 |
| 外部通知超时 | 不阻塞核心订单事务 |
| 消息重试成功 | 状态和审计记录一致 |
例如,可以验证事务回滚后不会触发监听:
@TestvoidshouldNotSendNotificationWhenOrderTransactionRollsBack(){CreateOrderCommandcommand=invalidOrderCommand();assertThrows(BusinessException.class,()->orderService.createOrder(command));verify(notificationService,never()).sendAsync(anyLong());}再验证事务提交后才触发:
@TestvoidshouldSendNotificationAfterOrderTransactionCommitted(){CreateOrderCommandcommand=validOrderCommand();orderService.createOrder(command);await().atMost(Duration.ofSeconds(3)).untilAsserted(()->verify(notificationService).sendAsync(anyLong()));}测试里不能只验证方法调用次数。
还要确认:
- 订单是否已真实提交;
- 通知记录是否可追踪;
- 失败后有没有进入补偿链路;
- 重试是否造成重复副作用;
- 线程池满载时系统如何表现。
八、上线后必须让异步状态可观察
异步任务最危险的状态是:
主流程成功了,但后续动作悄悄失败了。
因此,至少应记录:
async_task_submitted_total async_task_rejected_total async_task_success_total async_task_failed_total async_task_retry_total async_task_pending_count outbox_event_pending_count outbox_event_oldest_age_seconds需要重点关注:
- 提交了多少异步任务;
- 有多少任务被线程池拒绝;
- 有多少任务失败后没有重试;
- 待处理事件积压了多久;
- 是否存在订单已创建但通知长期未发送;
- 重试数量是否突然上升。
不要只看服务是否正常启动。
异步链路的问题,往往发生在高峰期、线程池繁忙、外部依赖抖动或重启恢复之后。
九、结语
@Transactional和@Async都是很有用的工具。
但它们解决的问题并不一样:
@Transactional负责当前线程中的数据库一致性;@Async负责把任务交给另一个线程执行;- 它们不会自动拼成一个跨线程、跨服务、绝对一致的执行单元。
AI 可以快速帮你生成异步代码、补齐监听器、写测试案例。
但真正要由开发者确认的是:
- 哪些动作必须与主事务一起成功;
- 哪些动作可以延迟、重试或人工补偿;
- 异步失败后谁来发现;
- 重复发送是否可接受;
- 运行时线程池满了会发生什么;
- 是否需要事件表、Outbox 或更明确的状态记录。
异步不是“丢到后台就结束”。
真正可靠的异步,是即使任务晚到、失败、重试或重启后恢复,系统也知道它应该怎么继续。