Spring Event实战:从业务解耦到异步监听的最佳实践
2026/9/7 6:36:41 网站建设 项目流程

简介:面向Spring开发者的Spring Event事件机制示例工程,适合希望降低业务模块耦合、掌握事件发布与监听流程的初中级程序员。压缩包共75个文件,以Java源码为主,包含事件类、发布者、监听器完整代码,同时提供class编译文件、XML配置、properties及Eclipse工程文件(.project、.classpath、.checkstyle等),便于直接导入IDE运行调试。包体仅44KB,轻量精简,目录结构清晰。已有675人学习下载。示例覆盖ApplicationEvent自定义事件、@EventListener注解与ApplicationListener接口两种监听方式、事件处理顺序控制、同步异步线程模型等关键知识点,并附带测试场景模拟,可帮助读者快速理解Spring Event在真实项目中的用法,将解耦开发思路应用到自己的模块设计中,也适合作为团队内部技术分享的参考工程。 最近重构订单中台,我在很多模块里引入了Spring Event做解耦,效果非常明显。以前用户下单成功后,要发短信、发站内信、加积分、更新统计、推送报表,这些逻辑全塞在createOrder方法里,一个方法六七十行,每加一个需求就要动老代码,测试也越来越难写。改成事件机制后,主流程只保留落库和发布事件,其它动作全部交给监听器,代码清爽,扩展也容易。

这篇文章不是抄官方文档,而是把我实际项目里的Spring Event示例、进阶玩法和踩过的坑整理一遍,适合正在做业务解耦、或者刚接触Spring事件机制的同学,看完可以直接在自己项目里跑通。

1. 为什么我建议你用Spring Event:从一段"恶魔代码"说起

1.1 一个典型的业务耦合问题

我先拿实际项目里最常见的代码来说。很多订单服务的创建逻辑长这样:

public void createOrder(OrderCreateRequest request) { Order order = orderDao.insert(request); // 1. 订单落库 smsService.send(request.getMobile(), "订单创建成功"); // 2. 发短信 messageService.push(request.getUserId(), "订单创建成功"); // 3. 发站内信 integralService.add(request.getUserId(), 100); // 4. 加积分 reportService.refreshCreateOrder(); // 5. 更新统计 }

这段代码最大的问题不是长,而是把"订单创建"和"创建后的附加动作"绑死在一个事务里。短信服务超时,订单创建就跟着失败;第4步积分加失败了,整个接口报错;下次要加一个"送优惠券"的功能,还得回过来改这个方法。业务在变,这个方法会一直膨胀,最后变成谁都不敢动的老代码。

实际上发短信、加积分、更新报表这些动作,对"订单创建成功"这事儿来说都是旁观者。订单服务只需要把"订单创建好了"这个消息广播出去,谁关心、谁处理,是别人的事。这正是事件机制要解决的思路。

1.2 观察者模式和Spring Event的对应关系

Spring Event就是观察者模式在Spring框架里的落地实现。理解这套机制,只需要记住三个角色:

  • 事件对象:描述"发生了什么",Spring 4.2之前必须继承ApplicationEvent,现在任意POJO都能当事件,省事很多。
  • 发布器:ApplicationEventPublisher,负责广播事件,它不关心谁在监听。
  • 监听器:用@EventListener标注的方法,或者实现ApplicationListener接口的类,收到事件后执行自己的逻辑。

我用点外卖来类比:你下单是发布事件,商家接单、骑手取餐、平台推送通知就是不同的监听器。商家不需要知道骑手存在,骑手也不用关心商家怎么做饭,大家各干各的,互不阻塞。Spring Event的价值就是让业务模块之间从"直接调用"变成"发布-订阅",耦合自然降下来。

2. 从零开始:一个最简单的Spring Event示例

2.1 定义事件对象

在Spring 4.2之后,写一个事件类就是写普通POJO,不需要继承任何父类:

public class OrderCreatedEvent { private final Long orderId; private final String userMobile; public OrderCreatedEvent(Long orderId, String userMobile) { this.orderId = orderId; this.userMobile = userMobile; } public Long getOrderId() { return orderId; } public String getUserMobile() { return userMobile; } }

我个人强烈建议事件字段用final修饰,事件发布后不可变。原因很简单:事件对象会在多个监听器之间传递,如果一个监听器改了字段,后面监听器读到的是被改过的值,排查起来非常痛苦。命名上就按"业务动作+Event"来,比如OrderCreatedEventOrderPaidEventStockChangedEvent,让人一眼就能看出发生了什么。

2.2 写一个监听器

监听器就是一个普通Spring Bean的方法加上@EventListener注解:

@Component public class OrderNotifyListener { @EventListener public void onOrderCreated(OrderCreatedEvent event) { smsService.send(event.getUserMobile(), "您的订单已创建,编号:" + event.getOrderId()); } }

注意,监听方法的方法名可以随便起,关键是参数类型。Spring会在发布事件时,根据事件对象运行时类型找到所有参数类型匹配的监听方法。你也可以用实现ApplicationListener接口的方式写监听器,但注解方式更灵活,一个类里可以同时监听多个事件,还能玩条件表达式,后面会讲。

2.3 发布事件

在业务代码里注入ApplicationEventPublisher,需要发布时调用publishEvent

@Service public class OrderService { private final ApplicationEventPublisher publisher; public OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; } @Transactional public void createOrder(OrderCreateRequest request) { Order order = orderDao.insert(request); publisher.publishEvent(new OrderCreatedEvent(order.getId(), request.getMobile())); } }

默认情况下,publishEvent是同步的。也就是说,发布事件后,Spring会找到所有匹配的监听器并逐个执行完,publishEvent才会返回。如果监听器里有耗时的短信推送、第三方调用,下单接口的RT会被明显拉长。这里先记住这个结论,后面讲异步再解决。

整个流程走一遍就是:请求进入createOrder-> 订单落库 -> 发布事件 -> Spring根据事件类型找到监听器 -> 执行onOrderCreated-> 返回。逻辑清晰,扩展时也不需要动主流程。

3. 让事件更好用:异步、事务、条件控制

3.1 异步监听:@Async + 自定义线程池

发短信、发站内信这类操作完全没必要阻塞主流程,给监听器加上@Async就能异步执行:

@Component @EnableAsync public class OrderNotifyListener { @Async("notifyExecutor") @EventListener public void onOrderCreated(OrderCreatedEvent event) { // 异步执行,不会阻塞发布线程 smsService.send(event.getUserMobile(), "您的订单已创建,编号:" + event.getOrderId()); } }

这里有三个容易踩的坑。第一,@EnableAsync要记得加,否则@Async不生效。第二,最好自定义线程池并指定名称,别用默认的。Spring Boot默认会提供一个applicationTaskExecutor,但它的线程数、队列长度不一定适配你的业务,高并发下容易成为瓶颈。我常用的是这样的配置:

@Bean("notifyExecutor") public Executor notifyExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(200); executor.setThreadNamePrefix("notify-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; }

CallerRunsPolicy的意思是线程池满了就退回到调用方线程继续执行,而不是直接把任务丢掉。对通知场景来说,慢一点可以接受,但消息丢失不能接受。第三,@Async@EventListener都是靠代理实现的,如果监听器在同一个类内部用this调用自己,代理不生效,异步就会失效,这一点在排查问题时特别值得留意。

3.2 事务提交后再处理:@TransactionalEventListener

如果事件是在事务方法里发布的,而监听器里要做一些对数据一致性敏感的操作,比如发送MQ或者处理账务,直接监听会有两个问题:事务还没提交,监听器查不到最新数据;监听器里的数据库操作会和主事务抢连接。@TransactionalEventListener就是为了解决这个时机问题:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onOrderPaid(OrderPaidEvent event) { // 订单事务提交成功后,再发短信/MQ }

默认的phaseAFTER_COMMIT,也就是事务成功提交后才触发。除此之外还有BEFORE_COMMITAFTER_ROLLBACKAFTER_COMPLETION,按需选择。

有个特别容易忽略的细节:如果发布事件的方法不在事务里,这个监听器默认是不执行的。想让它没事务时也执行,得加上fallbackExecution = true

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void onOrderPaid(OrderPaidEvent event) { // ... }

我遇到过不止一次:某个监听器不触发,排查半天发现是外面包了一层事务,但那个事务被切面吞掉了,@Transactional根本没生效。所以我把这条规则记在心里:事务事件监听器,没事务就静默不执行,有这个特性在,就一定要想清楚是否加fallbackExecution

3.3 条件监听:一个监听器按条件分派

@EventListener注解里可以写SpringEL表达式,满足条件才触发。最常见的两个场景,一是按事件的某个字段过滤:

@EventListener(condition = "#event.success") public void onTradeResult(TradeResultEvent event) { // 只有success=true才会进来 }

二是按业务来源分流:

@EventListener(condition = "#event.source == 'mobile'") public void onChannelNotify(OrderCreatedEvent event) { // 只有来源是mobile才处理 }

条件表达式看着灵活,但别写太复杂。一旦SpEL表达式写错,方法直接不执行,而且不会报错,排查成本很高。我的原则是:简单条件用condition,复杂逻辑就拆成多个监听器方法,或者在方法体里用if判断,保证可读性和可排查性。

另外Spring还支持监听泛型事件。比如定义DataChangeEvent<T>,监听方法里写DataChangeEvent<User>,Spring会按方法签名里的泛型参数匹配,只收到对应泛型类型的事件。这套机制适合做通用数据变更通知,但刚上手时不建议一上来就搞泛型,先把基础事件跑通,再逐步增加复杂度。

4. 实战案例:订单支付成功后的通知与积分

4.1 业务拆解

我们模拟一个支付回调场景,逻辑包括:更新订单状态为已支付、发短信通知用户、给用户加积分、更新用户等级、触发大数据报表统计。在设计上我把它们拆成几个独立环节,互不干扰:

  • OrderPaidEvent:携带订单ID、用户ID、支付金额、支付时间。
  • 短信监听器:负责发短信。
  • 积分监听器:负责加积分。
  • 报表监听器:负责同步统计数据。

支付回调方法只做两件事:修改订单状态、发布事件。其它动作全部后移,任何一个环节失败都不影响订单支付状态。

4.2 核心代码

定义事件:

public class OrderPaidEvent { private final Long orderId; private final Long userId; private final BigDecimal amount; public OrderPaidEvent(Long orderId, Long userId, BigDecimal amount) { this.orderId = orderId; this.userId = userId; this.amount = amount; } // getter 省略 }

发布事件:

@Transactional public void handlePaid(PaidCallbackRequest request) { orderService.markPaid(request.getOrderId()); publisher.publishEvent(new OrderPaidEvent(orderId, userId, amount)); }

监听器:

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void onOrderPaid(OrderPaidEvent event) { smsService.sendTemplate(event.getUserId(), "paySuccess", event.getAmount()); } @Async("notifyExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void onOrderPaidForIntegral(OrderPaidEvent event) { integralService.add(event.getUserId(), event.getAmount()); }

这里我把短信监听做成同步事务事件,保证事务提交后短信一定发出去;积分监听则放到异步线程里,因为积分服务偶尔抖动,不想拖慢回调接口。

4.3 为什么这么做

这样设计最大的收益是支付回调主流程稳定了,新增一个"支付成功送优惠券"的功能时,只需要再加一个监听器,完全不碰原方法。监听器之间彼此独立,可以用不同的线程池、不同的失败策略,扩展性很强。

代价也需要说清楚:事件发布和监听器执行如果不在一个线程里,事务上下文、请求上下文、traceId这些都没有了,监听器里拿不到RequestContextHolder这类线程绑定的数据。所以我设计事件对象时,会把userIdorderIdamount这些关键业务字段全部塞进去,监听器只依赖事件携带的数据,不依赖线程上下文,这样最稳妥。

5. 常见问题与排查技巧速查

5.1 监听器不触发的几个原因

我把平时排查"事件不执行"的思路整理成一张速查表:

现象可能原因解决方案
监听器完全没执行监听器类没被Spring扫描到检查@Component、包扫描路径
监听器没执行发布的事件类型和监听参数类型不匹配确认发布对象运行时类型与监听方法参数一致
监听器没执行@TransactionalEventListener但发布方无事务fallbackExecution = true
监听器没执行监听方法里写了SpEL condition但不满足简化条件或改在方法体内判断
监听器没执行异步事件线程池异常没有日志检查异步线程池的异常处理,加监控

Spring匹配监听器时有一个特性要注意:它是按事件对象的运行时类型去匹配的。如果发布的是子类事件,监听父类的监听器也能收到。反过来,如果发布的是接口类型,监听具体实现类的监听器就收不到。这个特性有时挺好用,比如定义一个BaseNotifyEvent,所有通知类监听器都能统一处理;但在泛型事件里也容易埋雷,泛型参数不匹配时监听器会静默不执行,排查很费劲。

5.2 @Async失效怎么排查

@Async失效是个高频问题,90%的原因出在代理上。常见情况有:同一个类内部this调用,绕过代理;@EnableAsync没加;监听器类没注册成Bean;方法或类是final的,CGLIB没法代理,异步逻辑直接失效。

排查技巧很直白:在异步方法里打印一下当前线程名。如果和发布事件的线程一样,说明代理没生效;如果线程名变成了notify-之类的自定义前缀,说明异步链路走通了。另外,一旦用了自定义线程池,可以在监控里加一个线程池状态指标,比如队列积压数、活跃线程数,防止线程池被打满后悄悄丢了任务。

5.3 异常和执行顺序处理

同步监听器里抛异常,异常会一路抛到publishEvent调用处,后面的监听器不会继续执行。这是个容易出大事的细节:如果第一个监听器挂了,后面所有监听器全部被跳过。我的处理习惯是:重要的监听器内部自己捕获异常并记录日志,不往外抛;不重要的通知类监听器直接try-catch;多个监听器都需要执行的时候,优先用异步隔离线程。

监听器执行顺序可以用@Order控制,数值越小越先执行。比如先扣减库存的监听器设@Order(1),发通知的监听器设@Order(100),能让关键依赖先执行完。

还有一个事务上的坑:在AFTER_COMMIT事务事件监听器里做远程调用要小心。事务虽然提交了,但事务同步管理里可能还持有数据库连接,如果远程调用很慢,连接一直不释放,数据库连接池会被拖垮。我一般只在这个阶段发MQ或者写本地表,真正的远程RPC交给MQ消费端去做。

5.4 给事件机制加一层可观测性

项目里我给事件发布和监听过程加了简单的指标监控:发布事件时记录事件类型、耗时和调用次数,监听器执行前后也埋点。Spring Boot Actuator加Micrometer的配置很成熟,通过/actuator/metrics能直接看到事件处理的情况,排查问题时会省很多力气。这里也提醒一句,生产环境要把监控端点保护好,配上鉴权,别让敏感信息裸奔。

6. 项目落地后我沉淀的几点体会

6.1 我在团队里定的几条规矩

用Spring Event三四年了,最大的感受是"解耦不是银弹"。事件机制让主流程变短了,但也让调用链变得隐晦,一个请求的完整处理路径不再是线性可读的。所以在团队里我定了几条规矩:

  • 事件类统一放在event包里,命名必须体现业务动作,不允许出现Event1这种无意义的名字。
  • 事件对象只放必要业务字段,别把EntityHttpSession、请求对象塞进去。
  • 发布事件的时机要明确,最好在事务方法内,配合@TransactionalEventListener控制触发时机。
  • 监听器里必须打印入参日志,没有traceId就手动塞一个业务ID,方便排查链路。

这些规矩治不了什么大毛病,但能避免项目后期事件满天飞、看代码像看迷宫的情况。

6.2 除了业务解耦,事件还能这样用

最后分享一个我经常用的小技巧:Spring Boot的ApplicationReadyEvent特别适合做应用启动后的初始化。比如缓存预热、定时任务注册、字典数据加载,都可以监听ApplicationReadyEvent来做,而不是在@PostConstruct里做。原因是@PostConstruct执行时,很多依赖Bean可能还没完全准备好,监听应用启动完成事件则更安全。

事件机制还能用来做模块间通信。我在一个项目里把用户变更、商品变更都定义成事件,缓存模块、搜索模块、消息模块各自监听,彻底避免模块间互相直接调用Service。这套思路和DDD里的领域事件有点像,简单场景下用Spring Event足够,不需要一上来就引入消息中间件。关键是先把同步事件跑通,理解发布订阅的本质,再逐步上异步、上事务事件,踩过的坑自己记下来,后面就顺了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询