SPRING · APPLICATIONEVENT · @EVENTLISTENER · @TRANSACTIONALEVENTLISTENER
发个广播,谁关心谁来听
下单成功后要加积分、发短信、记统计。下单代码不用认识这些人,只要喊一声“订单付款了”。Spring 的广播站会找出所有关心这件事的监听器,挨个通知。
OrderService.placeOrder()
存好订单,喊一声:
OrderPaidEventApplicationEventMulticaster
按事件类型挑出关心它的监听器,按 @Order 排好,一个个通知。
任意对象说清楚发生了什么。Spring 4.2 起不用再继承 ApplicationEvent。
JDK 17 可以直接用 recordApplicationEventPublisher注入进来调 publishEvent。容器本身就是一个发布者。
ApplicationEventMulticaster默认就在发布者的线程里,一个接一个调监听器。
SimpleApplicationEventMulticaster@EventListener方法参数写什么类型,就听什么事件。
也可以实现 ApplicationListener 接口最少的代码
三样东西:一个事件类,一处 publishEvent,几个带 @EventListener 的方法。OrderService 里没有一行和积分、短信有关的代码。
// JDK 17:一行 record
public record OrderPaidEvent(
Long orderId,
Long userId,
BigDecimal amount) { }
// JDK 8:普通类,
// final 字段 + 构造器 + getter
@Service
public class OrderService {
private final ApplicationEventPublisher publisher;
// 构造器注入省略
@Transactional
public void placeOrder(Order o) {
orderMapper.insert(o);
publisher.publishEvent(
new OrderPaidEvent(o.getId(),
o.getUserId(), o.getAmount()));
}
}
@Component
public class PointsListener {
@Order(1)
@EventListener
public void on(OrderPaidEvent e) {
pointsService.add(e.userId(), 10);
}
}
// 短信、统计监听器同理,
// 各自一个类,互不认识
广播站怎么找到听众
分两个时刻:启动时把所有 @EventListener 方法登记好;发布时按事件类型挑出来,挨个调用。
启动时:登记听众
- 所有单例 Bean 造完后,
EventListenerMethodProcessor扫描每个 Bean 上带 @EventListener 的方法。 - 每个方法包成一个
ApplicationListenerMethodAdapter,记下它听哪种事件、什么条件、什么顺序。 - 登记到广播站。从这以后发出的事件,它们才收得到(第 07 篇讲过启动早期的事件为什么收不到)。
发布时:挨个通知
- 事件不是 ApplicationEvent,就包成
PayloadApplicationEvent。 - 按事件的类型(连泛型一起)挑出关心它的监听器,结果按类型缓存,下次直接用。
- 按
@Order排序,一个个调用;有condition的先算条件。 - 监听方法有返回值,就把返回值当成新事件再发一次。
- 有父容器的话,再往父容器发一遍。
public void multicastEvent(ApplicationEvent event, ResolvableType eventType) {
Executor executor = getTaskExecutor(); // 默认是 null
for (ApplicationListener<?> listener : getApplicationListeners(event, eventType)) { // 挑出来、排好序
if (executor != null) {
executor.execute(() -> invokeListener(listener, event)); // 给广播站配了线程池:全部异步
} else {
invokeListener(listener, event); // 默认:就在发布者的线程里,一个接一个
}
}
}
事件实验台
placeOrder 带着 @Transactional,存订单后发出 OrderPaidEvent。给三个监听器选不同的写法,看谁在哪个线程跑、事务回滚了会怎样、用户要等多久。
三种写法,逐项比
同一个监听方法,加不同的注解,行为差别很大。挑的时候先问自己:这件事要不要和订单一起成败?
| @EventListener | @Async + @EventListener | @TransactionalEventListener | |
|---|---|---|---|
| 在哪个线程 | 发布者的线程 | 线程池里的线程 | 发布者的线程,事务结束之后 |
| 发布者等它吗 | 等,一个接一个 | 不等 | 等,提交后、方法返回前跑完 |
| 在发布者的事务里吗 | 在,同一个数据库连接 | 不在 | 事务已经提交,写库要 REQUIRES_NEW |
| 抛异常会怎样 | 抛回发布者,后面的监听器不再执行,事务回滚 | 只进日志 | AFTER_COMMIT 里抛的,只进日志 |
| 事务回滚了还执行吗 | 已经执行过了 | 已经交给线程池,照样执行 | 不执行 |
| 发布时没有事务 | 照常 | 照常 | 默认直接丢掉,fallbackExecution = true 才执行 |
| 适合 | 必须和主流程一起成败的事 | 慢、可以失败、和主流程无关的事 | 主流程真的成功了才该做的事 |
@Async 加 @TransactionalEventListener:事务提交成功后,才把监听器交给线程池。发短信、发通知这类事最合适:订单回滚了不会发,用户也不用等。实验台里的“提交后 + 异步”就是这种写法。@TransactionalEventListener 的四个时刻
用 phase 选。默认是 AFTER_COMMIT。
方法体存订单,publishEvent:先登记,不执行BEFORE_COMMIT提交前,还在事务里AFTER_COMMIT 提交成功后(默认)AFTER_ROLLBACK 回滚之后AFTER_COMPLETION不管提交还是回滚,结束后都执行几个顺手的细节
@Order(2) // 同步监听器按它排先后
@EventListener(condition = "#event.amount > 1000") // SpEL 条件:只听大额订单
public CouponEvent onBigOrder(OrderPaidEvent event) {
return new CouponEvent(event.userId()); // 返回值会被当成新事件,再广播一次
}
EntityCreated<Order> 这类事件,运行时泛型被擦掉了。让事件实现 ResolvableTypeProvider,监听器才能按泛型区分。
容器 refresh 时,广播站建好之前发的事件先存起来,登记完监听器再统一发出。
SimpleApplicationEventMulticaster.setTaskExecutor 会让所有监听器都异步,连框架自己的也是。一般只给需要的方法加 @Async。
Spring 自己也在发事件
不用自己发,直接听就行。Boot 启动过程中的事件见 第 07 篇。
ContextRefreshedEvent容器 refresh 完成。有父子容器时会发不止一次。ContextClosedEvent容器关闭,适合做清理、优雅下线。ContextStartedEvent / ContextStoppedEvent显式调用 start()、stop() 时才发,平时很少见。ServletRequestHandledEvent每个 HTTP 请求处理完,DispatcherServlet 发一个,带耗时和状态码。和消息队列比
| Spring 事件 | 消息队列(Kafka、RocketMQ……) | |
|---|---|---|
| 范围 | 同一个进程的内存里 | 跨服务、跨机器 |
| 进程挂了 | 还没处理的事件就丢了 | 消息存在 broker 上,重启后接着消费 |
| 失败重试 | 自己写 | 自带重试、死信 |
| 和数据库事务 | @TransactionalEventListener 保证提交后才处理 | 要靠本地消息表或事务消息 |
| 适合 | 一个服务内部解耦 | 服务之间通知,需要可靠投递 |
JDK 8 和 JDK 17:Spring 5 和 Spring 6
广播站、监听器、四个时刻,两边都一样。Spring 6 收紧了一条规则,Boot 3 周边还多了一些现成的工具。
Spring 5 · Boot 2
JDK 8+- 事件写成普通类:final 字段、构造器、getter
- @TransactionalEventListener 方法上随便加 @Transactional 也能启动,但默认传播方式下写库不会提交,容易踩坑
- @Async 用 Boot 配好的
applicationTaskExecutor:核心 8 个线程,队列不限长
Spring 6 · Boot 3
JDK 17+- 事件可以直接写成
record,天生不可变,几个监听器之间不会互相改坏 - 6.1 起,@TransactionalEventListener 方法上的 @Transactional 只能是
REQUIRES_NEW或NOT_SUPPORTED,写错了启动就报错 - Boot 3.2 起配
spring.threads.virtual.enabled=true,@Async 用虚拟线程(要 JDK 21) - Spring Modulith 的
@ApplicationModuleListener等于 @Async + REQUIRES_NEW + @TransactionalEventListener,还能把事件存进数据库,失败了可以重发
常踩的坑
多数坑都是同一个问题:没想清楚这个监听器在哪个线程、哪个事务里跑。
同步监听器抛的异常抛回了 placeOrder,整个事务回滚,后面的监听器也没执行。可以失败的事,用 @Async 或 @TransactionalEventListener。
@Async 不等事务,事件一发出就交给线程池。改成 @TransactionalEventListener,需要异步就再加 @Async。
没有 @EnableAsync。另外 @Async 靠代理,同一个类里自己调自己不生效,和事务注解一样(第 02 篇)。
Boot 默认的线程池队列不限长,短信网关一慢,任务全堆在内存里。用 spring.task.execution.pool.queue-capacity 设上限,想好满了怎么办。
登录信息、事务都挂在 ThreadLocal 上,换了线程就没了(第 05 篇)。需要的数据放进事件对象里带过去。
发布事件时根本没有事务,事件被直接丢掉了。检查发布方法上有没有 @Transactional,或者设 fallbackExecution = true。
原来的事务已经提交,连接还挂在线程上,新写的 SQL 参与的是一个不会再提交的事务。方法上加 @Transactional(propagation = REQUIRES_NEW)。
有父子容器,每个容器 refresh 完都发一次。改听 ApplicationReadyEvent,或者判断 event.getApplicationContext().getParent() == null。
所有监听器拿到的是同一个对象。事件做成不可变的,JDK 17 直接用 record。
事件只在内存里。必须送达的事,用消息队列,或者先把事件存进数据库(本地消息表、Spring Modulith 的事件登记表)。