1. 业务代码里的通知风暴事件机制要解决的问题先说我为什么开始认真研究Spring Boot的事件监听机制。早几年维护过一个订单系统核心的下单Service里一口气串了十来件事发优惠券、扣库存、推送站内信、同步给仓储系统、更新用户积分、给运营埋点、如果金额超过阈值还要通知财务审核……每次产品提新需求就往这个Service里再塞一段逻辑。到后面一个下单方法两百多行改一次要拉上三四个同事review出问题都不知道是哪一环炸的。这就是典型的业务耦合。下单这个核心动作和下单之后要做的各种附加动作被强行绑在了一个方法里。后来我重构的时候引入了Spring Boot的事件监听机制核心的下单逻辑只管下单其余事情通过发布事件丢出去谁关心谁去监听。改动之后的直观感受是核心方法瘦身了新增需求不再动老代码只需要新加一个监听器。如果你也在面对类似的通知风暴这篇文章应该对你有用。我会把这套机制的底层原理、从零实现的步骤、异步处理、事务边界以及我实际踩过的一些坑完整过一遍。适合已经能用Spring Boot写CRUD、但对事件机制只停留在听说过EventListener这个层面的开发者。2. 先弄懂三要素ApplicationEvent、ApplicationListener与EventPublisher2.1 三要素各自扮演的角色Spring的事件机制本质上是观察者模式的一种实现Java标准库里也有java.util.Observer/Observable但Spring这套做得更完整和容器生命周期整合得更紧密。整个机制由三个角色组成事件ApplicationEvent描述发生了什么。可以把它理解成一条消息体里面装着业务数据。监听器ApplicationListener描述某件事发生后要做什么。收到事件后执行对应逻辑。发布者ApplicationEventPublisher把事件广播出去。Spring容器本身就是一个ApplicationEventPublisher任何注入进来的Bean都能直接调用publishEvent方法。用一个生活化的类比事件是门铃响了监听器是听到门铃后去开门的人发布者就是按门铃的那只手。门铃按下去具体谁来开门、开完门之后干什么按门铃的人根本不需要知道。2.2 从publish到回调一次事件广播的完整链路很多人只知道用不清楚发布之后Spring内部干了什么。我理一下完整链路代码层面它的核心入口是AbstractApplicationContext的publishEvent方法最终会交给ApplicationEventMulticaster去分发。整个过程大致是这样调用publisher.publishEvent(event)事件对象被包装成ApplicationEvent如果传的是普通对象Spring会包一层PayloadApplicationEvent。事件进入SimpleApplicationEventMulticaster它会遍历容器里所有注册的ApplicationListener。对每个监听器用ResolvableType判断它是否能处理当前事件类型。这一步不是简单比较Class而是会做泛型匹配所以ApplicationListenerOrderCreatedEvent只能收到OrderCreatedEvent及它的子类事件。默认情况下匹配到的监听器会在当前线程里同步执行而且执行顺序不保证。如果要按顺序执行可以实现Ordered接口或者加Order注解。默认多播器的行为是同步的这里要注意事件发布之后发布线程会一直等到所有监听器执行完毕才返回。如果监听器里做了耗时操作主流程一样会被拖慢。后面讲异步方案时会专门说这个。2.3 4.2版本之后的变化不一定要继承ApplicationEvent这里值得单独提一下。Spring 4.2之前自定义事件必须继承ApplicationEvent。4.2之后Spring允许发布任意对象作为事件内部自动包装成PayloadApplicationEvent。换句话说你现在定义一个普通的POJO加上EventListener注解就能跑起来事件类不再有强制约束。不过我个人实际开发中还是倾向于继承ApplicationEvent至少显式定义事件类型会让代码语义更清楚尤其在事件种类变多之后一看类名就能定位是哪个业务域的事件。3. 实战手写一个用户注册成功业务事件这一段直接给代码。我以一个最常见的场景为例用户注册成功后需要发送欢迎邮件、发放新人大礼包、记录审计日志。3.1 定义事件对象public class UserRegisteredEvent extends ApplicationEvent { private final Long userId; private final String email; public UserRegisteredEvent(Object source, Long userId, String email) { super(source); this.userId userId; this.email email; } public Long getUserId() { return userId; } public String getEmail() { return email; } }source参数通常传入当前业务对象比如Service实现类它更多用于标识事件来源实际业务代码里用得不多真正携带业务数据的是后面那些字段。如果不想继承ApplicationEvent也可以直接写一个纯POJO类效果一样但可读性会稍弱一些。3.2 编写监听器Spring Boot 2.0之后主流写法是用EventListener注解不需要每个监听器都实现ApplicationListener接口。一个监听器方法处理一个事件Component public class UserRegisteredListener { EventListener public void onUserRegistered(UserRegisteredEvent event) { System.out.println(发送欢迎邮件至 event.getEmail()); } }如果想多个监听器都监听同一个事件就再写一个方法加同样的注解。各监听器互不感知这正是解耦的体现。同一事件有多个监听器需要控制执行顺序时在方法上加Order注解即可EventListener Order(1) public void sendGift(UserRegisteredEvent event) { System.out.println(发放新人大礼包用户ID event.getUserId()); }小Order执行优先级高。不过这里要提醒一句依赖监听器之间的执行顺序本身就是一种隐式耦合尽量别让两个监听器强依赖彼此的先后关系真需要强一致的话不如直接合并成一个监听器。3.3 在业务代码中发布事件注入ApplicationEventPublisher然后在注册成功的业务节点上发布Service public class UserService { private final ApplicationEventPublisher eventPublisher; public UserService(ApplicationEventPublisher eventPublisher) { this.eventPublisher eventPublisher; } public void register(String email) { // 核心注册逻辑 Long userId saveUser(email); // 发布事件后续动作交给监听器 eventPublisher.publishEvent(new UserRegisteredEvent(this, userId, email)); } }这里有个非常关键的细节发布事件的时机决定了监听器能否拿到最新的数据。如果你在事务还没提交时就发布事件监听器去查数据库可能查不到这条刚插入的数据。这个问题放到后面事务事件部分细讲。单从这个示例看publishEvent调用本身是非常轻量的瓶颈只取决于监听器执行了多少耗时操作。4. 异步事件如何避免监听器阻塞主流程4.1 开启异步与线程池配置前面提到默认同步执行的监听器会拖慢主流程。比如注册用户本身只要50毫秒结果监听器里调了个响应很慢的邮件服务整个注册接口变成了2秒这显然是本末倒置。解决方式很简单让监听器跑在独立线程里。第一步在启动类或配置类上加EnableAsyncSpringBootApplication EnableAsync public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }第二步在监听器方法上加AsyncComponent public class UserRegisteredListener { Async EventListener public void onUserRegistered(UserRegisteredEvent event) { System.out.println(异步发送欢迎邮件至 event.getEmail()); } }此时主线程发布完事件立刻返回监听器在异步线程池中执行。但这里有个大坑如果你没有自定义线程池Spring默认会使用SimpleAsyncTaskExecutor这个执行器每次任务都会新建一个线程根本不做线程复用高并发场景下反而可能把系统资源耗光。所以生产环境一定要配置自己的线程池。Configuration public class AsyncConfig { Bean(businessAsyncExecutor) public Executor businessAsyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数通常按CPU核数 * 2左右设置 executor.setCorePoolSize(8); // 最大线程数 executor.setMaxPoolSize(16); // 队列容量 executor.setQueueCapacity(500); // 线程名前缀 executor.setThreadNamePrefix(biz-async-); // 拒绝策略CallerRunsPolicy 让满负荷时退回调用线程执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }然后在Async注解里指定线程池名字Async(businessAsyncExecutor) EventListener public void onUserRegistered(UserRegisteredEvent event) { // ... }4.2 异步事件的两个隐藏问题第一个问题事务失效。Async会让方法在独立线程中执行而Spring事务默认是基于ThreadLocal的新线程里TransactionSynchronizationManager拿不到主线程的事务上下文。所以异步监听器里加Transactional是没有意义的它不会参与主流程的事务也不会开启自己的事务除非手动编程式管理。异步监听器里比较多的是写日志、发通知这类不需要强事务保证的操作。**第二个问题异常不会回到主流程。**同步监听器抛异常会沿着调用链抛给publishEvent的调用方异步执行后异常只会落在异步线程里如果不处理日志里可能只有一行简单的堆栈输出甚至被吞掉。建议给异步任务配置统一的异常处理器最直接的做法是实现AsyncUncaughtExceptionHandler把异常数据记录下来方便排查。5. 和事务打交道TransactionalEventListener的使用场景与边界5.1 普通监听器在事务中的尴尬回到前面留的坑注册用户这个方法如果加了Transactional你在方法内发布事件监听器同步执行时数据库事务还没提交。监听器如果想查一下刚刚保存的用户记录会因为READ_COMMITTED隔离级别下读不到未提交数据而返回空。更严重的场景是主流程事务后续回滚了但监听器已经执行了发邮件、发短信这类不可逆操作用户收到一封注册成功的邮件实际上注册并没有完成。这就是普通事件监听和事务边界之间的天然冲突。想要让监听器感知事务状态就得用Spring专门提供的事务事件机制。5.2 事务提交后再处理phase的取舍TransactionalEventListener允许你指定监听器在事务的哪个阶段执行Component public class UserRegisteredTransactionalListener { TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT) public void afterCommit(UserRegisteredEvent event) { System.out.println(事务提交后给用户发送确认邮件 event.getEmail()); } }TransactionPhase有四个值我列个表说明各自适用场景阶段触发时机典型场景BEFORE_COMMIT事务提交之前在提交前补充一些数据校验、修正AFTER_COMMIT事务提交之后默认值发邮件、发消息、清理缓存、同步数据AFTER_ROLLBACK事务回滚之后记录失败原因、发送告警AFTER_COMPLETION事务无论提交还是回滚结束后都触发统一清理临时资源你可能会问事务事件和普通事件发布在同一个方法里Spring是怎么实现延迟触发的原理是发布事件时Spring并不直接调用监听器而是把所有事务监听器记录下来注册到当前事务的TransactionSynchronization里。当事务真正提交或回滚完成后Spring再回调对应的方法。所以如果发布事件时当前并没有事务TransactionalEventListener默认是不会被触发的。这一点我在早期用错过事件发出去但监听器像消失了一样毫无反应。如果确实需要无事务也能触发可以给注解设置fallbackExecution trueTransactionalEventListener(phase TransactionPhase.AFTER_COMMIT, fallbackExecution true) public void afterCommit(UserRegisteredEvent event) { // 即使没有活动事务也会执行 }我个人的实践建议事务事件适合处理事务成功之后必须做事这类场景。不过要注意事务提交之后异步线程池还没真正执行监听器代码里如果再操作数据库要么自己处理一致性要么把核心逻辑做成幂等的否则在极端情况下比如网络闪断、服务重启数据还是可能对不上。6. 几类高频踩坑异常、循环依赖与事件丢失6.1 监听器抛异常会中断主流程同步监听器如果没捕获异常异常会一路向上抛到publishEvent调用处。如果业务上认为发邮件失败不应该影响注册成功你就必须在监听器内部捕获异常自己处理或者让监听器逻辑整体包在try-catch里。这个雷我踩过一次一个统计埋点的监听器因为上游接口超时抛异常结果用户注册接口直接500。后来我在埋点监听器最外层加了异常兜底核心业务才算稳定。所以设计监听器时要明确一个原则监听器应当是辅助角色它的失败不能决定主流程的成败。除非你确实需要把某件事的结果同步给主流程否则一律把异常消化在监听器内部。6.2 同类方法内自调用导致监听失效TransactionalEventListener和EventListener本质都是通过AOP代理实现的如果监听器方法内部调用同类中的另一个带EventListener的方法也就是自调用代理不生效事件不会被触发。这种情况在拆分方法时容易不小心踩到比如把多个监听逻辑放到了同一个类里另一个方法调用this.xxx()。解决办法是把监听器拆到独立的Bean里通过注入的方式调用绕开自调用问题。6.3 事件丢失的几个场景**场景一异步线程池队列满了。**前面配置里如果把拒绝策略设成AbortPolicy满负荷时新任务直接抛TaskRejectedException这个事件就算丢了。我比较推荐CallerRunsPolicy线程池满时让调用线程自己执行虽然会阻塞主流程但至少事件不会丢适合对一致性要求稍高的场景。**场景二应用重启导致未处理的事件丢失。**如果监听器收到事件后还没执行完进程突然挂了这个事件就没了。Spring事件机制本身不提供持久化能力这也是它的一个边界。真要有无论如何都必须处理完的需求就该上消息队列了比如RabbitMQ或Kafka。事件机制适合解决应用内部的模块解耦跨系统或要求高可靠投递的场景还是交给专业的MQ去管。**场景三多播器没有注入ErrorHandler。**Spring Boot默认装配的SimpleApplicationEventMulticaster如果在事件广播过程中有监听器抛出异常且没有配置ErrorHandler异常会中断整个事件的广播导致排在后面的监听器收不到事件。我之前在项目里看到过一个现象事件A的监听器抛异常之后监听同一事件的另一个监听器根本没执行。如果你希望单个监听器异常不影响其他监听器可以在配置类中新增一个ApplicationEventMulticaster的Bean并设置ErrorHandlerConfiguration public class EventConfig { Bean public ApplicationEventMulticaster applicationEventMulticaster() { SimpleApplicationEventMulticaster multicaster new SimpleApplicationEventMulticaster(); multicaster.setErrorHandler(t - { // 收集异常避免中断广播 log.error(事件监听器执行异常, t); }); return multicaster; } }Spring Boot的自动装配机制下只要容器里存在自定义的ApplicationEventMulticasterBean就会替换掉默认实现。6.4 循环依赖的隐患事件机制还有个比较隐蔽的问题如果A服务发布了事件监听器在B服务中B为了处理事件反过来调用了A的某个方法而A的这个方法恰好又处于创建阶段就可能触发循环依赖。Spring能处理普通的setter循环依赖但事件回调很容易打破它先创建后使用的秩序。遇到这种问题建议把事件监听逻辑拆出来不要在设计上形成A - B - A的回环必要时可以用Lazy延迟注入或者把事件发布延迟到初始化完成之后。7. 从这套机制里沉淀下来的几条经验做了几个项目的事件机制重构之后我自己的习惯基本固定下来了这里直接分享几条可以即拿即用的经验。第一事件类定义要细粒度。不要搞一个大而全的业务事件类里面塞一堆互相无关的字段。一个事件只表达一个业务事实字段尽量只包含必要的数据让监听器按需取用。如果后续需要增加事件载荷优先加字段而不是新建一个语义含混的事件类。第二同步与异步的选择要提前想清楚。默认同步虽然可能拖慢主流程但语义清晰异常能直接感知。异步适合发通知、写日志、埋点这类做了更好、没做也无关紧要的旁路操作。对于事务提交后必须可靠执行的核心操作应当优先考虑事务事件 幂等设计必要时配合消息中间件兜底。第三不要过度使用事件机制。如果一个操作的两个步骤强依赖执行结果比如先扣库存再生成订单就不适合拆成事件监听。事件机制的价值在于解耦非核心路径而不是把所有细小的调用链都打散。用我之前那个下单系统的例子来说扣库存这类核心一致性动作必须留在主链路里而发推送、记积分、更新统计这类辅助动作才适合丢给事件去处理。第四日志要带上事件标识。发布事件时在事件对象里放一个traceId或业务流水号监听器处理时打印同样的标识。否则排查问题时你只知道事件发了但不知道是哪条业务链路上发出来的追日志会非常痛苦。Spring Boot事件监听机制的入门门槛确实很低三个注解EventListener、Async、TransactionalEventListener就能跑通一整条链路但真正用好它需要对Spring的事件广播流程、事务同步机制、线程模型都有清晰认知。我从第一次盲目地在所有业务方法后面publishEvent到现在能根据场景判断该不该用事件、用哪种事件中间就是靠这些坑填出来的。希望这篇内容能帮你少踩几个。