SPRING · APPLICATIONEVENT · @EVENTLISTENER · @TRANSACTIONALEVENTLISTENER

发个广播,谁关心谁来听

下单成功后要加积分、发短信、记统计。下单代码不用认识这些人,只要喊一声“订单付款了”。Spring 的广播站会找出所有关心这件事的监听器,挨个通知。

发布者
OrderService.placeOrder()

存好订单,喊一声:

OrderPaidEvent
广播站
ApplicationEventMulticaster

按事件类型挑出关心它的监听器,按 @Order 排好,一个个通知。

积分监听器加 10 积分
短信监听器调短信网关
统计监听器Redis 计数加一
事件任意对象

说清楚发生了什么。Spring 4.2 起不用再继承 ApplicationEvent。

JDK 17 可以直接用 record
发布者ApplicationEventPublisher

注入进来调 publishEvent。容器本身就是一个发布者。

ApplicationContext 实现了它
广播站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 方法登记好;发布时按事件类型挑出来,挨个调用。

启动时:登记听众

  1. 所有单例 Bean 造完后,EventListenerMethodProcessor 扫描每个 Bean 上带 @EventListener 的方法。
  2. 每个方法包成一个 ApplicationListenerMethodAdapter,记下它听哪种事件、什么条件、什么顺序。
  3. 登记到广播站。从这以后发出的事件,它们才收得到(第 07 篇讲过启动早期的事件为什么收不到)。

发布时:挨个通知

  1. 事件不是 ApplicationEvent,就包成 PayloadApplicationEvent。
  2. 按事件的类型(连泛型一起)挑出关心它的监听器,结果按类型缓存,下次直接用。
  3. 按 @Order 排序,一个个调用;有 condition 的先算条件。
  4. 监听方法有返回值,就把返回值当成新事件再发一次。
  5. 有父容器的话,再往父容器发一遍。
SimpleApplicationEventMulticaster简化版
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);                               // 默认:就在发布者的线程里,一个接一个
        }
    }
}
记住默认是同步的。发布者调完 publishEvent,所有同步监听器已经执行完了;其中一个抛异常,异常会一路抛回发布者,后面的监听器也不会再被调用。

事件实验台

placeOrder 带着 @Transactional,存订单后发出 OrderPaidEvent。给三个监听器选不同的写法,看谁在哪个线程跑、事务回滚了会怎样、用户要等多久。

试试这几种:
哪个监听器抛异常:
ms主线程(处理这次请求)异步线程池

三种写法,逐项比

同一个监听方法,加不同的注解,行为差别很大。挑的时候先问自己:这件事要不要和订单一起成败?

@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,还能把事件存进数据库,失败了可以重发
没变的:默认同步按类型挑监听器@Order 排序四个事务时刻只在一个进程里

常踩的坑

多数坑都是同一个问题:没想清楚这个监听器在哪个线程、哪个事务里跑。

短信网关超时,订单也下不成了

同步监听器抛的异常抛回了 placeOrder,整个事务回滚,后面的监听器也没执行。可以失败的事,用 @Async 或 @TransactionalEventListener。

订单回滚了,短信却发出去了

@Async 不等事务,事件一发出就交给线程池。改成 @TransactionalEventListener,需要异步就再加 @Async。

加了 @Async,还是在主线程里跑

没有 @EnableAsync。另外 @Async 靠代理,同一个类里自己调自己不生效,和事务注解一样(第 02 篇)。

异步任务越堆越多,内存涨上去

Boot 默认的线程池队列不限长,短信网关一慢,任务全堆在内存里。用 spring.task.execution.pool.queue-capacity 设上限,想好满了怎么办。

异步监听器里拿不到当前用户

登录信息、事务都挂在 ThreadLocal 上,换了线程就没了(第 05 篇)。需要的数据放进事件对象里带过去。

@TransactionalEventListener 一次都没执行

发布事件时根本没有事务,事件被直接丢掉了。检查发布方法上有没有 @Transactional,或者设 fallbackExecution = true。

AFTER_COMMIT 里写数据库,没写进去

原来的事务已经提交,连接还挂在线程上,新写的 SQL 参与的是一个不会再提交的事务。方法上加 @Transactional(propagation = REQUIRES_NEW)。

ContextRefreshedEvent 的监听器执行了两次

有父子容器,每个容器 refresh 完都发一次。改听 ApplicationReadyEvent,或者判断 event.getApplicationContext().getParent() == null。

前一个监听器改了事件里的字段,后一个看到的是改过的

所有监听器拿到的是同一个对象。事件做成不可变的,JDK 17 直接用 record。

服务重启,还没处理完的事件丢了

事件只在内存里。必须送达的事,用消息队列,或者先把事件存进数据库(本地消息表、Spring Modulith 的事件登记表)。