资讯动态

Spring Event 从入门到实战:事务、异步与避坑指南

发布时间:2026/10/14 14:24:20 来源:尧图企业网站定制
简介Spring Event 是 Spring 框架内部的发布-订阅机制它能在不同组件之间传播事件从而降低代码耦合。不少 Java 开发者看过文档却缺少一个能直接运行的示例来理解事件从定义到监听的完整链路这份资源正好补充了这块空缺。压缩包共 75 个文件体积仅 44KB包含 25 个 Java 源码与 25 个编译后的 class 文件另有 XML 配置、properties 及工程配置文件其中源码负责演示业务逻辑配置则体现 Spring 容器装配过程可导入 IDE 直接查看运行效果目前已有 680 人浏览学习。内容覆盖事件类设计、ApplicationContext.publishEvent 发布事件、EventListener 与 ApplicationListener 两种监听器写法以及通过 Order 控制执行顺序、事件广播模式和自定义 ApplicationEventMulticaster 等进阶主题并配有基础测试示例。整个工程目录结构完整便于对照源码逐一调试对正在学习 Spring 事件驱动编程的开发者来说是一份结构清晰、上手门槛低的实战参考。1. 从一个订单消息漏掉的深夜说起Spring Event 到底解决了什么有次排查线上故障某支付回调服务处理完后要去通知积分系统和消息中心代码里就是三行同步调用。某个凌晨积分服务抖动了一下RPC 超时负责处理支付的线程直接抛异常结果支付记录已经落库但积分没加、站内信没发。当时整条链路只有一个办法——人工捞数据补发。复盘的时候同事抛出一句“要是用 Spring Event 解耦就好了”那是我第一次认真研究 spring event 示例。Spring Event 是 Spring Framework 内置的事件发布与监听机制基于观察者模式实现核心就三个角色事件、发布器、监听器。它的价值在于把“一件事发生后要做的其他事情”从主业务流程里剥离出去发布方不用关心谁在听、听到后干什么、是同步还是异步。对绝大多数单体应用和轻量分布式场景它比引入消息队列更轻比硬编码 RPC 调用更稳。适用于业务解耦、异步通知、审计日志、缓存刷新等场景对新手友好对老手来说关键在事务边界和异常处理的细节。2. Spring Event 的运行机制三个角色与三种事件源2.1 观察者模式在 Spring 里的落地形态Spring Event 不是一套独立于容器的黑科技它就是观察者模式在 Spring 容器中的标准化实现。事件发布器ApplicationEventPublisher负责发布事件事件监听器ApplicationListener 或 EventListener负责消费事件两者之间通过 ApplicationEventMulticaster 这个多播器完成解耦。多播器默认是 SimpleApplicationEventMulticaster它维护一个监听器集合发布事件时按类型匹配并逐个调用。Spring 容器启动时会自动注册多播器并扫描所有 ApplicationListener 类型的 Bean以及所有标注了 EventListener 的方法把它们包装成 ApplicationListenerBeanMethodAdapter 注册进多播器。意味着你写监听器时基本不用手动注册任何东西只要把 Bean 交给 Spring 管理即可。要理解为什么 Spring Event 能做到“发布和监听互不感知”关键在多播器这一层。发布器只负责把事件交给多播器多播器根据事件类型找到所有匹配的监听器逐个同步调用。默认是同步的同一个线程内执行完发布方法才会返回。这也是新手最容易误判的一点——不加 Async 的事件监听本质上跟直接方法调用没有性能差别。2.2 三种事件源继承、注解与泛型监听器代表三种维护习惯Spring Event 提供三条路定义事件和监听器选型决定了代码的可读性和维护成本这也是最常用的三种写法。第一种是继承 ApplicationEvent 定义事件监听器实现 ApplicationListener 接口。这是最原始的方式代码最啰嗦但是兼容性最好适合框架级封装。public class OrderCreatedEvent extends ApplicationEvent { private final Long orderId; public OrderCreatedEvent(Object source, Long orderId) { super(source); this.orderId orderId; } public Long getOrderId() { return orderId; } }Component public class OrderCreatedListener implements ApplicationListenerOrderCreatedEvent { Override public void onApplicationEvent(OrderCreatedEvent event) { Long orderId event.getOrderId(); // 记录审计日志或发送通知 } }这里的 super(source) 是必须的构造器第一个参数代表事件源对象一般是发布事件的 Bean 自身。ApplicationListener 是泛型接口Spring 在注册时通过泛型类型做事件匹配所以监听器类型参数不能省略。这种方式的问题在于事件类耦合了 Spring 的 ApplicationEvent业务对象被框架侵入测试时还得 new 一个事件对象写起来比较别扭。第二种是纯 POJO 事件类加 EventListener 注解这是目前的主流写法也是我推荐新项目直接用的一种。public record OrderCreatedEvent(Long orderId, String orderNo) { }Component public class OrderEventListener { EventListener public void onOrderCreated(OrderCreatedEvent event) { Long orderId event.orderId(); // 刷新订单缓存、发送通知 } }这种方式事件类就是一个干净的 Java 对象不依赖 Spring 任何 API。EventListener 标注在业务方法上Spring 根据方法入参类型自动推断监听的事件类型。方法名随意跟普通方法一样遵守命名规范即可。好处是业务代码完全不被框架污染测试时直接 new OrderCreatedEvent 传进去就能跑。推荐。第三种是 TransactionalEventListener专门解决事件和事务的关系问题。它比 EventListener 多了一个事务阶段控制能力默认在事务提交后触发。我在第 4 章会展开讲。2.3 发布器注入方式与同步异步的开关有了事件和监听器发布这一侧简单得多。任何 Spring 管理的 Bean 都可以注入 ApplicationEventPublisher 并调用 publishEvent 方法这个方法有两个重载publishEvent(Object event) 和 publishEvent(ApplicationEvent event)。从 Spring 4.2 开始推荐用 Object 重载因为它对 POJO 事件无要求底层会包装成 PayloadApplicationEvent。Service public class OrderService { private final ApplicationEventPublisher eventPublisher; public OrderService(ApplicationEventPublisher eventPublisher) { this.eventPublisher eventPublisher; } public void createOrder(Long orderId, String orderNo) { // 订单落库逻辑 OrderCreatedEvent event new OrderCreatedEvent(orderId, orderNo); eventPublisher.publishEvent(event); } }这里我用的是构造器注入而不是 Autowired 字段注入。构造器注入的好处是依赖显式、不可变、便于单测时传 mock 发布器。发布事件后如果监听器没有配置 Async所有监听器会在当前线程依次执行完毕publishEvent 才返回。要把监听器改成异步有两种做法一种是在监听方法上加 Async另一种是自定义 SimpleApplicationEventMulticaster 并设置 TaskExecutor。我一般用前者因为粒度更细只异步想异步的方法。需要在启动类或配置类上加 EnableAsync 开启异步支持否则 Async 不生效。3. 从零跑通一个 Spring Event 示例最小工程与完整代码3.1 准备一个最简 Spring Boot 工程先搭一个最小环境。Spring Event 是 spring-context 模块的能力Spring Boot 的 web 或基础依赖已经传递引入不需要额外加依赖。# 以 Spring Boot 3.x 为例创建一个空工程后在 pom.xml 加入基础依赖 dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency加一个 spring-boot-stater 就够跑事件机制了。验证版本匹配Spring Boot 3.x 对应 Spring Framework 6.xJDK 17 起步Spring Boot 2.7 对应 Spring 5.3.xJDK 8 或 11 都行。建议直接用 Spring Boot 3.x 起步事件机制 API 没有破坏性变化。工程结构保持简单一个启动类、一个事件类、一个发布服务、两个监听器。package com.example.demo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.scheduling.annotation.EnableAsync; EnableAsync SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } }这里 EnableAsync 必须显式开启。很多新手跑异步监听不生效排查到最后发现就是少了这个注解。它本质上是给 Spring 容器注册了一个代理后置处理器让标注了 Async 的 Bean 方法被代理拦截并丢给线程池执行。3.2 事件类与监听器写一个登录成功事件场景定得越具体越好复现。就以我最常用的“用户登录成功”为例发布一个登录事件监听器分别做两件事记录登录日志、发送欢迎通知。package com.example.demo.event; import java.time.LocalDateTime; public record LoginSuccessEvent(Long userId, String username, LocalDateTime loginTime) { }用 record 定义事件类简洁且不可变这是 Java 17 时代最省事的写法。如果你还在用 Java 8退回去写普通 class 加 final 字段和构造器即可事件类的要义是携带业务上下文数据。这里携带了用户 ID、用户名、登录时间监听器拿这三个数据足够做审计和通知了。下面写监听器。第一个监听器做登录日志记录第二个做欢迎通知。为了演示同步和异步的差别记录日志用同步发通知用 Async。package com.example.demo.listener; import com.example.demo.event.LoginSuccessEvent; import org.springframework.stereotype.Component; import org.springframework.context.event.EventListener; import org.springframework.scheduling.annotation.Async; Component public class LoginNotifyListener { EventListener public void recordLoginLog(LoginSuccessEvent event) { System.out.println([log] user event.username() logged in at event.loginTime()); } Async EventListener public void sendWelcomeMessage(LoginSuccessEvent event) { Thread.sleep(2000); System.out.println([notify] welcome event.username()); } }我故意在 sendWelcomeMessage 里加了 Thread.sleep(2000) 模拟耗时操作。同步执行时发布线程要等两秒异步执行时发布方法立即返回由线程池在后台慢慢跑。两个方法放在同一个类是可以的Spring 通过不同的方法适配器注册成两个独立的监听器互不干扰。3.3 发布服务与启动验证同步和异步的时间差发布服务负责触发整个流程。这里刻意不把发布逻辑放在 Controller 里因为事件发布通常发生在业务服务内部比如登录成功后的 service 层。package com.example.demo.service; import com.example.demo.event.LoginSuccessEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.time.LocalDateTime; Service public class LoginService { private final ApplicationEventPublisher publisher; public LoginService(ApplicationEventPublisher publisher) { this.publisher publisher; } public void login(String username) { String userId 10001; LoginSuccessEvent event new LoginSuccessEvent(Long.valueOf(userId), username, LocalDateTime.now()); System.out.println([publish] start publishing login event); publisher.publishEvent(event); System.out.println([publish] publish method returned); } }如果需要触发入口在 CommandLineRunner 里加一个测试调用即可不依赖 web 层。package com.example.demo.config; import com.example.demo.service.LoginService; import org.springframework.boot.CommandLineRunner; import org.springframework.stereotype.Component; Component public class StartupRunner implements CommandLineRunner { private final LoginService loginService; public StartupRunner(LoginService loginService) { this.loginService loginService; } Override public void run(String... args) { loginService.login(developer); } }启动应用观察控制台输出顺序。同步的 recordLoginLog 会先打印然后 publishEvent 返回输出 publish method returned两秒后异步的 sendWelcomeMessage 才打印。这就直观看到了同步和异步的差异。注意一个细节Spring 对同一事件的多个监听器默认没有严格的执行顺序保证。如果你需要 A 监听器先于 B 监听器执行要加 Order(1) / Order(2) 注解来显式规定顺序数字越小优先级越高。推荐把有依赖关系的监听器分组成同一个监听器处理避免在监听器间做顺序耦合。4. 事务事件与异步并发把事件机制玩出实战感4.1 TransactionalEventListener事务提交后才能真正发通知前面讲的都是事件发布后立即触发监听器。真实业务里有个经典问题如果事件是在事务内发布的但后续事务回滚了监听器已经执行的副作用动作怎么办比如订单创建这个方法标注了 Transactional事务内发布 OrderCreatedEvent监听器同步去调用了积分服务的接口加积分。积分加完了订单落库失败回滚积分却已经加了。这是典型的分布式事务不一致问题。Spring 提供了 TransactionalEventListener 解决这个场景它可以把监听器的执行时机绑定到事务的某个阶段。默认值是 TransactionPhase.AFTER_COMMIT也就是事务成功提交后监听器才执行。Component public class OrderTxEventListener { TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT) public void onOrderCreated(OrderCreatedEvent event) { // 订单事务已提交这里做积分赠送或发站内信 } }除了 AFTER_COMMIT还有 BEFORE_COMMIT、AFTER_ROLLBACK 和 AFTER_COMPLETION 三个阶段可选。BEFORE_COMMIT事务提交前触发监听器里的操作仍属于当前事务一般用于事务提交前的数据补充比如设置一些冗余字段。AFTER_COMMIT事务提交后触发监听器里抛出的异常不会影响主事务这是最常用的一种。AFTER_ROLLBACK事务回滚后触发常用于旁路记录失败原因或清理。AFTER_COMPLETION事务无论提交还是回滚都会触发收尾类工作。使用 AFTER_COMMIT 时有个重要限制事务已经提交监听器里如果再次访问和主事务同一个数据源它是看不到未提交数据的。这不算 bug是事务隔离级别的自然结果。4.2 没有事务时的 fallback很多人栽在这里TransactionalEventListener 默认在“事件发布时存在可用事务”的情况下才会触发。如果把前面那个登录事件改用 TransactionalEventListener然后发布方法没有加 Transactional监听器会静默不执行——这是 Spring Event 让人头疼的“事件明明发布了监听器却没反应”的常见原因之一。解决办法是设置 fallbackExecution true表示没有事务时立即执行有事务时按指定阶段执行。TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT, fallbackExecution true) public void onOrderCreated(OrderCreatedEvent event) { // do something }这里也顺带聊一下 synchronized 与监听器的并发问题。AFTER_COMMIT 阶段的事务事件在提交后执行此时数据库事务已经释放了行锁如果监听器里还需要查询订单状态会拿到最新数据但如果两个线程同时提交两个不同订单监听器又是并发执行的所以监听器里的代码也要保证线程安全不能用实例变量存状态。我见过有人把订单号塞进类字段结果高并发下日志和通知串号排查半天最后全部改成方法内局部变量才解决。4.3 异步监听器的线程池参数默认线程池根本不够用Async 默认使用 SimpleAsyncTaskExecutor它的问题在于每次执行都会创建新线程负载高时线程数不受控可能导致内存不足或线程爆炸。所以生产环境必须自定义线程池。package com.example.demo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; Configuration public class AsyncConfig { Bean(name eventExecutor) public Executor eventExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(500); executor.setThreadNamePrefix(event-worker-); executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }在监听器方法上指定线程池名称Async(eventExecutor) EventListener public void sendWelcomeMessage(LoginSuccessEvent event) { // ... }参数不是随便填的核心线程数设为 4、最大 8、队列 500 适合大部分中小业务。注意 CallerRunsPolicy 拒绝策略——线程池满时任务会在调用线程即发布事件的线程里继续执行这保证了任务不丢代价是发布线程可能被拖慢。如果业务对发布延迟敏感可以换成 DiscardOldestPolicy但要接受丢任务的风险。这块根据自己的业务选没有绝对最优。5. 避坑指南Spring Event 的 5 个经典翻车现场5.1 监听器异常导致主流程失败现象发布事件的业务方法里调用了 publishEvent监听器里调用外部服务抛了异常结果主流程也跟着失败事务回滚用户看到报错。原因Spring Event 默认同步执行监听器异常会一路抛回发布线程。如果发布方法本身在事务内监听器的异常会导致整个事务标记为 rollback-only即使主逻辑全走完事务依旧回滚。解决在监听器内部捕获所有异常并记录日志。外部依赖调用加上超时和熔断不要图省事把外部调用直接裸奔。EventListener public void sendWelcomeMessage(LoginSuccessEvent event) { try { // 调用外部通知服务 } catch (Exception e) { log.error(send welcome message failed, userId: {}, event.userId(), e); } }5.2 同类调用导致 Async 不生效现象监听器内部调用了同一个类的另一个被 Async 标注的方法异步没有生效还是同步执行。原因Spring 的异步和事务一样基于代理机制。同类内部方法调用走的是 this 引用不会经过代理对象注解自然失效。应用在自调用场景下对代理的直接绕过。解决把异步方法拆到另一个 Bean 中通过注入的代理对象调用或者直接在当前监听器方法上标注 Async让整个监听器执行体在异步线程中跑。5.3 事务监听器不触发又没有任何日志输出现象用了 TransactionalEventListener事件发布后监听器没有任何反应连日志都没有。原因发布方法没有事务或者事务传播级别不是 REQUIRED 导致当前不存在可用事务。Spring 默认要求有事务才触发监听器没有事务时权利直接跳过。解决两种选择要么给发布方法加上 Transactional 确保事务存在要么在注解上配置 fallbackExecution true让无事务时也能触发。注意如果加了 fallbackExecution true要考虑到无事务场景下无法拿到事务提交后的状态业务要能容忍不一样的数据视图。5.4 事件重复消费现象同一个事件被监听器执行了多次数据库里插入了多条重复记录。原因多播器在分发时会把同一事件发给所有类型匹配的监听器如果工程里同时存在接口式监听器和注解式监听器且都匹配同一事件会各触发一次还有一种情况是事件本身的业务含义模糊多个业务监听器都处理同一事件但职责边界不清晰。解决养成一个事件只表达一种业务动作的习惯。例如订单创建成功、支付成功要拆成两个事件而不是共用一个 OrderEvent 然后在监听器里加 if 判断。接口和注解式两种监听器不要混用在同一事件上。如果实在需要幂等在监听器里查重或者用数据库唯一索引兜底。5.5 异步监听器里的容器 Bean 注入失败现象监听器方法标注了 Async方法里自动注入的其它 Bean 出现空指针。原因Spring 的代理对象只对被代理 Bean 自身做增强Async 方法内部通过参数传入的依赖没有问题但如果这个方法所在的类不是容器管理的 Bean比如 new 出来的对象那么字段注入点全部为空。解决监听器类必须交给 Spring 管理推荐使用构造器注入获取依赖。手写 new 的监听器类上是不能触发 Spring Event 能力的。6. 让事件机制更可靠的最后一公里幂等控制与可观测性到了这个阶段事件链路能跑通、事务边界清晰、异步配置到位但离生产级还差两步事件不丢、事件可追踪。先说幂等。异步事件的消费方在极端情况下会被重复投递最典型的就是订单超时和消息补偿机制。我通常会为每一个事件生成一个全局事件 ID在监听器消费前先查一次消费记录表如果已经处理过就直接返回。EventListener public void onPaymentSuccess(PaymentSuccessEvent event) { if (eventProcessed(event.eventId())) { return; } // 执行真正的业务逻辑 markEventProcessed(event.eventId()); }事件 ID 在发布时用 UUID 就可以了。注意这里查重和标记必须放在同一个事务里否则会有并发窗口导致重复执行。幂等这块不追求百分之百的严格去重目标是让重复投递不要产生可见的业务副作用。另一个习惯是给事件加一个上下文追踪字段。建议在事件类里放一个 traceId发布时从当前线程的上下文里取出来放进去监听器里打日志时带上它。这样从用户请求入口到事件消费的整条链路在日志平台里可以串起来排查。如果用的是 Spring Cloud也可以从请求头里解析 traceId再放进事件里。最后做一次压测验证。用并发工具模拟 100 个用户同时登录观察发布线程的耗时和线程池负载。如果发布方法耗时明显增长且线程池队列持续堆积说明监听器里有慢操作没拆干净。我的习惯是先压发布接口再单独压每个监听器定位慢的环节再优化。这套机制跑下来的效果就是主业务流程不再被旁路逻辑拖垮事务边界清晰故障时可以从事件链路日志快速定位。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑