
做后端开发的人应该都有过这种经历一个下单接口业务代码越堆越多。一开始只是保存订单后面要扣库存、发短信、发优惠券、加积分、通知财务……新需求一个接一个每次都要打开核心 Service 往里面塞代码。改到后面下个单跟开了个会一样全场业务都要到场。这种局面其实有个很直接的解法就是标题里说的用 Spring 的ApplicationEventPublisher做事件发布和监听。Spring 自带一套完整的事件机制核心就是ApplicationEventPublisher负责发布事件监听方通过EventListener之类的注解接收并处理。事件结束后发布方和监听方完全不用互相认识订单模块只管发事件短信、积分、优惠券各听各的。这篇文章我想用实际项目里的思路把这套机制的原理、完整代码、异步写法、事务事件以及我踩过的坑一次讲清楚。不管你是刚开始接触 Spring 事件还是已经在用但遇到监听不生效、事务时机不对这些问题都可以参考。1. 为什么需要事件驱动从一个下单场景引发的思考1.1 核心需求解析硬编码带来的三个杠杆问题先还原一下最常见的项目场景。假设你维护一个电商后台订单创建成功后需要做这么几件事扣减商品库存给用户发送下单成功短信给会员体系累计积分写入运营分析日志最简单的写法就是在OrderService.createOrder()方法里按顺序调用四个 Service 的方法。代码是能跑但问题会随着版本迭代被放大第一主链路被无关业务拖慢。短信服务一旦超时下单接口跟着变慢积分服务报了异常订单保存也可能被回滚。用户付款本来两秒完成硬生生变成“支付中”转圈半天。第二变更总是波及核心代码。每次新增一个“下单后要做什么”的需求都要打开OrderService修改订单主流程方法。订单逻辑原本是电商系统里最需要稳定、最容易出问题的部分结果它成了所有人改代码的地方。第三职责边界越来越模糊。一个负责“创建订单”的方法承载了通知、营销、统计等多种职责单元测试时还要 Mock 一堆无关依赖。时间一长这个方法就成了谁都不敢动的雷区。事件驱动要解决的正是这三个问题。它的核心思想是订单创建成功后主流程只负责发布一条“订单创建成功”的消息至于谁去扣库存、谁去发短信、谁去累计积分主流程一概不关心。1.2 设计思路观察者模式在 Spring 中的落地Spring 事件机制本质上是观察者模式Observer Pattern的一种实现。我用一个生活化类比来理解这件事你订了一个公众号公众号发新文章你不会收到电话通知而是由平台把内容推给你。你并不知道公众号团队怎么写的稿子公众号也不知道订阅列表里都有谁双方只是通过“订阅关系”联系在一起。Spring 事件机制里这个“订阅关系”由容器来维护发布方ApplicationEventPublisher发布事件监听方只要声明“我对某个类型的事件感兴趣”容器就会自动把事件投递给它。在 Spring 中ApplicationContext本身就实现了ApplicationEventPublisher接口也就是说你在任何一个被 Spring 管理的 Bean 里注入ApplicationEventPublisher就能向当前容器内广播事件。整个链路大致是业务方法调用publishEvent(event)发布事件。容器找到所有匹配的监听器。调用监听器方法把事件对象传进去。这套机制是 JVM 进程内的事件调度适合解决单体应用里“模块与模块之间解耦”的问题。如果你的场景是跨系统、跨服务通信或者要保证消息持久化不丢失那就需要引入消息队列而不是用 Spring 本地事件。这一点在选型时要想清楚别用错地方。2. 事件机制的核心角色与默认行为2.1 三个核心角色事件、发布器、监听器Spring 事件机制里主要有三个角色弄清楚它们各自负责什么后面写代码就顺了。角色职责典型写法事件对象描述“发生了什么”携带业务上下文普通 POJO 类命名常用过去时如OrderCreatedEvent发布器向容器广播事件注入ApplicationEventPublisher调用publishEvent()监听器接收并处理感兴趣的事件类上加Component方法上加EventListener有一点要特别注意Spring 4.2 之前事件对象必须继承ApplicationEvent抽象类4.2 之后Spring 放宽了要求任何普通对象都能作为事件发布。实际开发里我更推荐使用普通 POJO这样事件类不需要依赖 Spring 框架的 API在领域层、应用层都能自由使用也方便单元测试。后面所有示例都基于普通 POJO 的写法。2.2 默认行为同一线程同步执行Spring 事件的默认行为是同步执行。什么叫同步就是你调用publishEvent()之后发布方线程会挨个调用所有匹配的监听器等它们全部执行完publishEvent()才返回。监听器就像开会时现场的与会者主持人发布方提出问题现场的人一个个发言完毕会议才能继续。Spring 官方为什么默认同步核心原因是保证事务一致性。很多监听器需要在业务方法的事务范围内执行比如扣减库存和创建订单必须在同一个事务里要么一起成功要么一起回滚。如果默认异步监听器在一个新线程里执行它脱离原有事务上下文无法保证一致性。同步执行也意味着一个隐藏风险监听器方法里的耗时、异常都会直接影响主流程。后文我会专门讲怎么用Async把部分监听器挪到独立线程这里先记住默认行为就够了。2.3 监听器的匹配机制类型与条件过滤容器是怎么知道“这个事件应该交给哪个监听器”的关键在于监听器方法的参数类型。Spring 内部通过ResolvableType解析方法参数从而确定它关心哪种事件类型。Component public class OrderEventListener { EventListener public void handleOrderCreated(OrderCreatedEvent event) { System.out.println(收到订单事件 event.getOrderId()); } }这里方法参数是OrderCreatedEventSpring 就会在每次发布OrderCreatedEvent或它的子类事件时调用这个方法。如果你不想按照参数类型匹配也可以用classes属性显式指定要监听的事件类型方法参数改成Object。EventListener还有一个很实用的属性是condition支持写 SpEL 表达式按条件过滤事件。比如只有订单金额大于 100 元才发短信可以直接写在注解里不需要在代码里写一坨 ifEventListener(condition #event.amount.compareTo(100) 0) public void handleLargeOrder(OrderCreatedEvent event) { // 只处理大额订单 }条件表达式基于事件对象解析#event对应方法参数名。注意编译时如果没有开启-parameters参数SpEL 里拿不到参数名最好在事件对象里设计好简单的字段访问方式或者直接用EventListener(condition #event.orderId ! null)这种基于属性的写法。3. 实操事件定义、发布与监听完整实现3.1 第一步定义事件对象事件对象是发布方和监听方之间的“协议”它的字段设计直接影响两个模块的耦合度。我建议把它做成不可变对象所有字段用final修饰只提供构造器和 getter不给 setter。这样事件一旦发布内容就不会被某个监听器意外修改。下面是一个典型的订单创建事件public class OrderCreatedEvent { private final Long orderId; private final Long userId; private final java.math.BigDecimal amount; private final String orderNo; public OrderCreatedEvent(Long orderId, Long userId, java.math.BigDecimal amount, String orderNo) { this.orderId orderId; this.userId userId; this.amount amount; this.orderNo orderNo; } public Long getOrderId() { return orderId; } public Long getUserId() { return userId; } public java.math.BigDecimal getAmount() { return amount; } public String getOrderNo() { return orderNo; } }设计事件对象时有几个建议字段只放必要的上下文。订单 ID、用户 ID、金额、订单号这些足够不要把整个订单实体塞进去。塞大对象会带来两个问题一是监听方被迫依赖订单实体的完整结构耦合变高二是如果后续要走 MQ 序列化大对象会非常笨重。命名用过去时。OrderCreatedEvent、PaymentFailedEvent、StockDeductedEvent这类命名表达“已经发生的事实”语义清晰。命令式命名如CreateOrderEvent容易让人误以为这是要去做一件事而不是通知一件事。事件可以分类但不要一开始就设计复杂的继承体系。多数项目里平铺几个事件类完全够用继承体系维护成本往往大于收益。3.2 第二步发布事件发布方要做的只有一件事注入ApplicationEventPublisher调用publishEvent()。我推荐使用构造器注入Spring 4.3 以后单个构造器可以省略Autowired注解Service public class OrderService { private final ApplicationEventPublisher eventPublisher; public OrderService(ApplicationEventPublisher eventPublisher) { this.eventPublisher eventPublisher; } public void createOrder(OrderDTO dto) { // 1. 核心业务校验、保存订单 Order order saveOrder(dto); // 2. 业务状态变更成功后发布事件 eventPublisher.publishEvent(new OrderCreatedEvent( order.getId(), dto.getUserId(), order.getAmount(), order.getOrderNo() )); } }publishEvent有几个重载版本最常用的是publishEvent(Object event)Spring 会自动包装处理。如果你希望监听器能根据事件类型做更多定制也可以用publishEvent(ApplicationEvent event)但普通 POJO 的方式已经够用。发布位置很讲究。我在实践中总结了一条原则事件发布必须放在业务状态变更成功之后。如果订单还没保存成功就先发了“订单创建成功”事件监听方拿到的是一个不存在的订单 ID后续所有逻辑都会跟着错。所以一定要先完成核心业务写入再发布事件。3.3 第三步编写同步监听器监听器就是一个普通的 Spring Bean方法上加EventListener即可。Component public class SmsNotificationListener { EventListener public void onOrderCreated(OrderCreatedEvent event) { // 模拟发送短信 System.out.println(给用户 event.getUserId() 发送下单成功短信订单号 event.getOrderNo()); } } Component public class MemberPointsListener { EventListener public void onOrderCreated(OrderCreatedEvent event) { System.out.println(给用户 event.getUserId() 增加积分订单金额 event.getAmount()); } }如果同一个事件有多个监听器默认执行顺序不确定可以后续用Order控制。监听器方法正常情况下应该是public void返回类型没有返回值。如果你把方法返回类型写成事件对象或集合Spring 会自动将返回值作为新事件继续发布形成链式事件。这个功能看起来很酷但我在真实项目里很少用事件链一长排查问题非常痛苦。除非业务链路特别清晰否则建议保持单一发布、独立处理。3.4 进阶操作异步监听别让通知拖慢主链路同步监听器有一个明显短板如果短信服务响应要 500ms下单接口就要陪它等 500ms。对于短信、邮件、日志这类不关心结果的旁路业务异步是更好的选择。开启异步监听需要三步。第一步在配置类上启用异步支持Configuration EnableAsync public class AsyncConfig { Bean(orderEventExecutor) public Executor orderEventExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(4); executor.setMaxPoolSize(8); executor.setQueueCapacity(500); executor.setThreadNamePrefix(order-event-); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }第二步写监听器时在EventListener上叠加Async指定线程池Component public class SmsNotificationListener { Async(orderEventExecutor) EventListener public void onOrderCreated(OrderCreatedEvent event) { // 发送短信不阻塞主线程 } }线程池的四个参数值得认真想一下。corePoolSize是常驻线程数日常流量下用几个线程足够maxPoolSize是峰值上限queueCapacity是缓冲队列容量。Spring 默认情况下如果只加了EnableAsync没有定义任何Executor它会尝试找容器内唯一的TaskExecutorbean找不到就用SimpleAsyncTaskExecutor。这个最简单的实现不会复用线程每个任务都新建线程在高并发下可能直接打爆系统所以只要你用异步事件就一定要自建线程池。异步监听的代价是失去了事务上下文和调用链上下文并且监听器里的异常不会传回发布方。后面排查技巧部分我会专门讲怎么处理。3.5 事务事件等提交后再动作避免回滚“白忙一场”异步解决的是性能问题事务事件解决的是数据一致性问题。假设你这个事务方法里做了两件事保存订单、发布订单创建事件监听器收到事件后立刻给用户发短信。如果保存订单之后、事务提交之前后面某一步发生了异常导致事务回滚订单实际上没有创建成功用户却已经收到了“下单成功”短信这就是典型的脏通知。Spring 提供了TransactionalEventListener让监听器可以绑定到事务的某个阶段执行Component public class SmsNotificationListener { TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT, fallbackExecution true) public void onOrderCreated(OrderCreatedEvent event) { // 事务提交后再发送短信 } }事务阶段有四种phase 值执行时机典型用途BEFORE_COMMIT事务提交前预校验、补充数据AFTER_COMMIT事务提交后发通知、发消息、同步缓存AFTER_ROLLBACK事务回滚后清理数据、补偿处理AFTER_COMPLETION事务结束提交或回滚通用收尾这里有两个特别容易踩的坑。第一个坑是fallbackExecution。它的默认值是false含义是如果发布事件时当前没有事务监听器根本不会执行。很多同学写完TransactionalEventListener发现监听器没反应排查半天最后发现是发布方方法没加Transactional。如果你希望“有事务就在提交后执行没有事务也照常执行”就显式设置fallbackExecution true。第二个坑是在AFTER_COMMIT阶段原有事务已经结束如果你需要在这个阶段写数据库必须在监听器方法上开启新事务典型写法是TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT, fallbackExecution true) Transactional(propagation Propagation.REQUIRES_NEW) public void afterCommit(OrderCreatedEvent event) { // 此时是全新事务 }REQUIRES_NEW的含义是挂起当前事务如果有开启一个全新事务。不理解这一点的同学在这个阶段写数据会莫名其妙报“No transaction”或者参加到一个意想不到的事务里这里提前说明白。4. 实战中的高频坑与排查手记4.1 监听器不生效先按这 6 个原因逐一排查学完基本用法后第一个常遇到的问题就是事件发了日志也打了监听器就是不执行。根据我自己的排障经验按顺序检查这几项监听器类没有被 Spring 管理。最常见。EventListener只是方法的注解类上必须要有Component、Service等能被扫描到的组件注解。Spring 扫描包没有覆盖到监听器类所在的位置。项目结构如果拆了多模块ComponentScan配置的 basePackage 可能没包含你的监听器包。发布的事件类型和监听器参数类型不匹配。发布的是子类监听器参数声明的是父类可能能匹配但发布的是父类实例监听器参数指向一个更具体的子类匹配不上。统一使用同一个事件类最省心。用了TransactionalEventListener但发布方没有事务且fallbackExecution为 false。前面提过这种情况下监听器静默不执行。泛型事件擦除问题。如果你定义了一个带泛型的事件类DataChangedEventT监听器写EventListener public void onChange(DataChangedEventString event)由于泛型擦除Spring 类型解析可能不按预期匹配。经验之谈事件类尽量不要设计泛型需要区分类型就在事件里加一个业务类型字段。监听器方法不是 public。部分版本和代理模式下非 public 方法不能正常触发事件。保险起见监听器方法一律声明public。排查时最直接的办法是在监听器方法第一行打日志确认有没有进入方法。如果实在定位不到可以临时在发布方断点看ApplicationEventMulticaster.getApplicationListeners()返回的监听器列表确认容器里到底有没有注册这个监听器。4.2 多个监听器执行顺序用 Order 显式控制同一个事件被多个监听器监听是很正常的。默认情况下Spring 会通过AnnotationAwareOrderComparator排序但如果没有显式声明顺序排列结果难以预判。比如你希望“先扣库存再发短信”如果不控制顺序短信可能在扣库存之前发出用户收到短信后实际库存扣减失败体验很糟糕。控制顺序用Order注解Component public class StockDeductListener { Order(1) EventListener public void onOrderCreated(OrderCreatedEvent event) { // 第一顺序执行扣减库存 } } Component public class SmsNotificationListener { Order(2) EventListener public void onOrderCreated(OrderCreatedEvent event) { // 第二顺序执行发送短信 } }Order值越小优先级越高。这里提醒一句能用显式顺序控制就别依赖监听器的注册顺序或者字母顺序那些都是不可靠的实现细节。4.3 异常处理同步回传与异步吞噬异常是事件机制里最需要拿捏的地方不同模式下差别非常大。同步模式下监听器方法抛出异常会直接传回publishEvent()调用方。如果这个异常是RuntimeException并且发布方方法上有Transactional事务会被标记回滚。有些场景这正好是你想要的订单创建后监听器做库存预占库存不足就抛异常整个订单回滚。但更多场景下你并不希望一个短信发送失败导致下单失败。所以写同步监听器时要想清楚这个监听器的失败到底应不应该影响主流程如果不应该就在监听器方法内部自己捕获异常而不是让它往外冒。异步模式下情况又变了。异步线程里的异常不会传回主线程如果你没有在监听器方法内捕获异常会被线程池吞掉或者只打印到线程的异常处理回调里日志排查难度很大。最稳妥的做法是异步监听器方法内部全程捕获并记录日志不要“裸奔”Async(orderEventExecutor) EventListener public void onOrderCreated(OrderCreatedEvent event) { try { smsClient.send(event.getMobile(), 您的订单已创建成功订单号 event.getOrderNo()); } catch (Exception e) { log.error(异步发送短信失败, orderNo: {}, event.getOrderNo(), e); } }如果要对所有异步任务统一做异常处理可以配置AsyncUncaughtExceptionHandler但一个监听器一个 try-catch 反而更直观容易定位问题。另外要关注线程池的拒绝策略。队列满了之后新提交的任务会走到RejectedExecutionHandler。我配置过几种策略CallerRunsPolicy不丢弃任务由调用者线程也就是主业务线程来执行。好处是不丢事件坏处是异步变同步主线程被拖慢。DiscardPolicy、DiscardOldestPolicy直接丢弃任务。在高流量下你的事件会静默丢失业务无感知。AbortPolicy直接抛RejectedExecutionException。具体怎么选取决于业务对事件丢失的容忍度。短信丢了可以接受订单状态同步丢了就不能接受。选择了策略之后线上最好监控线程池的活跃线程数和队列积压量一旦逼近阈值可以及时告警。4.4 循环事件与幂等设计监听器里再发布事件可以形成事件链。从设计角度说事件链能解决“一个动作引发多个关联业务”的问题但链条越长维护成本越高循环风险越大。举一个反面例子订单创建事件被积分监听器捕获积分监听器处理完后发布“积分变更事件”积分变更事件又被优惠券监听器捕获优惠券监听器里又发布“用户等级变更事件”……如果某个事件类型在环形链路里出现就可能无限循环。避免这个问题有两个实用原则一是事件链控制在两级以内核心业务事件发布后各监听器自己处理不要动不动再发新事件二是在关键链路上做“已处理”标识哪怕代码路径出现异常也能及时终止。权威的兜底方案是事件幂等。具体做法也不复杂每个事件对象带上唯一的业务标识比如orderId监听器在处理前先去 Redis 做SETNX或者查数据库唯一索引如果已经处理过就直接返回。这个方案在本地事件和 MQ 场景下都适用属于值得提前设计的基础设施。5. 进阶扩展与个人经验分享5.1 用事件重新划分模块边界事件机制最大的工程价值是可以让模块之间的依赖从“实现依赖”变成“事件契约”。如果你的项目有多个业务模块比如订单、会员、营销订单模块不需要在编译期依赖会员模块的 Service 接口只需要定义一个事件类会员模块自己去监听。这样模块间的依赖方向从“订单 → 会员、库存、短信”变成“订单 → 事件发布器”、“会员、库存、短信 → 事件监听器”。依赖方向反转之后新增一个“下单后赠送优惠券”的需求只需要新增一个监听器类订单模块的代码一行都不用动。对天天改需求的项目来说这个收益非常明显。事件类建议放在公共的契约模块里比如xxx-common-contract发布方和监听方都依赖这个模块。要注意保持事件类的稳定字段只增不改不在同一个事件里改字段语义否则上下游很容易踩雷。5.2 异步线程的上下文传递使用异步事件之后还有一个隐形问题ThreadLocal里的上下文信息在异步线程里会丢。最典型的是日志链路 IDTraceId。主线程里打日志能串起整条链路但异步线程里如果没有传递日志就断链了排查问题时完全不知道这个线程是从哪个请求触发的。有两种解决方案。第一种在设计事件对象时就把需要的信息显式放进去比如userId、orderNo这是最可靠的方式不依赖任何魔法。第二种配置ThreadPoolTaskExecutor时使用TaskDecorator在任务提交前把主线程的ThreadLocal复制到工作任务里executor.setTaskDecorator(runnable - { MapString, String contextMap MDC.getCopyOfContextMap(); return () - { if (contextMap ! null) { MDC.setContextMap(contextMap); } try { runnable.run(); } finally { MDC.clear(); } }; });这种方式对日志链路恢复很有用。如果你的项目里用到了TraceId、用户上下文之类的ThreadLocal建议在搭建异步事件基础设施的时就加上后面再补会很痛苦。5.3 关于事件机制定位的几条体会用过一段时间后我对 Spring 事件机制有了一些判断分享出来供参考。进程内解耦选 Spring 事件没错。单体应用内模块之间通信优先考虑它。实现成本低没有额外的中间件依赖调试也友好。可靠性要求高别硬撑。本地事件不持久化服务重启会丢事件。如果业务要求“订单创建后库存必须扣减一条都不许丢”本地事件满足不了需要事务性发件箱Outbox模式或者直接上 MQ。事件语义要准确。发布的是“已经发生的事实”比如OrderCreatedEvent不是“帮我做某件事”的命令。命令式语义适合 RPC 或消息指令不适合事件通知。不要为了解耦而解耦。项目里只有几个内部动作硬上事件机制反而增加阅读成本。事件的价值在规模变大后才体现出来小项目先用简单的同步调用等真出现多模块重复监听的需求了再引入事件也不迟。给线程池取名、设参数。异步监听器如果用了默认线程配置日志里全是simpleAsyncTaskExecutor-1这种名字一旦出问题根本不知道是谁在跑。自定义线程池并设置有业务含义的线程名前缀线上排查省很多力气。我自己第一次接触这套机制时写过一版全部同步监听的代码结果一个短信回调超时把整个下单接口拖垮了。后来改成Async 独立线程池又遇到监听器方法不执行的问题排查到最后才发现是TransactionalEventListener的fallbackExecution默认值是 false。这些教训让我意识到Spring 事件机制看似简单但同步、异步、事务三个阶段背后的语义差别非常大每一个都要根据具体业务场景去选型。如果你目前正处在“想重构一个越来越臃肿的业务 Service”的阶段我的建议是先把事件对象的字段设计好想清楚哪些监听器必须同步、哪些可以异步、哪些要等事务提交然后再动手改造。这套机制带给项目的不只是代码结构上的整洁更是一种“各模块只关心自己职责”的思维方式。