订单状态刚更新完,接口突然 500。
日志往下一翻,不是数据库挂了,也不是 RPC 超时,而是一个八竿子打不着的"发积分"监听器炸了。
2026-08-26 14:37:21.608 ERROR order-api
payment confirm failed, orderNo=OD882731
java.lang.IllegalStateException: points rule not found
at PointsListener.onPaid(PointsListener.java:43)
at ...
at SimpleApplicationEventMulticaster.invokeListener(...)
看到 SimpleApplicationEventMulticaster 这行,我基本就知道问题往哪查了。
Spring Event 又被当 MQ 用了。
这种代码我见过不少:
@Transactional
public void confirmPaid(String orderNo){
Order order = orderRepository.lockByOrderNo(orderNo);
order.markPaid();
eventPublisher.publishEvent(
new OrderPaidEvent(order.getId(), orderNo, order.getBuyerId())
);
}
然后另外一个类里:
@Component
public class PointsListener{
@EventListener
public void onPaid(OrderPaidEvent event){
pointsService.grant(event.buyerId(), event.orderId());
}
}
代码看着挺舒服。
订单模块不用依赖积分模块,publish 一下,监听器自己处理,甚至还有点"事件驱动架构"的味道。
问题是,Spring Event 默认根本不是你脑子里那个 MQ。
publishEvent() 默认是同步调用。
也就是说,上面的真实调用链差不多是:
HTTP线程
-> confirmPaid()
-> publishEvent()
-> PointsListener.onPaid()
-> pointsService.grant()
-> confirmPaid()继续执行
监听器没跑完,publishEvent() 就不会回来。
更麻烦的是,监听器抛 RuntimeException,异常可以直接沿调用栈往外冒。
而 confirmPaid() 上面正好还有一个 @Transactional。
于是就出现了很恶心的一幕:
订单支付逻辑本来没问题。
积分模块因为一条脏配置报错,订单事务跟着回滚。
业务那边看到的是"支付确认失败",你查半天支付代码,最后发现锅在一个监听器里。
这种解耦,我一般不认。
类之间确实没直接引用了,失败倒是绑得比以前还紧。
如果积分只是支付后的附加动作,我至少会先把事务边界拆开。
Spring 提供了 @TransactionalEventListener:
@Component
public class PaidAuditListener{
private final PaidAuditService auditService;
public PaidAuditListener(PaidAuditService auditService){
this.auditService = auditService;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void record(OrderPaidEvent event){
auditService.append(event.orderId(), event.orderNo());
}
}
这样监听器只在原事务提交成功之后触发。
订单都没提交,后面的动作也没必要跑。
但这里还有个坑。
AFTER_COMMIT 不是"开启一个新事务"。
原事务已经提交了,这时候你还想在监听器里继续写数据库,我一般不会赌当前事务资源的状态,直接把真正的写操作放进新事务:
@Service
public class PaidAuditService{
private final PaidAuditRepository repository;
public PaidAuditService(PaidAuditRepository repository){
this.repository = repository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void append(Long orderId, String orderNo){
repository.insert(orderId, orderNo, "PAID");
}
}
这个地方代码多两行没什么。
事务边界看不清,线上排起来才是真的贵。
还有一种改法更常见:
@Async("bizEventExecutor")
@EventListener
public void onPaid(OrderPaidEvent event){
pointsService.grant(event.buyerId(), event.orderId());
}
加个 @Async,接口确实不等监听器了。
但我看到这种改法,第一反应不是"好了",而是先找线程池配置。
因为你只是把坑从请求线程挪到了异步线程。
@Bean("bizEventExecutor")
public ThreadPoolTaskExecutor bizEventExecutor(){
ThreadPoolTaskExecutor pool = new ThreadPoolTaskExecutor();
pool.setCorePoolSize(4);
pool.setMaxPoolSize(12);
pool.setQueueCapacity(300);
pool.setThreadNamePrefix("biz-event-");
pool.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
pool.initialize();
return pool;
}
线程数、队列长度、拒绝策略,一个都不能靠感觉。
不然流量一上来,队列堆满,你会看到这种日志:
TaskRejectedException:
Executor [biz-event-] did not accept task
然后问题又来了。
这个事件丢了怎么办?
应用重启了怎么办?
执行到一半机器挂了怎么办?
消费失败怎么重试?
怎么查某个订单的事件到底处理过没有?
如果这些问题你都开始认真回答,那我劝你别继续给 Spring Event 打补丁了。
你需要的八成已经是 MQ。
Spring Event 我现在用得比较克制。
同一个 JVM 内部,监听方挂了也无所谓,失败可以接受,数据不用持久化,主要目的是拆开模块依赖——这种场景用它很顺手。
比如刷新本地缓存、刷新内部状态、记录一些非关键指标。
但订单、支付、库存、退款、发券这种核心链路,只要你开始要求"不能丢、能重试、可追踪、跨实例",我不会拿 Spring Event 硬顶。
还有个细节我也比较嫌弃。
别把 JPA Entity、DO 之类的对象直接塞进 Event:
eventPublisher.publishEvent(order);
后面监听器一多,谁改了对象、谁触发了懒加载、事务什么时候结束,全搅在一起。
我更愿意发一个不可变快照:
public record OrderPaidEvent(
Long orderId,
String orderNo,
Long buyerId
){}
事件里只放监听器真正需要的东西。
少一点"方便",后面就少一点玄学。
Spring Event 本身没什么问题。
真正容易出事的是,代码里写着 publishEvent(),脑子里想的却是 Kafka。
看到 Event,先问一句:这个监听器失败,能不能把当前业务一起带死?
这个问题答不清,我一般不会让它上线。