下单成功后要发短信、写日志、更新报表、通知库存。直接写在同一个方法里,方法越来越长;拆成事件发出去,又会出现"消息先于事务提交发出去"、"异步监听挂了没人知道"、"同一事件被处理三次"。事件机制的 API 只有三个类,踩的点却全在时机和线程上。
这篇按"角色 → 时机 → 事务边界 → 异步 → 异常与可追溯"拆,最后说清什么时候该换成 MQ。
1. 三个角色
| 角色 | 类型 | 职责 |
|---|---|---|
| 发布者 | ApplicationEventPublisher |
调用 publishEvent(),ApplicationContext 已实现该接口 |
| 事件对象 | 任意 POJO | 传递数据;继承 ApplicationEvent 是旧写法 |
| 监听器 | ApplicationListener<E> / @EventListener |
接收并处理,方法级注解更常用 |
public record OrderCreatedEvent(Long eventId, Long orderId, Long userId, Instant at) { }
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
public OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; }
public void create(OrderCmd cmd) {
Long orderId = orderRepository.save(cmd);
publisher.publishEvent(new OrderCreatedEvent(
IdWorker.next(), orderId, cmd.userId(), Instant.now()));
}
}
@Component
public class OrderCreatedListener {
@EventListener
public void on(OrderCreatedEvent e) {
log.info("order created: {}", e.orderId());
}
}
事件对象用 POJO/record,不要为了"规范"去继承 ApplicationEvent:Spring 4.2 之后已支持任意对象,继承反而让 record 用不了。
2. 同步执行:默认就在发布线程里
| 属性 | 默认 | 含义 |
|---|---|---|
| 执行线程 | 发布方线程 | 不换线程,监听器同步跑完才返回 |
| 执行顺序 | @Order 升序 |
没有注释则顺序不保证 |
| 异常处理 | 向发布方传播 | 监听器抛异常 → publishEvent 抛异常 |
| 事务 | 与发布方同一事务 | 监听器里的写操作会一起回滚 |
@Service
public class OrderService {
public void create(OrderCmd cmd) {
orderRepository.save(cmd);
publisher.publishEvent(new OrderCreatedEvent(...)); // 同步,抛异常会中断 create
// 后面的代码可能永远不执行
}
}
这条默认行为是很多 bug 的起点:监听器里查库失败,发布方整个业务也失败。事件本该"通知",却变成了"串行调用"。
3. @Async 监听的启用条件
| 条件 | 说明 |
|---|---|
@EnableAsync 存在 |
在配置类或启动类上,缺了它 @Async 完全不生效 |
| 方法经过代理 | 自调用不异步,和 @Transactional 同理 |
| 返回类型 | void 或 Future(CompletableFuture 亦可) |
| 线程池 | 未定义 TaskExecutor Bean 时用 SimpleAsyncTaskExecutor |
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
@Bean("eventExecutor")
public ThreadPoolTaskExecutor eventExecutor() {
ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
e.setCorePoolSize(8);
e.setMaxPoolSize(16);
e.setQueueCapacity(200);
e.setThreadNamePrefix("event-");
e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return e;
}
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return (ex, method, params) -> log.error("async failed: {}", method, ex);
}
}
@Component
public class SmsListener {
@Async("eventExecutor")
@EventListener
public void on(OrderCreatedEvent e) { smsService.send(e.userId()); }
}
SimpleAsyncTaskExecutor 每次调用都新建线程且不复用,高并发下等于自己造了一个线程泄漏。Boot 3 下如果没有自定义 TaskExecutor,@Async 用的就是它——这是第 8 节第二条坑的根源。
4. @TransactionalEventListener 的四个阶段
| 阶段 | 触发时机 | 监听器所在事务 |
|---|---|---|
BEFORE_COMMIT |
提交前,事务仍开启 | 加入原事务 |
AFTER_COMMIT(默认) |
提交成功后 | 原事务已结束,无活动事务 |
AFTER_ROLLBACK |
回滚后 | 无 |
AFTER_COMPLETION |
提交或回滚后 | 无 |
@Component
public class OrderMessagePublisher {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCommit(OrderCreatedEvent e) {
// 事务已提交,此处的写操作需要自己的事务
outboxMapper.markSent(e.eventId());
}
}
关键点:AFTER_COMMIT 时原事务已提交、连接多已归还,监听器里再做数据库写不会自动加入原事务——要么监听器自己加 @Transactional(propagation = REQUIRES_NEW),要么接受"消息发出但标记失败"的可能,用幂等兜住。
还有一个隐藏开关:@TransactionalEventListener(fallbackExecution = false)(默认)表示没有活动事务时该监听器不执行。在非事务上下文(比如定时任务)里发布同一个事件,监听器会被静默跳过。这个默认值坑过很多人。
5. 事务内发事件的时序问题
@Transactional
public void create(OrderCmd cmd) {
orderRepository.save(cmd);
publisher.publishEvent(new OrderCreatedEvent(...)); // 普通 @EventListener
}
如果监听器是普通 @EventListener 或 @Async,事件在提交之前就被处理:
| 风险 | 场景 |
|---|---|
| 消费者查不到数据 | 监听器在另一个线程/连接查该订单,事务未提交 → 查不到 |
| 消息先发出去,事务回滚 | 用户收到"下单成功"短信,实际订单不存在 |
| 下游系统读到旧状态 | 同库不同连接,隔离级别下不可见 |
正确写法只有一个方向:要么用 @TransactionalEventListener(AFTER_COMMIT),要么用本地消息表在同一个事务里落库、再由调度器投递。
@Transactional
public void create(OrderCmd cmd) {
orderRepository.save(cmd);
outboxRepository.insert(new OutboxRecord(cmd)); // 同一事务落库,可靠
}
@Scheduled(fixedDelay = 5000)
public void relay() { outboxRelay.sendPending(); }
6. 顺序与异常
| 控制点 | 手段 | 注意 |
|---|---|---|
| 监听器顺序 | @Order / Ordered |
仅对同步监听器有意义 |
| 异步监听器顺序 | 无法保证 | 线程池调度决定 |
| 同步异常 | 传播给发布方 | 需要隔离就 try-catch 或改异步 |
| 异步异常 | 默认被吞 | 必须配 AsyncUncaughtExceptionHandler |
| 事件对象被多监听器共用 | 各自处理 | 无"消费一次"语义 |
@Order(1) @EventListener
public void audit(OrderCreatedEvent e) { }
@Order(2) @EventListener
public void notify(OrderCreatedEvent e) { }
事件是广播语义:所有匹配的监听器都会收到同一个对象引用,没有"队头取走即消费"的排他性。需要"一次且仅一次"的处理,事件机制给不了,要靠幂等键去重。
7. 事件的形状与可追溯性
| 字段 | 作用 |
|---|---|
eventId |
幂等键、日志关联、去重 |
| 业务主键 | 方便监听器回查(不要塞整个实体) |
| 发生时间 | 排查时序 |
| 事件版本 | 多监听器并存时区分语义 |
public record OrderCreatedEvent(String eventId, Long orderId, Long userId,
Instant occurredAt, int version) {
public static OrderCreatedEvent of(Long orderId, Long userId) {
return new OrderCreatedEvent(UUID.randomUUID().toString(), orderId, userId,
Instant.now(), 1);
}
}
不要往事件里塞大对象:事件在同步监听时是引用传递,但在异步场景下会被放进队列,直到被处理前都占着堆。塞一个 10MB 的列表进事件,队列深度 200,就是 2G 的隐性占用。
8. 排错表
| 现象 | 原因 | 处理 |
|---|---|---|
@Async 完全不生效 |
缺 @EnableAsync;或自调用 |
加注解;换 Bean 调用 |
| 异步监听里查不到刚写的数据 | 发布方事务未提交 | 改 @TransactionalEventListener |
| 消息发出去了但订单回滚 | 普通监听在提交前处理 | 同上,或本地消息表 |
| 异步任务报错无日志 | 异常被吞 | 配 AsyncUncaughtExceptionHandler |
| 事件没人接收 | fallbackExecution=false 且无事务 |
显式传 fallbackExecution = true |
| 队列满后事件丢失 | 拒绝策略为 DiscardPolicy |
用 CallerRunsPolicy 或扩队列 |
| 同一事件被处理多次 | 广播语义 + 重试 | 用 eventId 做幂等 |
9. 事件与 MQ 的分界
| 维度 | Spring 事件 | MQ |
|---|---|---|
| 范围 | 同进程内 | 跨进程 |
| 可靠性 | 无持久化,进程挂了就没了 | 有持久化与重投 |
| 顺序 | 同步有序、异步无序 | 取决于分区策略 |
| 背压 | 队列满即拒 | 由 broker 缓冲 |
| 运维成本 | 零 | 需要集群与监控 |
| 适用 | 同 JVM 内解耦 | 跨服务、需重投 |
别把事件当可靠消息用。事件的价值是把"下单"和"发短信"在代码层面解耦;一旦跨服务、一旦需要重投与死信,就该上 MQ。中间形态是本地消息表 + outbox 投递,它在同库事务内保证"至少一次"。
10. 边界
| 场景 | 建议 |
|---|---|
| 同进程内的旁路逻辑(日志、缓存刷新) | Spring 事件,同步即可 |
| 必须与主流程同事务 | 普通 @EventListener |
| 提交后才该做的事(发消息、通知) | @TransactionalEventListener(AFTER_COMMIT) |
| 跨服务通知 | MQ + 幂等 |
| 大量短任务 | 直接提交线程池,不必绕事件 |
| 需要重试与死信 | MQ |
一句话:事件解决的是"同一进程内谁关心这件事",不解决"这件事一定被处理"。把这两件事混在一起,才是事件机制出问题的根源。
// 最小验证:确认监听器真的被注册了
@EventListener(ApplicationReadyEvent.class)
public void dump(ApplicationReadyEvent e) {
Arrays.stream(ctx.getBeanNamesForType(ApplicationListener.class))
.forEach(n -> log.info("listener: {}", n));
}
11. 自测
- 默认的
@EventListener在哪个线程执行?监听器抛异常会怎样? @TransactionalEventListener默认是哪个阶段?此时监听器里的写操作在哪个事务里?- 在事务方法里用普通
@EventListener发"下单成功",最坏会出现什么? - 不配
TaskExecutor时@Async用哪个执行器?它的代价是什么? fallbackExecution = false加在非事务上下文里会看到什么现象?- 什么情况下应该放弃 Spring 事件改用 MQ?