资讯动态

Spring Event事件机制:从同步耦合到异步解耦的完整实践与避坑指南

发布时间:2026/10/18 8:36:20 来源:尧图企业网站定制
简介面向 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 ApplicationListenerT重写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 / 11record 是编译不过的这种情况老老实实用 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调用发生在事务提交之前同步监听器执行时数据库里数据还没提交当前连接的隔离级别也读不到未提交数据所以查出来是旧值。解决改成事务提交后再触发监听器用TransactionalEventListenerTransactionalEventListener(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 与 fallbackExecution5.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的行为——立即同步执行不再等待什么提交。如果你以为它还在等待事务提交就踩坑了在无事务方法里发事件fallbackExecutiontrue的监听器即时执行数据库数据可能还没落盘。我的习惯是发布方确定有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一次是异步事件在事务提交前查不到数据。从那以后我每次新增事件监听器都强制走一遍检查先确认同步还是异步再确认有没有事务边界最后看异常怎么处理。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑