简介:面向 Spring 开发者的 Event 事件机制示例工程,以可运行代码演示事件定义、发布、监听、顺序控制与异步处理等核心用法,适合正在系统学习 Spring 事件模型,或希望在业务模块间实现低耦合通信的读者。资源共 75 个文件,包括 25 个 Java 源码和对应 class 文件、8 个 XML 配置、3 个 properties 配置及少量工程描述文件,压缩包仅 44KB,整体为 Maven 工程结构,源码、测试类与配置分离,便于快速导入 IDE 查看。内容覆盖自定义事件类(如继承 ApplicationEvent)、通过 ApplicationContext 发布事件、使用 @EventListener 注解或实现 ApplicationListener 接口监听事件、用 @Order 控制多个监听器的执行顺序,以及基于 @Async 的异步处理等关键知识点;同时提供测试相关代码,可帮助读者通过断点或运行测试观察事件广播与响应全过程。已有 680 人学习,可作为 Spring Event 入门理解与实战复现的轻量参考样包。
1. Spring Event 示例:从同步耦合到事件解耦的落地路径
业务系统做到一定规模,最明显的信号是模块开始互相“串门”:下单要发短信、要改库存、要加积分、还要写日志,全部堆在主流程里,每加一个下游动作就得改一遍下单方法,回归范围也跟着扩大。Spring Event 就是用来拆掉这种耦合的事件机制——主流程只负责发布事件,各模块通过监听器接收并处理,新增动作时不需要再动原有核心代码。这份 Spring Event 示例把事件定义、同步监听、异步监听、事务监听和异常处理配置完整跑通,适合正在做订单、支付、通知类系统的开发者对照落地,也适合第一次接触事件机制、想直接看可运行代码的新手。
2. Spring Event 原理:事件、监听器与发布器的三角协作
2.1 核心组件与职责边界
Spring Event 整套机制可以拆成四个角色:事件对象、监听器、发布器、多播器。前三个是我们在业务代码里经常接触的,多播器是容器内部的分发执行者,平时不直接操作,但理解它的存在能帮我们少踩很多坑。
| 组件 | 接口 / 类 | 职责 |
|---|---|---|
| 事件对象 | ApplicationEvent | 承载业务数据与发生时间,也可以直接用普通 POJO |
| 监听器 | ApplicationListener接口或@EventListener注解 | 订阅事件并处理 |
| 发布器 | ApplicationEventPublisher | 向容器广播事件 |
| 多播器 | ApplicationEventMulticaster | 查找匹配的监听器并分发调用 |
ApplicationEvent内部带了一个timestamp字段,继承它的事件对象会带上事件创建时间。Spring 4.2 之后@EventListener支持任意 POJO 作为事件对象,不强制继承ApplicationEvent,这在写事件类时能省不少事。
监听器是容器管理的普通 Bean。老写法是implements ApplicationListener<T>,重写onApplicationEvent方法;新写法是直接在一个 Bean 的方法上加@EventListener,一个类里可以写多个监听方法,分别处理不同类型的事件。发布器则简单得多:注入ApplicationEventPublisher,调用publishEvent完成广播,完全不用关心监听器怎么注册。
这里容易产生一个误区:以为ApplicationContext与ApplicationEventPublisher是两个东西。实际上ApplicationContext本身就实现了ApplicationEventPublisher接口,所以在老代码里你也能看到直接注入ApplicationContext再调publishEvent的写法。我一般习惯注入ApplicationEventPublisher,类型语义更清楚,测试时也好 mock。
2.2 默认同步:SimpleApplicationEventMulticaster 的执行模型
默认情况下,Spring 容器里注册的多播器是SimpleApplicationEventMulticaster,它的执行方式是:在调用线程里遍历所有匹配的监听器,直接同步调用。这句话有两个重要推论。
第一个推论:如果监听器里做了耗时操作,比如查数据库、调外部接口,主流程的响应时间会被这些监听器累加。第二个推论:如果监听器抛出异常,异常会沿着调用栈传播回publishEvent调用处,导致主流程跟着失败。
很多刚接触 Spring Event 的人默认它是异步的,甚至会拿 MQ 的理解来套,这是最大的认知偏差。事件机制和消息中间件的差别恰恰在这里:事件默认走的是当前线程,没有独立的消费线程池,也没有 ACK 机制。异步不是白送的,需要显式配置@Async或给多播器设置TaskExecutor。
提示:默认情况下,Spring Event 是同步的。需要异步时显式配置
@Async,不要假设监听器会自动跑到别的线程。
2.3 事件继承与多播顺序
事件类型之间的继承关系会影响匹配。假设PaySuccessEvent extends OrderEvent,容器里有一个监听器只订阅OrderEvent,那么PaySuccessEvent被发布时,这个监听器同样会触发,因为多播器会按事件类型向上查找匹配的监听器。
多播器匹配到多个监听器后,调用顺序默认不保证稳定,尤其是在同一个类里有多个方法同时匹配一个事件时,顺序依赖反射方法列表的排列,难以预测。要控制顺序,可以在监听方法上加@Order注解,数字越小越先执行。这个特性和 Spring AOP 里的@Order语义一致,但注意作用位置是方法而不是切面类。
我见过一个翻车场景:两个监听器都订阅同一个事件,一个做数据校验,一个做短信通知,因为默认顺序不对,短信先发出去了才被校验兜住。后边改成在方法上显式声明@Order(1)和@Order(2)才稳定下来。只要监听器之间有依赖关系,顺序就不该交给运气。
3. 订单事件实战:从定义事件到异步监听的完整链路
3.1 定义事件对象
以一个支付成功事件为例。事件对象只负责携带业务上下文,不承载逻辑。推荐做成不可变对象,字段用final修饰,只暴露 getter。
public class OrderPaySuccessEvent extends ApplicationEvent { private final Long orderId; private final Long userId; private final BigDecimal amount; public OrderPaySuccessEvent(Object source, Long orderId, Long userId, BigDecimal amount) { super(source); this.orderId = orderId; this.userId = userId; this.amount = amount; } public Long getOrderId() { return orderId; } public Long getUserId() { return userId; } public BigDecimal getAmount() { return amount; } }source参数是发布事件的源头对象,一般传this即可。继承ApplicationEvent会自动带上时间戳,如果不想每个监听器都去解析getTimestamp(),后续代码里可以直接读业务字段。
如果项目用的是 JDK 17 及以上,可以考虑用 record 替代,代码更精简:
public record OrderPaySuccessEvent(Long orderId, Long userId, BigDecimal amount) { }但要注意:团队里如果还有老项目停留在 JDK 8 / 11,record 是编译不过的,这种情况老老实实用 class 封装,别为了省几行给团队留坑。
3.2 编写监听器
监听器是消费事件的地方,一个事件可以有多个监听器独立处理,互不干扰。
@Component public class OrderNotifyListener { private static final Logger log = LoggerFactory.getLogger(OrderNotifyListener.class); @EventListener @Order(1) public void sendSms(OrderPaySuccessEvent event) { log.info("send sms, orderId={}", event.getOrderId()); } @EventListener @Order(2) public void addIntegral(OrderPaySuccessEvent event) { // 给用户加积分 } }@EventListener会按参数类型自动匹配事件。一个监听器类里可以定义多个方法,分别处理不同事件类型,不必像接口模式那样为每种事件单独建一个类。
和implements ApplicationListener的方式相比,注解模式有两个明显优势:一是方法不需要实现接口,侵入性小;二是同一个 Bean 里可以放多个监听方法,按业务域聚合,代码组织起来更顺手。接口模式适合那些需要强类型约束、或者希望监听器自身被显式注入的场景,普通业务开发用注解足够。
@Async加在监听方法上之后,这个方法就变成异步执行,发布器不需要改动。但要注意:@Async只在方法级别生效,同一个 Bean 内部调用另一个@Async方法是不会异步的。如果OrderNotifyListener内部再调一个@Async方法,那一步会退化成同步执行,这是 AOP 代理的边界,排查时可别忽略。
3.3 发布事件
发布事件的位置要放在业务状态变更之后,这样监听器拿到的数据才完整。
@Service public class OrderService { private final ApplicationEventPublisher eventPublisher; public OrderService(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } @Transactional public void paySuccess(Long orderId, Long userId, BigDecimal amount) { // 订单状态改为已支付、扣减库存等主流程 eventPublisher.publishEvent(new OrderPaySuccessEvent(this, orderId, userId, amount)); } }构造函数注入ApplicationEventPublisher,比注入ApplicationContext更轻。publishEvent是同步方法,事件发出去之后,所有同步监听器都在这个调用里被消费完才返回。
这个位置有一个隐含问题:paySuccess上有@Transactional,而publishEvent是写在事务提交之前的。同步监听器在这个时刻执行,读到数据库里还是未提交的数据。这个问题在第 4 章会展开,先留个印象。
3.4 配置异步线程池
异步监听需要两个配置:@EnableAsync开启异步能力,以及一个明确的线程池。不要用 Spring 默认的SimpleAsyncTaskExecutor,它每次提交都新建线程,没有复用,压力一大线程数会失控。
@Configuration @EnableAsync public class EventAsyncConfig { @Bean("orderEventExecutor") public ThreadPoolTaskExecutor orderEventExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(100); executor.setThreadNamePrefix("order-event-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }然后在监听方法上指定执行器:
@EventListener @Async("orderEventExecutor") public void sendSms(OrderPaySuccessEvent event) { log.info("send sms async, orderId={}, thread={}", event.getOrderId(), Thread.currentThread().getName()); }线程池参数按业务量调:核心线程数 4 适合大多数单机应用,短时间内大量事件进来时可以扩到 8,队列 100 用来缓冲平稳流量。CallerRunsPolicy的意思是队列满了之后由发布线程直接执行,这个策略在事件消费场景更稳妥——慢一点比丢事件好。
@Async("orderEventExecutor")中的名称要和@Bean名称一致。如果只写@Async不指定名称,Spring Boot 会去找applicationTaskExecutor,可能和你预期的线程池对不上,日志里线程名一看就知道有没有配错。
4. Spring Event 避坑:四个高频故障的现象、原因与解法
4.1 监听器异常拖垮主流程
现象:下单接口偶尔 500,堆栈里看到的是短信发送异常,但订单本身已经保存成功了。
原因:默认同步模式下,监听器异常会直接传播到publishEvent调用处,业务方法整体失败。短信服务抖动被放大成了订单接口失败。
解决:给多播器配置ErrorHandler,让监听器异常被统一捕获,不打进主流程。在配置类里覆盖默认多播器即可:
@Bean(name = "applicationEventMulticaster") public ApplicationEventMulticaster applicationEventMulticaster() { SimpleApplicationEventMulticaster multicaster = new SimpleApplicationEventMulticaster(); multicaster.setErrorHandler(throwable -> log.error("event handle error", throwable)); return multicaster; }注意:这个配置会替换 Spring Boot 自动注册的多播器,并且没有设置TaskExecutor,所以监听器仍然是同步执行的。配了ErrorHandler之后,某个监听器挂了不影响业务主流程,但也意味着“事件没被消费”这类问题只能从日志找线索,团队里要说清楚这个边界,别指望有人主动上报。
4.2 事务未提交,监听器查不到数据
现象:监听器里通过orderMapper.selectById()查询订单,拿到的状态还是“待支付”,跟发布前一样。
原因:publishEvent调用发生在事务提交之前,同步监听器执行时数据库里数据还没提交,当前连接的隔离级别也读不到未提交数据,所以查出来是旧值。
解决:改成事务提交后再触发监听器,用@TransactionalEventListener:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void sendSms(OrderPaySuccessEvent event) { // 此时事务已提交,能查到最新状态 }这里有个硬性条件:只有发布方处于事务中时,事务事件监听器才会被触发。如果发布方法没有@Transactional,这个监听器默认不会执行,我用fallbackExecution可以兜底,但千万别把兜底打开之后就忘了事务数据可见性的问题,详见第 5 章。
4.3 事件重复触发与幂等
现象:日志里同一个orderId的监听器打了两遍,短信重复推送,用户连着收到两条。
原因:一种场景是发布方被重复调用,比如支付回调重试;另一种是事件继承导致父事件监听器和子事件监听器同时命中;还有一种是一个类里多个监听方法都匹配了同类型事件,而顺序又不可控。
解决:监听器入口做状态幂等。先查订单状态,已处理过就直接返回:
if (order.getStatus().equals(OrderStatus.PAID_NOTIFIED)) { return; }更通用的做法是引入去重表,用事件标识加业务主键做唯一约束,先抢占再处理:
CREATE TABLE event_process_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL, order_id BIGINT NOT NULL, process_time DATETIME NOT NULL, UNIQUE KEY uk_event_order (event_id, order_id) );处理前先执行INSERT IGNORE,返回影响行数为 1 才执行正式逻辑。这样即使事件被重复发布,重复的监听器线程也会在插表这一步被拦下来。
4.4 异步监听器拿不到请求上下文
现象:监听器里调RequestContextHolder.getRequestAttributes()直接报 NPE,或者集成某些依赖当前线程上下文的框架时取不到会话信息。
原因:@Async把任务提交到了线程池的线程执行,ThreadLocal里的请求上下文没有传递过去,子线程和主线程隔断了。
解决:用TaskDecorator包装任务,把主线程的请求上下文带到异步线程,执行完再清掉:
executor.setTaskDecorator(runnable -> { RequestAttributes context = RequestContextHolder.getRequestAttributes(); return () -> { try { if (context != null) { RequestContextHolder.setRequestAttributes(context); } runnable.run(); } finally { RequestContextHolder.resetRequestAttributes(); } }; });注意空判断:如果事件来源不在 Web 请求线程,比如来自定时任务或 MQ 消费,context本身为 null,直接setRequestAttributes反而会报错。更稳的做法是:事件对象里只携带需要的业务 ID,监听器里按需用 ID 重建数据,不依赖请求上下文,这样异步线程完全解耦,也方便以后换成 MQ 时迁移改造。
5. 事务事件监听器:@TransactionalEventListener 的 phase 与 fallbackExecution
5.1 为什么需要事务事件监听器
第 4.2 提到,同步监听器在publishEvent调用后立即执行,此时事务可能还没提交,监听器查不到新数据。这个问题的根因是事件发布和监听器消费没有对齐事务边界。
@TransactionalEventListener解决的是这个对齐问题:Spring 在事务同步管理器里注册回调,等事务走到指定阶段后再触发监听器。它和@EventListener的区别只在触发时机,监听器本身的执行线程规则不变——默认还是同步,加@Async才异步。
5.2 phase 取值与异常影响
phase决定触发时机,选错会引发两类问题:一是监听器执行太早查不到数据,二是监听器异常导致事务回滚。
| phase | 触发时机 | 监听器异常的影响 |
|---|---|---|
BEFORE_COMMIT | 事务提交前 | 异常会导致事务回滚 |
AFTER_COMMIT | 事务提交后(默认) | 不影响事务结果,只打日志 |
AFTER_ROLLBACK | 事务回滚后 | 不影响事务结果,用于补偿 |
AFTER_COMPLETION | 事务完成后(无论提交还是回滚) | 不影响事务结果 |
实际业务里用得最多的是AFTER_COMMIT。支付成功发短信、加积分、同步状态,这些动作都希望看到已提交的数据,失败也不应该把已提交的支付结果拉回滚。默认值就是它,写代码时显式写出来反而更清楚:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void syncOrderStatus(OrderPaySuccessEvent event) { // 同步订单状态到下游 }BEFORE_COMMIT适合那些需要在新事务内一起提交的补偿逻辑,但一旦监听器出异常,整个业务都会回滚,副作用很大。我一般不推荐默认选它,除非你能接受“监听器失败等于业务失败”这个约束。
AFTER_ROLLBACK适合记录失败原因或做数据补偿。比如订单支付事务回滚后,需要记录一条支付失败轨迹,这个监听器就派得上用场。事件对象里可以带上失败码和失败原因,监听器拿到后写流水表。
5.3 fallbackExecution 与无事务边界
@TransactionalEventListener有一个容易让人困惑的参数:fallbackExecution。默认它是false,意思是事件在没有事务的方法里发布时,监听器根本不会执行。很多新手测试时发现事件没触发,先查监听器代码,然后查依赖,最后才发现发布方压根没有@Transactional。
把fallbackExecution设为true后,无事务发布也会触发监听器,但会退化成@EventListener的行为——立即同步执行,不再等待什么提交。如果你以为它还在等待事务提交,就踩坑了:在无事务方法里发事件,fallbackExecution=true的监听器即时执行,数据库数据可能还没落盘。
我的习惯是:发布方确定有@Transactional时才用事务事件监听器;如果确实要兼容无事务场景,也要在事件对象里自带一个标记,监听器里判断标记后再决定要不要走数据查询,别把可见性假设留给调用方。还有一个容易忽略的点:如果事件是在传播行为为REQUIRES_NEW的内部方法里发布的,事务事件监听器会在新事务的阶段触发,而不是外层事务,排查时看日志线程和事务 ID 才对得上。
6. 事件链路验证:日志排查与异步确认的技巧
事件链路不像方法调用栈那么好观察,发布器和监听器分散在不同类里,出问题时第一件事就是确认“事件到底有没有触发、在哪个线程触发的”。我给一个低成本高收益的验证习惯:在监听器里带线程名打日志。
@EventListener @Async("orderEventExecutor") public void syncOrderStatus(OrderPaySuccessEvent event) { log.info("[{}] sync status, orderId={}", Thread.currentThread().getName(), event.getOrderId()); }跑一次支付流程,然后看日志输出:
grep "sync status" app.log | tail -n 20order-event-1这类前缀说明异步生效了;如果出现的是http-nio-*线程名,说明@Async失效,优先检查监听器是否被同一个 Bean 内部调用,或者@EnableAsync有没有真正开启。同步监听器的线程名会和主流程一致,代码里刻意打上线程名,能减少很多排查时间。
再配合去重表的查询,统计同一个orderId在日志里出现的次数,能很快判断事件是否重复发布。我之前维护订单模块时被事件机制坑过两次,一次是监听器异常把支付回调拖成 500,一次是异步事件在事务提交前查不到数据。从那以后我每次新增事件监听器都强制走一遍检查:先确认同步还是异步,再确认有没有事务边界,最后看异常怎么处理。希望帮到你。
本文还有配套的精品资源,点击获取