简介:一份面向Spring框架学习者的Spring Event事件机制示例工程,聚焦事件定义、发布、监听及自定义事件总线等核心用法,适合想掌握应用内解耦通信与异步处理的中级Java开发者。资源共75个文件,压缩包仅44KB,包含25个Java源码、25个编译后的class文件、8个XML配置及少量properties与工程描述文件;源码可直接阅读,class文件便于快速运行验证,XML配置则展示了Spring整合方式。目前已有680人浏览学习。通过该示例,可直观理解ApplicationEvent、@EventListener与ApplicationListener两种监听写法、@Order控制监听顺序、@Async异步处理及自定义ApplicationEventMulticaster等关键知识点;同时工程目录结构完整,涵盖事件发布、监听器、配置与测试等模块,读者可对照源码逐段调试,复现事件传播与顺序控制过程,适合作为Spring Event入门到实践的参考模板,有助于减少组件耦合、提升系统扩展性。
1. Spring Event:本地事务内的轻量事件总线,先想清楚再动手
刚接手业务服务时,我跟你一样觉得 Spring Event 是个「黑匣子」:明明只是调了个publishEvent,别的模块怎么就自动动起来了?等把这个机制拆完才发现,它就是进程内的观察者模式,最大价值在于把「下单成功」「支付回调」这类业务事实发布出去,让发通知、扣积分、写流水各自监听,互不阻塞。它不需要额外中间件,事务边界内还能做到基本可靠,适合单应用、小团队、不想为通知类逻辑单引消息队列的场景。不过一个反直觉的结论要先记住:Spring Event 默认是同步的,同一个线程跑完监听器才返回,把它当本地 MQ 用,会踩出后面那一串坑。
2. 拆开 Spring Event 的三个核心件:事件、发布器与监听器的实现边界
2.1 事件类的设计:继承 ApplicationEvent 还是用普通 POJO
Spring 4.2 之前,发布事件必须继承ApplicationEvent,这是很多人写老代码的第一个「历史包袱」。4.2 之后完全没必要继承,直接建普通 POJO 就能发布,框架会在发布时自动包一层PayloadApplicationEvent。我建议业务事件一律用 POJO,理由有三:不强制序列化;字段自己掌控;测试好构造。每个事件类要满足两个约束:字段只放「事实」,不放「大对象」;类名用「名词 + 过去式」表达已发生的事。
下面是一个典型事件类的写法。
@Getter @Builder public class OrderCreatedEvent { private final Long orderId; private final Long userId; private final String orderNo; private final LocalDateTime occurredAt; }逻辑说明:事件类最重要的不是继承,而是「承载哪些信息」。orderId是消费方查详情的主键,orderNo用于幂等和日志关联,occurredAt记录事实发生时间。事件里只放 id 和必要的上下文,不要把整个订单实体或数据库连接放进去——事件是跨模块传递的「快照」,不是数据库模型的搬运工。
参数说明:用@Builder构建事件,能让发布侧代码更清晰,也避免字段顺序错乱。final字段保证事件创建后不可变,这是事件对象的基本要求,防止某个监听器意外改掉字段影响后续监听器。
2.2 发布器:ApplicationEventPublisher 的正确注入姿势
发布动作的核心接口是ApplicationEventPublisher,它只有一个方法publishEvent(Object event)。Spring 注入它非常省事,直接用构造器注入就行。发布器不需要做成单例工具类,放在业务 Service 里即可。
@Service @RequiredArgsConstructor public class OrderService { private final ApplicationEventPublisher publisher; public Long createOrder(OrderCreateDTO dto) { // 1. 落库、写订单状态 // 2. 发布业务事实 publisher.publishEvent(OrderCreatedEvent.builder() .orderId(12345L) .userId(dto.getUserId()) .orderNo("ORD20250101001") .occurredAt(LocalDateTime.now()) .build()); return 12345L; } }逻辑说明:publishEvent是同步调用,默认在调用线程内把事件分发给所有匹配的监听器。这里有个容易忽略的行为:发布方法执行完,监听器可能还没跑完,但对外表现是「发布后立即返回」;如果是同步监听器,实际是等监听器全部执行完才从publishEvent返回。
参数说明:ApplicationEventPublisher没有强制要求事件类型,传普通对象没问题。发布顺序遵循「先落库、后发布」,保证监听器读取数据时能看到已提交的内容。若在事务内发布,监听器可能读到未提交的数据,后面第 3 章专门解决这个问题。
2.3 监听器:@EventListener 的写法与生效条件
监听器是消费端,最常见写法是@EventListener注解加方法参数。Spring 根据方法参数类型自动做事件匹配。一个事件可以配多个监听器,执行顺序用@Order控制。
@Component @RequiredArgsConstructor public class OrderEventListeners { private final OrderNotifyService orderNotifyService; private final StockService stockService; @EventListener @Order(1) public void handleOrderCreated(OrderCreatedEvent event) { orderNotifyService.sendNotify(event.getOrderNo()); } @EventListener @Order(2) public void handleOrderCreatedForStock(OrderCreatedEvent event) { stockService.lockStock(event.getOrderId()); } }逻辑说明:@EventListener方法所在 Bean 必须被 Spring 管理,才能被事件多播器找到。方法名任意,真正起作用的是参数类型。@Order(1)和@Order(2)保证发通知在锁库存之前执行,适合有前提依赖的顺序场景;没有顺序依赖的监听器不要用@Order去刻意排序。
参数说明:同一个类里两个方法监听同一个事件完全合法,Spring 会把它们注册成两个监听器。监听器支持返回新事件实现链式发布,但这个特性容易造成链路不可控,我不建议在业务里依赖它——想关联后续操作就直接在监听器里调用服务。
提示:发布事件后监听器不执行,第一步先确认监听器类有没有被 Spring 扫描到。扫描不到,监听器连注册都不会发生。
2.4 一个最小可运行工程的骨架
把上面三块拼起来,一个最小工程就要包含:事件类、业务发布 Service、监听器组件、启动类。关键在 starter 上,Spring Boot 默认自动配置了事件多播器,不需要额外注解。
src/main/java/com/example/demo/ ├── DemoApplication.java ├── event/ │ └── OrderCreatedEvent.java ├── service/ │ ├── OrderService.java │ ├── OrderEventListeners.java │ └── OrderNotifyService.java逻辑说明:整个事件链路不需要写任何配置类,Spring Boot 启动时自动装配ApplicationEventMulticaster。DemoApplication加上@SpringBootApplication即可完成组件扫描,事件类被@Service、@Component注册的模块看到就行。
参数说明:事件类不需要加任何注解。如果你在事件类上加@Component,事情也不会立刻出错,但会让事件变成多例组件,完全没意义。事件类保持纯 POJO 状态是底线。
3. 事务与异步的正确组合:让监听器在提交后、异步线程里执行
3.1 TransactionalEventListener:把监听动作放到事务提交后
第 2 章的同步监听有一个经典翻车场景:订单落库后立刻发通知,通知服务去查订单却查不到。原因是事务还没提交,监听器在事务内执行,读不到未提交数据。Spring 提供的解决工具是@TransactionalEventListener,它能把监听器绑定到事务的特定阶段。
@Component @RequiredArgsConstructor public class OrderNotifyTransactionListener { private final OrderNotifyService orderNotifyService; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void sendNotifyAfterCommit(OrderCreatedEvent event) { orderNotifyService.sendNotify(event.getOrderNo()); } }逻辑说明:phase枚举有BEFORE_COMMIT、AFTER_COMMIT、AFTER_ROLLBACK、AFTER_COMPLETION四个值。AFTER_COMMIT表示事务成功提交后再执行监听器,此时监听器里查库一定能看到已提交的数据。fallbackExecution = true表示当前没有事务时也照常执行,避免非事务方法发布事件时监听器被静默丢弃。
参数说明:这个注解要求监听器所在 Bean 在事务上下文中被 Spring 代理调用。注意sendNotify如果自己又开了新事务,需要在事务传播上做取舍,默认REQUIRED会加入原事务;如果监听器要独立事务,就改成REQUIRES_NEW。fallbackExecution默认是false,无事务时监听器不会执行,这常常是「事件发了但没反应」的原因之一。
3.2 @Async + 自定义线程池:异步监听的正确配置路径
同步监听会把主流程拖慢,所以要异步。最直接的组合是@Async加@EventListener,但这个组合有个前提:@Async必须生效,否则只是换了个注解,行为还是同步。为了让异步可控、线程池隔离,必须在配置里显式创建线程池。
@Configuration @EnableAsync public class AsyncEventConfig { @Bean("eventExecutor") public Executor eventExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix("event-"); executor.setKeepAliveSeconds(60); executor.setWaitForTasksToCompleteOnShutdown(true); executor.setAwaitTerminationSeconds(30); executor.initialize(); return executor; } }逻辑说明:ThreadPoolTaskExecutor是 Spring 对ThreadPoolExecutor的封装,@Async("eventExecutor")会从容器里按名字拿到这个线程池。为什么不复用默认线程池?默认SimpleAsyncTaskExecutor每个任务都建新线程,高并发下线程数不可控。事件消费往往涉及通知、写流水、外部调用,必须独立线程池,避免和业务接口线程互相挤占。
参数说明:corePoolSize=4是常驻线程数,maxPoolSize=8是最大线程数,任务积压时线程数会从 4 涨到 8。queueCapacity=200是队列缓冲,超过maxPoolSize个活跃线程且队列满时才触发拒绝策略。waitForTasksToCompleteOnShutdown和awaitTerminationSeconds是优雅停机参数,保证应用关闭时已提交的事件任务能跑完。
监听器侧写法:
@Component public class AsyncOrderEventListener { @Async("eventExecutor") @EventListener public void handleAsync(OrderCreatedEvent event) { // 异步执行,不阻塞主流程 // 这里写发送站内信、记录轨迹等非关键逻辑 } }逻辑说明:@Async加在监听方法上后,事件多播器分发到这个监听器时,会丢给eventExecutor线程池执行。主流程立即返回,监听器里的异常不会再影响调用方。这里的关键是:@Async和@EventListener的 Bean 不能是同一个类的自调用,必须从 Spring 容器里注入,否则代理不会套在监听方法上。
参数说明:异步监听器不适合做事务性强的操作,因为任务进入线程池后事务边界已经脱离调用链。如果你既想要「事务提交后执行」又想要「异步」,就把@TransactionalEventListener和@Async组合使用,事务先提交,线程池再接下去处理。
3.3 监听器执行顺序与异常传播的边界
监听器顺序用@Order控制,这一点很多人知道,但异常传播才是真正致命的点:默认同步模式下,第一个监听器抛异常,后续监听器不会再执行,异常会传给发布方。
@EventListener @Order(1) public void first(OrderCreatedEvent event) { throw new RuntimeException("通知服务不可用"); } @EventListener @Order(2) public void second(OrderCreatedEvent event) { // 当 first 抛异常时,这里不会被调用 }逻辑说明:Spring 默认的事件多播器SimpleApplicationEventMulticaster在同步分发时,遇到异常直接中断后续监听器。这意味着「通知服务挂了」的错误,会连累「库存扣减」「积分发放」全部不执行。这是同步事件最需要警惕的边界。
参数说明:要改变这个行为,可以给多播器设置ErrorHandler,但更稳妥的工程方案是:监听器内部各自 try-catch,把非关键链路的异常吞掉并记日志;或者把容易失败的动作异步化,让异常隔离在各自线程里。错误处理策略应该显式写在每个监听器里,而不是依赖 Spring 的默认行为。
4. Spring Event 实战避坑篇:五个高频翻车点与排查顺序
4.1 监听器不生效:事件发布了,监听器却没执行
现象:日志里确认publishEvent这行已经执行,但监听器方法整个没进入。
原因:最常见的两个原因。第一,监听器类没被 Spring 扫描到,比如监听器放在某个没在启动类扫描范围下的包;第二,事件类型不匹配,监听器监听的是父类,发布的是子类,Spring 按精确类型匹配时不会命中。
解决:启动类上确认@SpringBootApplication的扫描路径覆盖监听器所在包。排查类型不匹配时,把发布和监听的日志打出来,确认两者的实际类型。我一般会在监听器里加一行:log.info("handle event: {}", event.getClass().getSimpleName());这行日志能立刻区分是没注册还是没匹配。
4.2 异步不生效:@Async 加了,方法还是跑在主线程
现象:监听方法里打印线程名,发现还是http-nio之类的主线程名,任务没有进event-线程池。
原因:多数情况是@EnableAsync没加;另一种是@Async方法通过this调用——Spring 的异步代理只对从容器注入的 Bean 生效,自调用直接绕过代理。
解决:确认配置类上有@EnableAsync;把@Async方法放到独立的监听器 Bean 中,不要跟发布逻辑写在一个类里。代码里通过构造器注入监听器,通过注入的实例调用。还有个别场景是线程池名字写错,@Async("wrongName")容器里拿不到对应 Bean,Spring 会降级用默认执行器,线程名当然不对。
4.3 @TransactionalEventListener 不执行:事务提交后监听器没动静
现象:数据库记录已提交,监听器代码没跑,也没有任何异常日志。
原因:最常见的是监听的方法根本没在事务里——发布方方法没有@Transactional,或者发布方法存在事务自调用问题,事务代理没生效。还有一种情况是fallbackExecution默认false,无事务时监听器直接被忽略。
解决:给发布方业务方法加@Transactional,并确保事务是通过代理进入的,不能用this.doSomething()自调用。同时在@TransactionalEventListener上显式设置fallbackExecution = true,兜住非事务场景。排查时看日志里有没有事务提交记录,先确认事务是不是真的提交了。
4.4 主流程被监听器拖慢:接口响应突然变慢
现象:一个原本 100ms 的创建订单接口,发布事件后飙到 1s 以上,有时还伴随超时。
原因:监听器里有同步的 RPC 调用、数据库写操作或长轮询。默认同步分发下,这些耗时全部算在主线程上。
解决:把耗时逻辑移到异步监听器里,用独立线程池隔离。排查时先统计发布事件到最后一个监听器返回的耗时,再逐个查看监听器执行时间。我一般会做一步保护:在耗时较长的同步监听器外面包一层超时控制,防止某个下游服务假死拖垮主程序。
4.5 重复消费没有幂等:事件重放导致数据重复写入
现象:业务重试或链路重放时,监听器逻辑执行了两遍,积分加了两份,通知发了两次。
原因:Spring Event 是进程内事件,本身没有 ack 机制,也不会自动去重。如果下游是重试驱动型设计,事件对象被重复投递时,消费端没有任何幂等保护。
解决:在事件对象里带上业务唯一键,比如orderNo;监听器消费前先查幂等表或用 Redis setnx 校验,已消费的直接跳过。这是一个非常容易被忽略的设计点,等线上数据对不上账时再补,代价会大很多。
注意:事件一旦发布,Spring 默认不保证「只消费一次」。所有监听器都应该按「可能被重复调用」来写。
5. 把 Spring Event 用得更顺手:批量、过滤与调试的几个习惯
5.1 批量发布:循环发布还是 List 事件
批量导入场景下,1000 条订单逐个publishEvent会触发 1000 次事件分发,每个监听器跑 1000 次,性能不好。常见做法是定义一个批量事件包一个 id 列表,监听器内再遍历。但要注意:批量事件会让监听器失去单条事件的处理粒度,事务和异常边界都变粗。
@Getter @Builder public class OrdersImportedEvent { private final List<Long> orderIds; } // 发布侧 publisher.publishEvent(OrdersImportedEvent.builder() .orderIds(orderIds) .build());逻辑说明:这样做的好处是只做一次事件分发,监听器里统一遍历,数据库写操作也能批量执行。代价是监听器内部必须自己处理部分失败,不能让一条数据失败导致整个批次的事件处理中断。
参数说明:OrdersImportedEvent作为一个事件对象,字段里放列表是合理的,它仍然属于「事实」——描述了批量订单导入这件事。注意别把List包一层又一层,事件对象字段超过五六个时,就该拆成更细粒度的事件了。
5.2 用 SpEL 条件过滤:一个监听器只处理特定来源
事件类里经常会带一个source或type字段,用来区分事件来源。可以用@EventListener的condition属性配合 SpEL 表达式过滤,避免多个监听器各自判空。
@EventListener(condition = "#event.source == 'order-app'") public void handleAppCreated(OrderCreatedEvent event) { // 只处理来自 order-app 的订单创建事件 }逻辑说明:condition表达式在事件分发时求值,只有返回true才进入方法。SpEL 表达式里#event指代监听方法参数名,可以直接访问对象的属性。条件过滤比监听器内部 if 判断更干净,也能减少无效执行。
参数说明:参数名必须与监听方法入参名一致,否则表达式求值会拿到null,建议显式指定。
5.3 调试习惯:事件日志、线程名与监听清单
最后一个建议是调试习惯,这是我的血泪经验。从那以后我每次搭事件链路,都会强制做三件事:第一,在所有监听器入口打一行带事件类型、线程名和业务单号的日志;第二,启动时打印 Spring 注册的监听器清单,确认每个事件对应几个消费端;第三,发布端记录事件发布耗时,超过阈值直接告警。这套习惯能省掉大量排查事件链路的精力,希望帮到你。
本文还有配套的精品资源,点击获取