接到需求的时候,大多数人的第一反应还是往Service里加代码:下单成功以后要发短信、记审计日志、同步搜索索引、更新数据看板……等这一串写完,主流程里已经堆了七八个无关操作。如果项目还没接入事件机制,我一般会建议先用Spring的ApplicationEventPublisher把事件发布和监听搭起来,它能让你把这些"后置动作"从核心业务代码里拆出去,让主流程只管自己的本职工作。这篇内容就围绕这个工具展开,适合刚接触Spring事件机制、或者已经用过但对细节不笃定的开发者,内容包括最小可跑通的标准写法、同步与异步的取舍、事务提交阶段的注意事项,以及我在实际项目中踩过的坑和最终沉淀下来的使用原则。
1. 为什么要把"下单后发短信"这类逻辑从主流程里拆出来
1.1 一个每天都在发生的业务痛点
先还原一个典型场景。你在维护一个订单模块,createOrder()方法原来的逻辑很简单:保存订单、返回结果。后来产品说下单成功后要发一条短信,于是你在方法末尾加了短信发送;再后来运营要看实时订单量,你又在方法末尾加了统计数据上报;再后来风控要求记录变更日志,又塞了一段日志写入。
表面上看代码也能跑,但问题会随着功能的堆叠越来越难收拾。
- 每次新增一个"订单创建后的联动操作",都要改一遍下单主流程,哪怕只是加几行代码,也要重新走一遍全量回归。
- 如果发送短信的接口响应慢,下单接口的RT会被直接拖长,用户感知就是"点结算按钮之后转圈了半天"。
- 短信服务万一挂了呢?异常会直接抛到
createOrder()里,一件和下单无关的事,导致下单直接失败。
这种代码最大的问题不是脏,而是把"订单创建"这个核心业务和"订单创建后要做的事情"强耦合在了一起。它们明明属于不同的关注点,却挤在同一个方法体里。
1.2 事件机制背后其实是观察者模式
要解开上面的结,大多数人第一个想到的是"调用反转":主流程不主动去调短信、日志、统计,而是把"订单已创建"这件事广播出去,谁关心这个事件,谁就自己订阅。
这正是观察者模式(Observer Pattern)的思路:一个主体维护一组观察者,状态变化时主动通知它们。Spring容器在很早期就内置了事件机制,核心就是ApplicationEventPublisher这个接口,配合@EventListener注解,就能够实现"发布-订阅"。
这里有个容易误会的点:很多人以为ApplicationEventPublisher是MQ那样的消息中间件,其实完全不是。它只是Spring容器内部的一套事件广播器,默认在同一个JVM进程内同步调用监听器,不跨进程、不持久化。它的价值不是做分布式解耦,而是做应用内部的逻辑解耦。理解这一点,后面很多设计取舍才有依据。
1.3 事件机制到底改变了什么
从编码角度来说,事件机制把以前"主流程主动调用多个依赖"的硬编码关系,改成了"主流程只发一个事件,各监听器自行决定要不要响应、怎么响应"的松散关系。
用订单场景打比方,改造前的主流程像是婚礼上一个人把所有亲戚朋友的安排全部手写在自己的本子里;改造后则是婚礼现场广播一声"开席了",每个人听到广播后自己去座位上吃饭。广播者不需要知道今天来了哪些人、谁坐哪一桌,它只负责把消息发得足够清楚。
收益是立竿见影的:主流程代码里不再有短信SDK、统计服务、日志客户端的依赖;新增联动逻辑时,只需要新增一个监听器,不需要动主流程一行代码;某个监听器出了问题,它的异常默认不会反向污染主流程(但这取决于同步还是异步,后面会详说)。
代价也同样明显:调用链变得不那么显式,代码跳转从"方法调用"变成了"事件匹配";排查问题时需要知道有哪些监听器、各自做了什么,否则容易摸不清数据流。所以这个工具用得好很舒服,用不好就是给自己挖坑。
2. ApplicationEventPublisher三件套:事件、发布器、监听器
2.1 事件类:用普通POJO,别急着继承ApplicationEvent
想发布一个"订单已创建"的事件,首先要定义事件本身。我见过一些老项目里的事件类还继承着ApplicationEvent,比如:
public class OrderCreatedEvent extends ApplicationEvent { public OrderCreatedEvent(Object source) { super(source); } }这其实是Spring 3.0时代的写法了。Spring 4.2以后,发布事件时不再要求事件类继承ApplicationEvent,直接传一个普通POJO进去,容器会自己包装。所以我现在的建议很简单:事件类就是一个普通类,字段越少越好,命名遵循领域语言。
public class OrderCreatedEvent { private final Long orderId; private final Long userId; private final String orderType; public OrderCreatedEvent(Long orderId, Long userId, String orderType) { this.orderId = orderId; this.userId = userId; this.orderType = orderType; } public Long getOrderId() { return orderId; } public Long getUserId() { return userId; } public String getOrderType() { return orderType; } }这个类里我只放了三个字段:订单ID、用户ID、订单类型。为什么不直接把整个订单对象塞进去?后面有一节专门聊这个,一句话概括就是:事件是"消息"而不是"快照",传递标识符最安全。
2.2 发布端:注入ApplicationEventPublisher后调用publishEvent
发布事件只需要注入ApplicationEventPublisher,然后调用publishEvent()方法:
@Service public class OrderService { private final ApplicationEventPublisher eventPublisher; public OrderService(ApplicationEventPublisher eventPublisher) { this.eventPublisher = eventPublisher; } public Order createOrder(CreateOrderCommand cmd) { Order order = saveOrder(cmd); // 订单已经入库,业务状态已变更,此时再发布事件 eventPublisher.publishEvent( new OrderCreatedEvent(order.getId(), order.getUserId(), order.getType()) ); return order; } }这里有个实践经验:publishEvent的调用时机很重要。一定要放在业务状态变更完成后,也就是订单已经落库、内存中的状态已经更新。如果你在事务方法执行到一半、还没提交事务的时候就发布事件,同步监听器会立刻执行,此时其他系统或者监听器里的查询操作很可能读不到这条订单数据。具体怎么和事务配合,第4部分会展开。
2.3 监听端:@EventListener按参数类型自动接收
监听器就是一个普通的Spring Bean,方法上标注@EventListener即可。Spring会根据方法参数类型自动匹配事件类型,比如:
@Component public class SmsNotifyListener { @EventListener public void onOrderCreated(OrderCreatedEvent event) { // 这里写发短信的逻辑 smsClient.send(event.getUserId(), "您的订单 " + event.getOrderId() + " 已创建"); } }当OrderService发布OrderCreatedEvent时,Spring会扫描容器中所有@EventListener方法,找到参数类型能匹配该事件的方法并调用它。多个监听器可以同时监听同一个事件,互不感知。
这已经是最小可复现的一套流程:定义事件、注入发布器、写监听器。跑起来就能看到监听器被触发。很多业务接入事件机制,到这一步其实就可以交付了。
2.4 两个容易误解的匹配规则
第一个误解是"监听器只能通过具体事件类型匹配吗"。其实Spring是按ResolvableType做类型检查的,也就是说如果你的监听方法参数写的是Object,那它几乎会收到所有事件;如果写的是父类事件,它也能收到所有子类事件。
@Component public class AllEventListener { @EventListener public void onEvent(Object event) { // 不建议这么写,会收到容器里所有事件,包括各种内部事件 } }这种写法我只在调试时候用过,生产环境最好不要出现,否则事件一多,这个方法会被调用得相当频繁,而且很难理清逻辑。
第二个误解是Spring只支持@EventListener注解一种方式。实际上它还支持在XML里配置ApplicationListener接口实现类,或者用@Bean配合ApplicationListener。只是@EventListener是目前最主流、最简洁的写法,新项目直接用它就好。
3. 默认同步才是常态:一次耗时监听如何拖垮主流程
3.1 用一段日志证明监听器是同步执行的
Spring的ApplicationEventPublisher默认情况下的确会在当前线程内同步调用所有监听器。这里我说的"同步",是指发布者调用publishEvent()时,会遍历匹配的监听器并依次调用,等所有监听器执行完毕,publishEvent()才返回。
我做个验证,往监听器里塞一个耗时操作:
@Component public class SlowListener { @EventListener public void onOrderCreated(OrderCreatedEvent event) { System.out.println("监听器开始,线程:" + Thread.currentThread().getName()); Thread.sleep(3000); // 模拟耗时操作 System.out.println("监听器结束"); } }然后在主流程里打印日志:
eventPublisher.publishEvent(new OrderCreatedEvent(...)); System.out.println("publishEvent返回,耗时:" + (end - start) + "ms");结果会非常直观——publishEvent()的耗时包含了SlowListener的3秒。如果这里是一个短信发送接口,短信服务响应1秒,订单接口就要多等1秒。这是事件机制最容易踩的坑:你以为它不影响性能,其实它把监听器的耗时原原本本叠加到了主流程上。
3.2 @Async开异步,并把线程池攥在自己手里
把监听器做成异步其实很简单:在启动类或者配置类上加@EnableAsync,在监听方法上加@Async即可。
@Configuration @EnableAsync public class AsyncEventConfig { }但这里我强烈建议你配置专门的线程池,不要完全依赖Spring默认的SimpleAsyncTaskExecutor。那个默认执行器每次都会新开一个线程,没有池化复用,高并发下资源开销很大,很容易把服务拖垮。
我常用的配置是这样:
@Configuration @EnableAsync public class AsyncEventConfig { @Bean("eventTaskExecutor") public ThreadPoolTaskExecutor eventTaskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(3); executor.setMaxPoolSize(10); executor.setQueueCapacity(200); executor.setThreadNamePrefix("event-exec-"); executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }监听器里通过@Async指定线程池名称:
@Component public class MessageCenterListener { @Async("eventTaskExecutor") @EventListener public void sendStationMessage(OrderCreatedEvent event) { // 这里是耗时操作:拼内容、调接口、记录发送状态 } }这样主流程调用publishEvent()时,只会把事件投递到线程池就返回,监听器在独立线程里执行,主接口的RT不再受监听器影响。
有几点值得注意:
@Async依赖Spring代理,同一个类内部调用监听方法不会走代理,也就不会异步执行。所以千万别在监听器内部自己调用自己的另一个方法来做"异步"。- 线程池的
CallerRunsPolicy是重负载时的保护策略:当队列满、线程池满,任务会被回退到调用者线程执行。对事件机制来说,这比"直接丢弃任务"安全,因为丢事件往往意味着业务动作丢失。 - 给线程池一个易识别的名字,比如
event-exec-,出问题看线程栈的时候就知道是事件线程,而不是一脸迷茫地瞎猜。
3.3 异步后的三个新问题:异常、事务线程、链路追踪
异步不是银弹。我把同步和异步的差异整理成了一张表,在实际选型时可以直接对照:
| 对比项 | 默认同步 | 开启@Async |
|---|---|---|
| 执行线程 | 发布者线程 | 独立线程池 |
| 发布者耗时 | 包含所有监听器总耗时 | 只包含投递耗时 |
| 异常影响 | 异常抛给发布者 | 异常由线程池处理,主流程无感知 |
| 事务上下文 | 可以感知当前事务 | 拿不到原事务上下文 |
| 执行顺序 | 基本可预期 | 并发执行,顺序不可控 |
| 适用场景 | 强一致、轻量级、需要回滚联动 | 耗时长、可容忍延迟、可补偿 |
其中最大的三个坑分别是:
第一个坑:异常没人管。同步模式下,监听器抛异常会一层层抛回publishEvent()调用处,至少你还有机会在try-catch里兜底。异步模式下,异常被吞在线程池里,主流程完全无感知,可能一个监听器挂了半天,线上毫无反应。所以异步监听方法里一定要自己包好try-catch,或者配置自定义的AsyncUncaughtExceptionHandler做统一告警。
第二个坑:事务上下文丢失。同步监听器执行时,还在事务方法内部,可以通过TransactionSynchronizationManager感知到当前事务。切了异步线程之后,事务上下文就被切断了,你在监听器里做数据库操作,不会再自动加入原事务。这也是为什么"监听器里直接写数据"容易出现数据对不上的问题。
第三个坑:链路难追踪。主流程里的traceId默认不会自动传给异步线程,除非你手动传递专门的上下文。排查问题的时候,从主流程日志跳转到监听器日志,往往要拼半天时间戳和业务ID。解决起来也不复杂:事件对象里带上orderId这类业务主键,监听器日志打印的时候把这个主键打出来,至少能按业务维度对上号。
3.4 哪些监听器适合同步,哪些必须异步
我自己做判断的标准其实很简单:这个监听器做错之后,能不能重跑一次?
如果能把监听逻辑做成幂等的,异步很合适,比如发通知(重复发一条短信问题不大)、同步搜索引擎(即使多同步一次也只是覆盖写)、投递消息给下游(下游幂等消费)。
如果监听器必须和主流程保持强一致,比如"下单后必须记录一条不可丢失的审计日志"、"订单状态变更后必须同时维护一份冗余表",那同步执行反而更安全。虽然牺牲了一点性能,但至少保证事务提交失败时这个监听动作也跟着失败,不会出现"主流程成功了、日志却没记上"的诡异状态。
如果既想要强一致,又不想阻塞主流程,那就不能靠@Async硬扛了,需要引入本地消息表或者MQ这类可靠异步方案,这就超出了ApplicationEventPublisher的职责范围。
4. 事务阶段的空档期:从"事件发出"到"事件真正生效"
4.1 为什么事件在事务提交前处理会踩坑
回到OrderService.createOrder()的场景。方法上有@Transactional,当方法执行到publishEvent()时,事务还没提交。此时同步监听器立刻执行,比如监听器里去查订单数据、去发MQ,会碰到两种情况:
- 在同一个事务内查询,能查到(因为还在同一个数据库连接里,能看到未提交数据),但这会给其他系统带来隐患。
- 监听器里通过新事务查询或调用外部服务,外部去查库就查不到——因为事务还没提交,数据对其他连接不可见。
举一个真实例子。某业务在订单创建后通过事件触发MQ消息,消息的消费者要去数据库里查最新订单状态。如果监听器在事务提交前发送MQ,消费者消费消息时事务已经提交了倒还好;但如果消费很快、事务很慢,消费者就会查到"订单不存在",直接告警。这种偶发问题非常难排查,因为你复现时需要刚好让事务提交慢于消息消费。
所以一个基本原则是:如果监听器的动作依赖这次事务提交后的数据,就不能在事务内同步执行,需要把执行时机延迟到事务提交之后。
4.2 @TransactionalEventListener与phase参数
Spring针对"事务提交后再处理"这个需求,专门提供了@TransactionalEventListener注解。它可以让监听器绑定到事务的某个阶段,而不是事件发布后立刻触发。
@Component public class MqPublishListener { @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) public void publishOrderMessage(OrderCreatedEvent event) { // 此时事务已经提交,订单数据对其他连接可见 mqClient.send("order-created", event.getOrderId()); } }phase参数常用的有四个值:
AFTER_COMMIT:事务提交成功后才执行,这是默认值。AFTER_ROLLBACK:事务回滚后执行。AFTER_COMPLETION:无论提交还是回滚,都会执行。BEFORE_COMMIT:事务提交之前执行,注意这仍然是事务内。
还有一个容易被忽略的参数fallbackExecution。默认情况下,如果当前根本没有事务在执行,@TransactionalEventListener监听器不会被触发。比如你发布事件的方法没有加@Transactional,那这个监听器就静默不执行。设置fallbackExecution = true后,没有事务时监听器也会直接执行,相当于给了兜底逻辑。
这里我建议:凡是依赖事务提交后数据的监听器,一律加上fallbackExecution = true,并结合实际场景判断。因为有些方法早期没加事务,后期你可能到处去排查"为什么事件发了监听器没跑",白白浪费时间。
4.3 多个监听器的执行顺序与条件过滤
同一事件有多个监听器时,执行顺序默认不做保证。如果你的业务对顺序有要求,比如"先更新统计,再发通知",需要用@Order显式声明:
@Component public class StatisticsListener { @EventListener @Order(1) public void updateStatistics(OrderCreatedEvent event) { // 先更新统计相关数据 } } @Component public class SmsNotifyListener { @EventListener @Order(2) public void sendSms(OrderCreatedEvent event) { // 后发短信 } }@Order数值小的先执行,不写默认是Ordered.LOWEST_PRECEDENCE。注意如果你用的是异步监听器,@Order只能决定"提交任务到线程池的顺序",并不能保证执行顺序,因为线程池并发跑起来后谁先完成是无法预期的。异步+多个监听器+严格顺序,这三者天然互斥。
@EventListener还支持condition属性做SpEL条件过滤。比如只处理线上订单:
@EventListener(condition = "#event.orderType == 'online'") public void handleOnlineOrder(OrderCreatedEvent event) { // 只处理线上订单的联动逻辑 }条件表达式里#event就是方法参数名,可以直接访问事件对象的属性。这个功能很实用,可以避免在监听方法开头写一堆if判断,也方便快速看懂监听器在什么条件才生效。
4.4 重复消费问题与规避方式
事件机制一旦加上异步、事务阶段、条件过滤这些能力,重复消费的问题就会冒出来。常见的情况有两种:
场景一:同一个监听器既匹配父类事件,又匹配子类事件。比如你监听Object,又监听OrderCreatedEvent,那么订单事件会被调用两次。解决办法是明确监听的具体类型,不要滥用宽泛类型。
场景二:事务重试导致事件重复发布。某业务方法事务失败后重试,publishEvent又被执行了一遍。这个问题的根源不在监听器,而在发布端——publishEvent应该只发一次。可以考虑把事件发布动作和业务操作放在同一个事务里,靠事务一致性来约束,或者引入去重表。
另外,如果监听器不是幂等的,重复执行可能会造成重复扣减、重复发券等问题。我的做法是:写监听器时默认假设它可能会被调用多次,要么做幂等控制(比如按订单ID查一下有没有处理过),要么记录处理流水。在这个问题上偷懒,后面线上出问题只能自认倒霉。
5. 事件机制被滥用后的代价与三条实践原则
5.1 事件满天飞的排查噩梦
事件机制用得越顺手,越容易失控。我接手过一个模块,里面的监听器层层嵌套:一个订单创建事件触发了一个监听器,监听器里又发了新的事件,新事件又触发另一个监听器……最后整个链路有七八层跳转。
当时排查一个线上问题时,我拿着一串订单号在日志里翻,从下单主流程跳到A监听器,再从A监听器跳到B监听器,中间还夹杂着异步线程的日志,时间戳都对得上人却看得头疼。因为没有全局追踪标识,我在日志系统里只能靠业务主键硬拼,效率极低。
后来我做了一个很小的改造:事件对象里增加一个traceId字段,每次从入口生成一次,整个事件链路无论同步还是异步,都把traceId带上,日志打印统一加这个标识。改造之后,再排查事件链路问题,直接按traceId搜日志,一秒就能把事件的全链路拉出来。
如果项目日志系统支持,也可以把traceId放进MDC(Mapped Diagnostic Context),这样每个日志行都会自动带上这个标识,连手动拼接都省了。
5.2 事件里放ID而不是放大对象
我见过有人直接发布OrderCreatedEvent时把整个Order实体塞进事件对象里:
public class OrderCreatedEvent { private final Order order; }短时间看确实方便,监听器拿到event.getOrder()就能直接用。但问题随之而来:
- 事件被异步线程处理时,如果
Order实体里有懒加载的关联对象(比如订单明细List<OrderItem>),你压根不知道什么时候会抛LazyInitializationException。 - 如果你把事件序列化到MQ,实体对象里的字段和结构会直接暴露给下游,后面实体一变,事件结构跟着变,接口兼容性一地鸡毛。
- 监听器拿到的是事件发布那一刻的对象,此时它已经是一个"过去的状态"。而监听器真正要用的,往往是"最新的状态"。与其传一个过时快照,不如让监听器按
orderId查一遍最新数据。
所以我现在的原则是:事件对象只放标识符和最少量的上下文信息。比如orderId、userId、操作来源,最多加一个枚举类型的orderType用于条件过滤。监听器需要更多数据,自己去查,这样既安全又清晰。
5.3 发布点固定在一处,别在Controller里乱发
事件发布最怕"到处发"。如果订单创建事件既在Service里发了一次,又在Controller里发了一次,或者两个Service方法都发了,监听器就会被触发两次,而且每次触发时的上下文还不一样,排查起来非常崩溃。
我的建议是:事件发布的入口固定在同一层,通常是领域服务层。Controller只负责接收请求、调用Service、返回结果,不做事件发布。OrderService.createOrder()作为订单事件唯一的发布点,所有"订单已创建"的事件只能从这里发出。
如果你担心多个地方调用createOrder()导致事件重复发布,那需要检查是不是存在"伪创建"路径。真实项目里常见的做法是:核心方法收敛成单独的方法,其他入口都走这个方法,从源头保证事件只发布一次。
5.4 三种不该用事件的场景
最后总结一下我的判断标准。不是所有"解耦"都需要事件机制,以下三种场景我会直接避开:
场景一:强一致性要求极高。比如下单必须扣库存,扣库存失败订单就不能创建。这种场景如果把"扣库存"放到事件监听器里,即使同步执行,也会增加流程长度;如果做成异步,就会出现订单已创建但库存扣减失败的脏状态。强一致业务应该放在同一个事务里用普通方法调用完成。
场景二:调用方唯一且确定不会变化。如果"下单后"只有"发短信"这一个动作,而且三年内都看不到新增动作的可能,那直接用短信Service调用就完事了。引入事件机制需要增加事件类、发布调用、监听器三个类,跳转逻辑还多了一层,得不偿失。事件机制是为"多个监听器、未来可能新增监听器"的场景服务的。
场景三:对延迟极度敏感、且无法接受异步不确定性的链路。虽然@Async能解决主流程耗时,但线程池调度本身有开销,排队时反而可能拖慢整体吞吐。更麻烦的是异步监听器的异常不可控,一旦链路涉及资金、对账这类敏感场景,异步的不确定性会让人非常不安。这种链路宁可做得"重",也要让每一步都有保障。
如果非要用事件,也要把补偿机制一并设计好。我的做法是:重要事件在监听器里增加告警日志,必要时引入本地消息表兜底,至少要知道"这个事件有没有被正确处理"。
我自己的体会是,ApplicationEventPublisher是Spring里被低估的一个能力,它能在项目早期用极低的成本把业务逻辑的组织方式理顺。但任何解耦工具都有代价,事件机制最大的代价就是"隐式调用",用得多了,代码的显式跳转关系会被切断。每次新增一个事件或监听器前,多问自己一句:这个动作真的适合异步解耦吗?它的失败会导致什么后果?想清楚这三个问题,要比多写几个监听器有价值得多。