Note 23. Spring Event 一文详解
Note 23. Spring Event 一文详解
Prorise第一章. 先有问题,再有 Spring Event
用户注册,是几乎所有 Web 系统都绕不开的第一个功能。它的主逻辑不复杂——把用户信息写进数据库,返回成功。但随后,产品经理会给这个"简单"的动作附加越来越多的后续行为:注册成功后发一封欢迎邮件,发一条短信验证码,给运营团队同步一条数据,写一条审计日志……
这些后续行为每一个单独来看都不难实现。麻烦在于:你把它们全部"接"在注册流程的末尾,最终会造成什么?
本章就从这个场景出发,先把问题看清楚,再引出 Spring Event 是什么、解决了什么。
1.1. 项目依赖对齐
1.1.1. 版本约定:Java 17+ · Spring Boot 3.5 · MyBatis-Plus 3.5.12 · Simple Java Mail 8.x
整个系列使用下表所列的版本,请在动手之前核对一下本地环境:
| 组件 | 版本 | 说明 |
|---|---|---|
| Java | 17 或 21(LTS) | Spring Boot 3.5 最低要求 Java 17,推荐使用 21 |
| Spring Boot | 3.5.x | 底层对应 Spring Framework 6.2 |
| MyBatis-Plus | 3.5.12 | Spring Boot 3.x 须使用 mybatis-plus-spring-boot3-starter |
| Lombok | 随 Spring Boot BOM 管理 | 无需手动指定版本 |
| Simple Java Mail | 8.12.x | 轻量邮件发送库,与 Jakarta Mail 兼容 |
| Maven | 3.9+ | 构建工具 |
Spring Boot 3.x 对应 Jakarta EE 9+,MyBatis-Plus 必须使用 mybatis-plus-spring-boot3-starter,而非旧版 mybatis-plus-boot-starter,否则启动时会因包名冲突报错。
1.1.2. 依赖清单与包名约定
创建好 Spring Boot 项目后,将 pom.xml 的依赖部分对齐为如下内容。本系列遵循"即用即引入"原则,此处只放本章需要的最小集,后续章节用到新能力时,会在对应位置再追加依赖。
包名约定:所有示例代码统一使用 com.example.mailDemo 作为根包名。创建项目时请保持一致,否则 Spring 的组件扫描路径与后文代码不符,可能出现 Bean 找不到的情况。
1 | <!-- pom.xml 依赖部分(仅列出本系列相关依赖) --> |
application.yml 此时只需要让 H2 与 MyBatis-Plus 能正常启动:
1 | # src/main/resources/application.yml |
MyBatis-Plus 不像 JPA 那样能自动建表,需要手动提供建表 SQL。在 resources 目录下新建 schema.sql:
1 | -- src/main/resources/schema.sql |
配置项说明:sql.init.mode: always 告诉 Spring Boot 每次启动都执行 schema.sql。配合 DROP TABLE IF EXISTS 语句,可以保证表结构始终和代码保持一致,不会因为上次启动残留的表结构导致字段不匹配的报错。
运行 MailDemoApplication,控制台出现 Started MailDemoApplication in X.XX seconds,说明环境对齐完成。
1.1.3. 本节小结
本节完成了版本确认与基础依赖的统一,确保后续所有示例代码运行在同一个已知的环境基线上。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 版本对齐 | 创建项目前 | 确认 Java 17+,Spring Boot 3.5.x,MyBatis-Plus 3.5.12 |
| 使用正确的 starter | 添加 MP 依赖时 | 使用 mybatis-plus-spring-boot3-starter 而非旧版 |
| 手动建表 | 首次启动前 | 新建 schema.sql,配置 sql.init.mode: always |
1.2. 没有 Event 时的代码长什么样
现在正式进入核心话题。我们先把"直接调用"这条思路走到底,看清楚它会把代码带向何处,再来讨论如何改进。
1.2.1. 从一个需求开始
假设你接到了这样一张需求卡:
用户填写用户名和邮箱完成注册后,系统向该邮箱发送一封欢迎邮件。
分解成代码动作,只有两步:把用户信息存进数据库,然后调用邮件服务发邮件。
在动手之前,先把项目的文件结构建起来,本章涉及的文件只有以下几个:
1 | src/main/java/com/example/mailDemo |
先把启动类上的 Mapper 扫描路径加上,否则 MyBatis-Plus 找不到 Mapper 接口:
📄 文件:src/main/java/com/example/mailDemo/MailDemoApplication.java(修改)
1 | package com.example.mailDemo; |
用户实体用 Lombok + MyBatis-Plus 注解来写:
📄 文件:src/main/java/com/example/mailDemo/user/User.java(新增)
1 | package com.example.mailDemo.user; |
Mapper 接口只需继承 BaseMapper,单表 CRUD 全部开箱即用,无需手写任何 SQL:
📄 文件:src/main/java/com/example/mailDemo/user/UserMapper.java(新增)
1 | package com.example.mailDemo.user; |
邮件服务先写成"只打印日志"的占位版本,真实的 Simple Java Mail 集成放到后续章节:
📄 文件:src/main/java/com/example/mailDemo/mail/MailService.java(新增)
1 | package com.example.mailDemo.mail; |
现在是最关键的部分——UserService。按照最自然的"直接调用"思路,注册方法会这样写:
📄 文件:src/main/java/com/example/mailDemo/user/UserService.java(新增)
1 | package com.example.mailDemo.user; |
这段代码能跑,逻辑清晰,没有任何问题。把它存下来,先不急着改,接下来看看随着需求演化,它会走向哪里。
1.2.2. 直接调用带来的三个真实痛点
需求第二张卡来了:
注册成功后,除了发欢迎邮件,还要发一条短信通知,同时往审计日志表里写一条记录。
按直接调用的思路,UserService 会变成这样:
1 |
|
这里有三个问题,每一个在项目初期都不显眼,但随着系统生长会越来越刺手。
第一个问题:UserService 的知识边界在扩张
UserService 现在需要知道:公司用哪家短信服务商、审计日志是写数据库还是消息队列、邮件服务的方法叫什么名字。这些细节原本应该各自封装在自己的服务类里,但因为要"直接调用",UserService 不得不把所有下游都握在手里。
用一个生活场景来对应这个感受:这就像餐厅的前台接待员,除了负责迎接客人,还要亲自去厨房传菜、去仓库领食材、去收银台处理账单。职责越堆越多,任何一个环节出了问题他都要负责,而和他无关的人改了工作流程他也得跟着调整。
第二个问题:下游服务的任何改动都会牵连 UserService
三个月后,短信服务商换了,SmsService 的方法签名调整了——你必须打开 UserService 修改它,即使 UserService 和短信业务本质上没有直接关系,它只是"触发了短信"而已。每次下游服务内部调整,都要追溯到这里来改,这就是"改动扩散"。
第三个问题:任意一个下游抛出异常,注册主流程会跟着失败
register() 方法里所有调用都在同一个事务和同一个调用栈内运行。假设 auditService.recordRegistration() 因为某个边缘情况抛出了 RuntimeException,这个异常会向上冒泡,@Transactional 会把事务全部回滚——包括已经写进数据库的用户记录。
于是:用户注册失败了,原因是审计日志服务出了问题。
1.2.3. 本节小结
本节从一个真实的注册需求出发,用可运行的代码演示了"直接调用"模式,并通过需求演化暴露出三个结构性问题。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 识别依赖膨胀 | 构造器参数超过三个时 | 审查是否存在职责蔓延 |
| 识别改动扩散 | 修改下游服务时需改调用方 | 检查调用链深度 |
| 识别异常穿透 | 下游副作用导致主流程回滚 | 考虑将非核心副作用与主流程隔离 |
1.3. 引入 Spring Event 之后发生了什么
上一节把问题拍在了桌上:注册方法不该知道这么多。那么,如果让注册方法只"宣布一个事实",由其他模块自行监听这个事实并处理各自的逻辑,代码会变成什么样?
这就是 Spring Event 的设计出发点。
1.3.1. 同一个注册流程,改造后的代码对比
重构的核心动作只有一个:UserService 不再主动调用任何下游服务,它发布一个"用户注册已完成"的事件对象,宣布这个事实的发生。
这个事件对象就是一个普通的 Java 类,只负责携带"这件事发生时应该记录的信息":
📄 文件:src/main/java/com/example/mailDemo/user/UserRegisteredEvent.java(新增)
1 | package com.example.mailDemo.user; |
UserService 改造后,注入的依赖从"一堆下游服务"变成了 ApplicationEventPublisher:
📄 文件:src/main/java/com/example/mailDemo/user/UserService.java(修改)
1 | package com.example.mailDemo.user; |
邮件发送的逻辑,现在由一个独立的监听器类承接:
📄 文件:src/main/java/com/example/mailDemo/mail/WelcomeMailListener.java(新增)
1 | package com.example.mailDemo.mail; |
改造后的文件结构:
1 | src/main/java/com/example/mailDemo |
现在如果产品经理再来一张"注册后还要发短信"的需求卡,只需要新建一个 WelcomeSmsListener,标注 @EventListener,方法参数写 UserRegisteredEvent——UserService 一行代码都不用改。
两种实现方式的结构差异,对比如下:
| 维度 | 直接调用 | Spring Event |
|---|---|---|
UserService 的依赖数量 | 随需求增长 | 固定为 ApplicationEventPublisher |
新增下游行为时是否修改 UserService | 是 | 否,只需新增监听器 |
| 下游异常是否影响注册主流程 | 是(同调用栈) | 可通过异步配置隔离,第五章详述 |
| 各下游逻辑之间是否互相感知 | 都集中在同一个方法里 | 完全独立,各自成文件 |
1.3.2. 事件机制的三个核心角色
理解了代码对比之后,再来看 Spring Event 内部有哪几个角色在协同工作。
发布者(Publisher)
就是调用了 publishEvent() 的那个 Bean,在我们的例子里是 UserService。发布者只负责"宣布事实",它不知道有哪些监听者、也不关心事件最终被处理了几次。
事件广播器(ApplicationEventMulticaster)
事件广播器Spring 内部组件,负责将发布的事件路由给所有匹配的监听器,开发者通常无需直接操作它 是 Spring 框架内部的中间层,开发者一般感知不到它的存在。它的职责是:收到 `publishEvent()` 的调用后,找出所有"订阅了这个事件类型"的监听方法,并逐一触发。默认情况下,广播器在同一个线程内同步完成整个分发过程——publishEvent() 调用返回时,所有监听器都已执行完毕。这个细节在第五章讨论异步时会很重要。
监听者(Listener)
就是标注了 @EventListener 的方法所在的 Bean,在我们的例子里是 WelcomeMailListener。监听者通过方法参数的类型来声明"我对哪种事件感兴趣",Spring 负责在事件发布时自动匹配并调用。
三者的协作关系如下:
1.3.3. Spring Event 的定位与边界
清楚了 Spring Event 能做什么,同样需要知道它不适合做什么,避免在错误的场景下使用它。
Spring Event 的准确定位:同一个 JVM 进程内,解耦不同模块之间的触发关系。
这句话里有三个关键约束:
“同一个 JVM 进程内”——Spring Event 的事件只在当前应用实例内传播。多个微服务之间,服务 A 发出的事件,服务 B 收不到。跨进程场景需要消息队列(如 RabbitMQ、Kafka)来承接。
“解耦触发关系”——Spring Event 擅长把"主流程完成后触发副作用"这个模式做干净,但它不保证"事件一定被处理"。应用重启时,正在传播中的事件会丢失。需要可靠投递的场景,要借助消息队列或事件发布日志。
“不同模块之间”——如果触发方和被触发方在同一个类里,直接调用就够了,引入事件反而让代码更难读懂。事件机制的价值在于跨模块的解耦,不是用来替代模块内部的控制流。
对于本系列的主业务——“注册后发欢迎邮件”——Spring Event 是恰当的工具:同进程内的操作,不需要可靠投递保证,邮件发送和用户注册属于不同的业务模块。
1.3.4. 本节小结
本节用完整的改造对比演示了 Spring Event 的结构价值,并介绍了事件机制的三个核心角色。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 发布者只宣布事实 | 主流程完成后需要触发副作用时 | 注入 ApplicationEventPublisher,调用 publishEvent() |
| 监听者通过参数类型订阅 | 需要响应某类事件时 | 标注 @EventListener,方法参数写目标事件类型 |
| 确认场景适合用 Spring Event | 选型时 | 检查是否同进程、不需要可靠投递、属于跨模块解耦 |
第二章. 事件对象的建模规范
第一章里,我们用 UserRegisteredEvent 让注册流程完成了第一次解耦。当时那个 record 是"先跑起来再说"的临时写法——它能工作,但还没有经过认真的建模思考。
事件对象是整个事件机制的数据载体,它的设计质量直接决定监听器能不能独立工作、事件能不能复用、代码能不能在六个月后还读得懂。本章就来把事件对象这件事说清楚。
2.1. 用 record 定义不可变事件
2.1.1. 字段选择原则:只携带业务主键与渲染参数
先问一个问题:事件对象应该放什么字段?
最直觉的答案是"放监听器用到的所有数据"。但这个答案有一个隐患——你现在写的是给当前的几个监听器用,三个月后又来了新的监听器,它需要不同的字段,你要回来改事件对象,改了之后又影响已有的监听器。
更好的设计原则只有一条:事件对象只携带"这件事本身包含的信息",而不是"监听器恰好需要的信息"。
具体落地成两类字段:
一类是业务主键。它是这条业务记录的身份证,任何监听器只要拿到它就能查到完整信息。比如 userId,邮件监听器如果需要用户的手机号、注册时间、会员等级,自己去查。
另一类是必要的渲染参数。"必要"指的是:这个字段在事件发生那一刻有意义,且不应该要求监听器再绕一圈查库才能拿到。比如 email 和 username——发邮件时如果还得回头查一次用户表,就是多一次 IO,而这两个值在注册时就在手边,直接打进事件里更合理。
理解了原则,再来看 UserRegisteredEvent 的最终形态:
📄 文件:src/main/java/com/example/mailDemo/user/UserRegisteredEvent.java(修改)
1 | package com.example.mailDemo.user; |
严禁在事件对象里放 User 实体、HttpServletRequest、EntityManager、ThreadLocal 中的任何对象。
把 User 实体整个塞进事件看起来很方便,但有三个问题:第一,JPA/MyBatis-Plus 的实体对象可能持有持久化上下文的引用,事件传到异步线程后,该上下文已经关闭,访问懒加载字段会直接报错;第二,实体对象通常是可变的,两个监听器并发运行时可能互相污染数据;第三,它把事件的"数据契约"和数据库模型强绑定在一起,数据库字段一改,所有监听器都受影响。
HttpServletRequest 更危险:请求对象是线程绑定的,异步监听器运行在另一个线程,拿到的 Request 要么是空的,要么是已经回收的——访问时会直接抛异常,而且很难复现,因为它取决于主线程和异步线程的执行时序。
2.1.2. 三种写法的对比与取舍
Spring Event 支持三种事件对象定义方式,但它们不是平等的选择——有清晰的优先顺序。
1 | // 推荐写法:Java 16+ record,所有字段自动 final,天然不可变 |
字段自动不可变,无需手写构造器、getter,代码量最少,语义最明确。这是本系列全程使用的写法。
1 | // 次选写法:普通 Java 类 + Lombok,手动保证不可变性 |
如果项目的 Java 版本低于 16,用这种写法。final 类防止被继承后篡改字段,所有字段 final 保证不可变。
1 | // 不推荐:Spring 4.2 之前的历史写法,现在没有必要 |
继承 ApplicationEvent 是 Spring 4.2 之前的唯一写法,现在 Spring 已经完整支持 POJO 事件,不再需要这个父类。它带来的问题是:父类要求传入 source 参数,这个参数在大多数场景下没有实际意义,却让构造器变得冗余。
2.1.3. 本节小结
本节确定了事件对象的字段选择原则,并对比了三种定义方式的取舍。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 只放业务主键和渲染参数 | 每次定义新事件时 | 问自己"这个字段是事实本身还是监听器的方便" |
| 使用 record 定义 | Java 17+ 项目 | 直接 public record XxxEvent(...){} |
| 禁止放实体和请求对象 | 代码评审时 | 字段类型中出现 Entity、Request、Session 直接打回 |
2.2. 命名与粒度
在上一节里,我们确认了事件对象的字段应该放什么。但光有内容还不够——如果事件的命名含糊,或者一个事件试图描述两件不同的事,监听器的职责就会随之变得模糊。
2.2.1. 用完成时态命名,一个事件只承载一种业务事实
命名规则只有一条:用动词的完成时态,描述一件已经发生的业务事实。
1 | UserRegisteredEvent ✅ 用户已注册(完成时) |
"用完成时"不只是命名风格,它隐含了一个约束:发布者只有在业务动作真正完成后才能发布这个事件。UserRegisteredEvent 的名字本身就在提醒你:用户入库之后才能发布它,不能在 insert 之前就发出去。
粒度的问题比命名更容易踩坑。来看一个错误示范:
1 | // ❌ 错误:一个事件试图描述两件事 |
这个事件的问题在于:它把"注册完成"这个事实,和"后续要做什么"这两个决策混在了一起。一旦邮件模板名称发生变化,或者短信发送被移除,都要回来修改这个事件——而修改事件对象会波及所有监听器。
正确的拆分:
1 | // ✅ 正确:每个事件只描述一件事 |
邮件用哪个模板、要不要发短信,这些是监听器自己内部的业务逻辑,由监听器自己决定,不应该写进事件里。UserRegisteredEvent 只描述"用户注册完成了"这个事实,其余的细节各自封装。
如果将来真的有一个场景需要区分"普通用户注册"和"企业用户注册"发不同邮件,正确的做法是拆成两个事件——PersonalUserRegisteredEvent 和 EnterpriseUserRegisteredEvent——而不是在同一个事件里加 userType 字段然后让监听器去 if-else。
2.2.2. 本节小结
本节确定了事件的命名约定和粒度控制原则。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 完成时态命名 | 每次定义事件时 | 用 XxxedEvent 或 XxxCompletedEvent 格式 |
| 一个事件一种事实 | 字段超过四五个时 | 检查是否混入了决策性字段,考虑拆分 |
| 事件不下命令 | 命名评审时 | 事件名中出现动词原形(Send、Create)时警惕 |
2.3. 本章总结
本章完成了事件对象的建模规范梳理,从字段选择、定义写法、命名方式到粒度控制,形成了一套可以直接落地的设计标准。所有后续章节的事件对象都将严格遵循这些规范,遇到新的事件定义时照此检查即可。
本章回顾
覆盖范围:事件对象的三种定义写法及优先级、字段黑白名单、命名语义约定、粒度拆分判断方式。
关键动作:确认使用 record 作为默认写法,禁止在事件对象中放实体、Request、Session,命名使用完成时态,每个事件只承载一种业务事实。
产出物:可复用的事件对象设计检查清单,适用于代码评审与新事件定义场景。
| 核心要点 | 标准 |
|---|---|
| 定义方式 | Java 17+ 项目统一使用 record |
| 字段白名单 | 业务主键、发布时已在手边的渲染参数 |
| 字段黑名单 | 实体对象、Request、Session、EntityManager、ThreadLocal 对象 |
| 命名格式 | 完成时态:XxxedEvent |
| 粒度原则 | 一个事件只描述一件业务事实,决策性字段不进事件 |
第三章. @EventListener 的使用规范
有了规范的事件对象之后,下一步是把监听器写对。第一章里的 WelcomeMailListener 是最基础的形态——一个方法,一个注解,一个参数类型。但在真实项目里,监听器会遇到更多的情况:怎么监听父类事件?多个监听器谁先执行?能不能按条件跳过?监听方法有返回值会怎样?
本章把这些情况一次性说清楚,以最佳实践代码为主线,把注意事项内嵌在旁注里。
3.1. 方法签名与触发规则
3.1.1. 类型匹配机制与常见"不触发"原因清单
Spring 决定是否调用一个监听方法的依据,只有一条:方法参数的类型是否与发布的事件类型匹配。
"匹配"不是简单的等号,它遵循 Java 的类型继承规则:
1 | // 事件继承体系示例 |
用父类或 Object 做参数类型的监听器要谨慎:应用内所有事件都会路由进来,Spring 框架自身也会发布内部事件(如 ContextRefreshedEvent),很容易出现意料之外的触发。
实际开发里,绝大多数监听器用精确的事件类型做参数就足够了。接下来是更重要的内容——监听器配置正确但就是不触发,通常是以下几个原因之一:
原因一:监听器类没有被 Spring 扫描到
@EventListener 需要标注在 Spring Bean 的方法上。如果监听器类忘记加 @Component(或 @Service、@Bean 等),Spring 根本不知道这个类的存在,事件发出去就是一片寂静。
检查方式:在监听器类上加 @Component,确认它在 @SpringBootApplication 所在包的子路径下。
原因二:监听方法不是 public
Spring 默认通过代理来调用 @EventListener 方法。私有方法(private)和包私有方法对代理不可见,标注 @EventListener 也不会生效。
1 | // ❌ 不会触发:方法是 private |
原因三:监听器 Bean 本身通过 @Conditional 未被装配
如果监听器类上有 @ConditionalOnProperty 或其他条件注解,而当前环境不满足条件,这个 Bean 压根不存在,事件自然也不会被处理。
检查方式:在应用启动日志里搜索监听器类的类名,确认它出现在 Bean 注册列表里。
原因四:发布的对象类型与监听的参数类型不匹配
这个错误看起来明显,但实际上很容易犯在泛型场景里。第三章 3.2 节会详细说明,这里先记住一点:发布 new WelcomeMailEvent() 和监听 WelcomeMailEvent 是匹配的,但如果发布的是 new Object() 然后监听 WelcomeMailEvent,不会触发。
原因五:@EventListener 方法在同一个 Bean 里被同类方法调用
这是 Spring AOP 代理的经典陷阱。如果你在一个 Bean 的方法 A 里调用同一个 Bean 的方法 B,而 B 上标注了 @EventListener,这个调用是"内部调用",不经过代理,注解不生效。
@EventListener 标注的方法只能由 Spring 的事件广播机制调用,不要在代码里手动直接调用它。
3.1.2. 本节小结
本节梳理了 @EventListener 的类型匹配机制和五类常见不触发原因。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 精确类型匹配 | 定义监听器时 | 方法参数写具体事件类,不用父类或 Object |
| Bean 注册检查 | 监听器不触发时 | 确认类上有 @Component 且在扫描路径下 |
| 方法可见性 | 写监听方法时 | 必须是 public 方法 |
3.2. 进阶用法与注意事项
上一节把监听器的基础触发机制说清楚了。实际项目里还会遇到三类进阶需求:按条件决定是否处理、一个方法监听多个事件类型、多个监听器控制顺序。本节把这三件事和两个边界情况一次性覆盖。
3.2.1. condition 条件监听与 classes 多事件监听
先看 condition 属性。它允许你用 Spring Expression LanguageSpring 表达式语言,一种轻量级的表达式求值框架,可以在注解中动态计算条件 表达式决定这次事件是否需要处理:
📄 文件:src/main/java/com/example/mailDemo/mail/WelcomeMailListener.java(修改)
1 | package com.example.mailDemo.mail; |
condition 里的表达式要尽量轻量,只做空值保护或简单字段判断这类事情。
不要把复杂业务逻辑写进 SpEL——比如查库、调用 Service、计算折扣。SpEL 表达式不受 Spring 的事务管理,没有正常的异常处理链,出了问题极难调试。复杂条件应该写在监听方法体的 if 语句里。
再来看 classes 属性。它解决的是"多种事件触发同一个处理动作"的场景:
📄 文件:src/main/java/com/example/mailDemo/mail/AuditMailListener.java(新增)
1 | package com.example.mailDemo.mail; |
classes 属性和 condition 属性可以同时使用,但当参数类型是 Object 时,condition 里的字段访问需要先做类型转换,可读性会变差。如果逻辑差异较大,拆成两个监听方法更清晰。
3.2.2. @Order 排序、返回值再发布与泛型事件的限制
多个监听器的执行顺序
同一种事件有多个监听器时,默认执行顺序是不确定的——Spring 不保证任何固定顺序。如果业务逻辑要求"先写审计日志、再发邮件",用 @Order 显式声明:
1 | // Order 值越小,优先级越高,越先执行 |
谨慎依赖 @Order。如果两个监听器的业务逻辑存在顺序依赖,通常意味着它们之间有隐式的数据耦合——后执行的监听器依赖前一个监听器的副作用结果。这种情况下应该考虑用事件链(下面会讲)或直接方法调用,而不是靠排序来维系。@Order 更适合"独立逻辑的优先级调度",比如日志记录优先于业务处理。
@Order 在同步场景下生效。一旦监听器标注了 @Async 变成异步执行,顺序保证消失——异步线程的调度由操作系统决定,@Order 的值此时没有意义。
监听方法返回值触发事件链
监听方法如果有返回值,且返回值不为 null,Spring 会把这个返回值再次当作新事件发布出去:
1 |
|
这个机制可以构建事件链:UserRegisteredEvent → 发邮件 → MailSentEvent → 写发送记录。
但要注意两个限制:第一,事件链超过两跳之后,从代码里很难追踪事件的完整流向,出了问题排查起来很痛苦;第二,异步监听方法(标注了 @Async)的返回值不会触发再发布,Spring 无法在异步线程里回收返回值。
实际开发里,能用"发布者主动发两个事件"解决的场景,不要用事件链。事件链适合"第二个事件的发布时机依赖第一个监听器的处理结果"这类场景。
泛型事件的类型擦除限制
假设你定义了一个泛型事件:
1 | // ❌ 有隐患的写法:泛型事件 |
Java 泛型在运行时会被擦除,DomainEvent<UserRegisteredData> 和 DomainEvent<OrderPlacedData> 在字节码层面是同一个类型。Spring 的事件路由依赖运行时类型信息,泛型参数在路由阶段已经不存在了。
解决方式很简单,也是本系列全程遵循的原则:按业务语义定义具体的事件类,不用泛型做通用事件包装。
1 | // ✅ 正确:具体类型,无歧义 |
每个事件类型对应一种业务事实,类型名字本身就是文档,路由也不会出问题。
3.2.3. 本节小结
本节覆盖了 @EventListener 的三类进阶用法和两个边界限制。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| condition 空值保护 | 事件字段可能为空时 | 用 #event.field() != null 做前置检查 |
| @Order 显式排序 | 多监听器有顺序依赖时 | 值小的先执行,异步场景下排序失效 |
| 返回值再发布 | 后续事件依赖当前处理结果时 | 控制链深度不超过两跳,异步时无效 |
| 泛型事件 | 绝大多数场景 | 不使用,用具体业务类型代替 |
3.3. 本章总结
本章完整覆盖了 @EventListener 的使用规范,从基础的类型匹配机制,到不触发的五类原因排查,再到条件监听、多事件监听、排序控制、事件链与泛型限制。
本章回顾
覆盖范围:事件类型匹配的继承规则;监听器不触发的五类诊断路径(Bean 未注册、方法非 public、条件装配、类型不匹配、内部调用);condition 与 classes 属性的用法边界;@Order 的有效范围与失效场景;监听方法返回值触发事件链的规则与限制;泛型事件的类型擦除问题与推荐写法。
关键动作:监听方法必须是 public,监听器类必须是 Spring Bean;condition 只做轻量判断,复杂逻辑写在方法体里;@Order 在异步下无效;泛型事件不使用,用具体类型代替。
产出物:@EventListener 使用规范检查清单,适用于代码评审与监听器不触发时的排查流程。
| 核心要点 | 标准 |
|---|---|
| 类型匹配 | 使用精确事件类型作为参数,避免父类和 Object |
| 方法要求 | public 方法,所在类是 Spring Bean |
| 不触发排查顺序 | Bean 注册 → 方法可见性 → 条件装配 → 类型匹配 → 内部调用 |
| condition | 只做空值保护和简单字段判断 |
| @Order | 同步有效,异步无效 |
| 事件链深度 | 不超过两跳,异步下返回值不触发再发布 |
| 泛型事件 | 不使用 |
第四章. 事务安全:@TransactionalEventListener
第三章把监听器的使用规范说清楚了,但我们的示例里还有一个隐患没有处理。WelcomeMailListener 现在用的是普通 @EventListener——这意味着它会在 UserService.register() 的事务提交之前就执行邮件发送。来看这个隐患会怎样变成真实问题。
4.1. 为什么事务内直接发邮件是个隐患
4.1.1. 事务回滚后邮件已发出的场景还原
先把 UserService.register() 的执行顺序画清楚:
邮件在事务提交之前就发出去了。一旦后续因为任何原因触发回滚,结果是:数据库里没有这个用户,但欢迎邮件已经进了对方的收件箱。
这个时序问题不是假想场景。在真实项目里,可能是 register() 方法后面还有其他写库操作,那个操作违反了唯一约束抛出异常;可能是 AOP 切面在事务提交前做了检查并主动触发回滚;也可能是数据库连接在提交阶段短暂断开。
修复这个问题,需要把监听器的触发时机推迟到 事务成功提交之后。这正是 @TransactionalEventListener 存在的原因。
4.1.2. 本节小结
本节通过执行时序分析,揭示了普通 @EventListener 在事务场景下的隐患。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 识别时序风险 | 监听器有副作用(邮件、短信、外部调用)时 | 问自己"如果事务回滚,这个副作用能撤回吗" |
| 替换注解 | 副作用不可撤回时 | 把 @EventListener 换成 @TransactionalEventListener |
4.2. AFTER_COMMIT 的标准写法
上一节把问题说清楚了:邮件发送这类不可撤回的副作用,必须等事务提交成功后再执行。现在来看怎么改。
4.2.1. AFTER_COMMIT、fallbackExecution 与监听器内部写库
把 WelcomeMailListener 改造成事务感知版本,只需要把注解换掉:
📄 文件:src/main/java/com/example/mailDemo/mail/WelcomeMailListener.java(修改)
1 | package com.example.mailDemo.mail; |
注解替换完成后,执行时序变成:
1 | 开启事务 |
关于其他事务阶段
@TransactionalEventListener 支持四个 phase,但大多数场景只需要一个:
| phase | 触发时机 | 适合做什么 |
|---|---|---|
AFTER_COMMIT | 事务成功提交后 | 发邮件、短信、调用外部系统——不可撤回的副作用 |
BEFORE_COMMIT | 事务即将提交前 | 在同一事务内做最后的数据校验(失败可触发回滚) |
AFTER_ROLLBACK | 事务回滚后 | 记录回滚日志、清理临时资源 |
AFTER_COMPLETION | 事务结束后(无论提交还是回滚) | 释放资源,无论结果如何都要执行的清理 |
本系列主业务——发欢迎邮件——使用 AFTER_COMMIT,这也是你在项目里最常用到的阶段。
fallbackExecution 的行为与取舍
默认情况下,如果发布事件时外层没有活跃的事务@TransactionalEventListener 标注的方法不会执行——事件会被静默丢弃。
1 | // 假设某个工具方法没有 @Transactional: |
这是最难排查的静默 bug 之一——发布者认为事件发出去了,监听器什么反应都没有,控制台没有任何报错。排查时第一步就是确认发布点是否在 @Transactional 方法内。
fallbackExecution = true 可以改变这个行为,让无事务时也触发监听器。但对于邮件发送场景,不建议开启它,原因很具体:如果 register() 方法上 @Transactional 漏写了,数据可能根本没落库,但 fallbackExecution = true 会让邮件照样发出去,此时邮件发出、数据无记录,问题更难排查。让监听器在无事务时静默不触发,反而是一个更容易发现"漏写事务"这个 bug 的保护机制。
监听器内部需要写库时的事务传播选择
AFTER_COMMIT 阶段执行时,发布方的事务已经结束了,此时监听器内部是没有活跃事务的。如果监听器需要写库(比如记录邮件发送日志),必须自己开启一个新事务:
📄 文件:src/main/java/com/example/mailDemo/mail/MailAuditListener.java(新增)
1 | package com.example.mailDemo.mail; |
如果在 AFTER_COMMIT 阶段的监听器上只写 @Transactional 而不指定 REQUIRES_NEW,Spring 会尝试加入当前事务——但当前事务已经提交,加入会失败,写库操作在无事务保护下裸跑。
4.2.2. 常见问题定位
监听器设置了 AFTER_COMMIT 但依然不触发
排查顺序:第一,确认发布点在 @Transactional 方法内,无事务时默认静默丢弃;第二,确认 @TransactionalEventListener 所在的类是 Spring Bean;第三,确认 Spring 的事务管理器是否正常装配(H2 内存库 + MyBatis-Plus 默认会自动装配,但如果项目里有多个 DataSource 就需要手动指定)。
AFTER_COMMIT 阶段监听器内部写库没有效果
原因大概率是没有加 REQUIRES_NEW,写操作在无事务状态下执行,虽然不报错,但没有事务保护,一旦出现异常不会回滚。加上 @Transactional(propagation = Propagation.REQUIRES_NEW) 即可。
4.2.3. 本节小结
本节给出了 @TransactionalEventListener 的完整最佳实践,覆盖了阶段选择、fallbackExecution 取舍和监听器内部写库的事务配置。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
AFTER_COMMIT | 副作用不可撤回(邮件、短信、外部调用) | 替换 @EventListener 为 @TransactionalEventListener |
不开启 fallbackExecution | 邮件等强依赖事务成功的场景 | 保持默认值 false,让无事务时静默失败倒逼修复漏写事务的 bug |
REQUIRES_NEW | 监听器内部需要写库时 | 同时标注 @Transactional(propagation = REQUIRES_NEW) |
4.3. 本章总结
本章解决了事件机制与数据库事务之间的时序问题,是生产环境中最重要的一道关卡。
本章回顾
覆盖范围:普通 @EventListener 在事务场景下的时序隐患;@TransactionalEventListener 的四个阶段及各自适用场景;fallbackExecution 的行为差异与邮件场景的取舍建议;AFTER_COMMIT 阶段内部写库必须使用 REQUIRES_NEW 的原因;监听器不触发的排查路径。
关键动作:将邮件、短信等不可撤回副作用的监听器注解替换为 @TransactionalEventListener,phase 使用 AFTER_COMMIT;监听器内部写库时叠加 @Transactional(propagation = REQUIRES_NEW);fallbackExecution 在邮件场景保持默认 false。
产出物:事务安全的监听器模板,适用于所有需要在主业务事务成功后才执行副作用的场景。
| 核心要点 | 标准 |
|---|---|
| 邮件/短信监听器注解 | @TransactionalEventListener(phase = AFTER_COMMIT) |
fallbackExecution | 邮件场景保持 false(默认) |
| 监听器内写库 | 叠加 @Transactional(propagation = REQUIRES_NEW) |
| 不触发第一排查项 | 确认发布点在 @Transactional 方法内 |
第五章. 异步执行:@Async 与线程池
到目前为止,注册流程已经是事务安全的了——事务提交后才发邮件。但我们还有一个性能问题没有解决:邮件发送是在 AFTER_COMMIT 回调里同步执行的,它还是会占用处理注册请求的那个线程。
5.1. 邮件发送阻塞注册接口的问题
5.1.1. 同步 vs 异步的时序差异
先把当前的执行时序画出来:
1 | HTTP 请求进入(Tomcat 工作线程 T1) |
对用户来说,一次"点击注册"要等将近一秒才能得到响应,而其中绝大部分时间花在了发邮件上。更糟糕的是:如果 SMTP 服务器响应慢,或者网络抖动,这个等待时间会更长;在高并发时,大量 Tomcat 工作线程都阻塞在邮件发送上,可用线程快速耗尽,整个服务的响应能力骤降。
异步化之后,时序变成:
1 | HTTP 请求进入(Tomcat 工作线程 T1) |
注册接口的响应时间从 ~800ms 降到 ~5ms,T1 线程立刻释放,服务的并发处理能力完全不受邮件发送速度的影响。
5.1.2. 本节小结
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 识别阻塞点 | 监听器包含 IO 操作时 | 评估 IO 耗时是否影响主线程响应速度 |
| 引入异步 | 副作用耗时超过 50ms 时 | 用专属线程池隔离邮件等慢操作 |
5.2. 开启异步与专属线程池配置
5.2.1. @EnableAsync + @Async + TaskExecutor 完整配置
开启异步需要三步:启用能力、配置线程池、标注监听方法。
步骤 1:在启动类或配置类上开启异步支持
📄 文件:src/main/java/com/example/mailDemo/MailDemoApplication.java(修改)
1 | package com.example.mailDemo; |
@EnableAsync 缺失是最常见的"@Async 不生效"原因——标注了 @Async,方法依然同步跑,控制台也没有任何报错,因为 Spring 只是没有代理它而已。
步骤 2:配置邮件发送专属线程池
不建议让邮件发送使用 Spring Boot 默认的公共异步线程池(SimpleAsyncTaskExecutor)——它每次都创建新线程,没有线程复用,在高并发下会耗尽系统资源。我们给邮件发送配置一个有界的专属线程池:
📄 文件:src/main/java/com/example/mailDemo/config/AsyncConfig.java(新增)
1 | package com.example.mailDemo.config; |
步骤 3:在监听方法上标注 @Async
📄 文件:src/main/java/com/example/mailDemo/mail/WelcomeMailListener.java(修改)
1 | package com.example.mailDemo.mail; |
启动应用,触发一次注册,观察日志里的线程名称。如果看到类似 mail-async-1 的前缀,说明邮件发送已经在专属线程池中执行了。
异步化后的三个行为变化
标注 @Async 之后,监听方法的行为有三处和之前不同,需要提前了解:
第一,@Order 排序在异步下失效。异步任务提交到线程池后,哪个线程先拿到任务由操作系统调度决定,@Order 的值不再影响实际执行顺序。如果存在顺序依赖的监听器,其中只要有一个是异步的,顺序保证就消失了。
第二,监听方法的返回值不再触发事件再发布。异步线程执行完毕后,返回值会被 Spring 的 Future 机制丢弃,不会被广播器收集用于二次发布。需要事件链的场景,不能依赖异步方法的返回值。
第三,主线程的 ThreadLocal 变量在异步线程里不可见。如果业务里有通过 ThreadLocal 传递的上下文(如登录用户信息、TraceId),需要在任务提交前显式传递,或者使用 TransmittableThreadLocal(可传递的 ThreadLocal阿里巴巴开源的 ThreadLocal 增强,支持在线程池的异步场景下自动传递父线程的 ThreadLocal 值)。
5.2.2. 本节小结
本节完成了从同步监听到异步监听的完整改造,包含了线程池的每一个配置项的生产建议值。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
@EnableAsync | 项目首次引入异步时 | 加在启动类或配置类上,缺少时 @Async 静默失效 |
| 专属线程池 | 任何 @Async 场景 | 配置 ThreadPoolTaskExecutor,设置核心数、队列、拒绝策略 |
@Async("beanName") | 监听方法上 | 指定 Bean 名称,明确使用哪个线程池 |
5.3. 异常处理与失败兜底
5.3.1. AsyncUncaughtExceptionHandler 与失败事件设计
同步监听器抛出的异常会沿调用栈向上传播,调用方能捕获到。异步监听器就不一样了:任务在独立线程里执行,主线程早已继续运行,异步线程里抛出的异常没有调用方可以接收。
不配置任何处理器时,默认行为是:异常会被 Spring 记录一条 ERROR 级别的日志,然后静默吞掉。邮件没发出去,注册接口已经返回成功,调用方毫不知情。
为异步异常配置一个全局处理器,确保失败时有迹可查、有机制可以补偿:
📄 文件:src/main/java/com/example/mailDemo/config/AsyncConfig.java(修改)
1 | package com.example.mailDemo.config; |
只有日志记录还不够。"邮件发送失败"是一个需要补偿的业务事件——用户没收到欢迎邮件,应该有机制在稍后重试。更完整的设计是定义一个 MailSendFailedEvent,让监听器在捕获异常时发布它:
📄 文件:src/main/java/com/example/mailDemo/mail/MailSendFailedEvent.java(新增)
1 | package com.example.mailDemo.mail; |
在监听器里捕获异常并发布失败事件:
📄 文件:src/main/java/com/example/mailDemo/mail/WelcomeMailListener.java(修改)
1 | package com.example.mailDemo.mail; |
在异步监听器里发布新事件时,新事件在当前异步线程中同步发布和处理。如果新事件的监听器也是异步的,它会再次提交到线程池——这属于正常链路,但要注意线程池容量是否足够。
5.3.2. 本节小结
本节完成了异步监听器的异常处理与失败兜底设计。
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
AsyncUncaughtExceptionHandler | 项目引入 @Async 时 | 实现接口并注册,确保异步异常有日志可查 |
| 监听器内部 try-catch | 有补偿需求时 | 捕获异常后发布失败事件,解耦失败处理逻辑 |
MailSendFailedEvent | 邮件发送失败时 | 携带 userId 和失败原因,供补偿任务使用 |
5.4. 本章总结
本章完成了邮件发送的异步化改造,覆盖了线程池配置、异步化后的行为变化和失败兜底的完整设计。
本章回顾
覆盖范围:同步邮件发送对注册接口吞吐量的影响;@EnableAsync 的启用要求;ThreadPoolTaskExecutor 的核心配置项及生产建议值;@Async 异步化后 @Order 失效、返回值不触发再发布、ThreadLocal 不可见三个行为变化;AsyncUncaughtExceptionHandler 的配置方式;基于失败事件的补偿设计。
关键动作:@EnableAsync 加在启动类上;为邮件发送配置专属 ThreadPoolTaskExecutor;@Async 指定 Bean 名称;实现 AsyncUncaughtExceptionHandler;监听器内部 try-catch 捕获异常并发布 MailSendFailedEvent。
产出物:可直接复用的异步邮件监听器模板、线程池配置模板、失败补偿事件定义。
| 核心要点 | 标准 |
|---|---|
| 线程池类型 | ThreadPoolTaskExecutor,禁止使用 SimpleAsyncTaskExecutor |
| 拒绝策略 | CallerRunsPolicy(降级,不丢任务) |
| 优雅停机 | setWaitForTasksToCompleteOnShutdown(true) + setAwaitTerminationSeconds(30) |
| 异常处理 | 实现 AsyncUncaughtExceptionHandler + 监听器内部 try-catch |
| 失败兜底 | 发布 MailSendFailedEvent,由补偿机制处理 |
第六章. 测试
前五章把事件发布、监听、事务边界、异步执行全部搭建完毕。最后这一章解决一个关键问题:怎么在自动化测试里验证这些事件机制是否按预期工作。
6.1. 断言事件发布结果
6.1.1. @RecordApplicationEvents 用法与断言粒度
Spring Boot 3.x 提供了 @RecordApplicationEvents 注解,它可以在测试期间把发布的所有事件录制下来,供测试方法断言。
先写一个验证"用户注册后事件是否正确发布"的测试:
📄 文件:src/test/java/com/example/mailDemo/user/UserServiceTest.java(新增)
1 | package com.example.mailDemo.user; |
@RecordApplicationEvents 只录制当前测试线程内发布的事件。异步监听器在另一个线程里发布的后续事件(如 MailSendFailedEvent)不会被录制到。这个限制在 6.2 节详细处理。
作用域规则:ApplicationEvents 的生命周期是"测试方法级",每个 @Test 方法开始时清空,结束后丢弃。不同测试方法之间的事件记录互不干扰,无需手动清理。
6.1.2. 本节小结
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
@RecordApplicationEvents | 需要断言事件发布时 | 加在测试类上,配合 @SpringBootTest 使用 |
ApplicationEvents.stream() | 断言事件类型和数量时 | 传入具体事件类,用 AssertJ 链式断言 |
| 字段内容断言 | 验证事件数据正确性时 | findFirst() 取出事件,逐字段断言 |
6.2. 异步事件的测试稳定性
6.2.1. 同步 TaskExecutor 替换策略与分层断言
异步测试有一个经典难题:测试方法执行完断言时,异步线程可能还没有完成任务。断言会偶发性地失败,而且失败是随机的——本地跑通、CI 上失败,或者连跑十次通过一次报错。
解决这个问题有两个层面的思路。
思路一:在测试环境里替换成同步执行器,彻底消除时序不确定性
在测试配置里覆盖生产的 mailTaskExecutor,换成一个同步执行器:
📄 文件:src/test/java/com/example/mailDemo/config/TestAsyncConfig.java(新增)
1 | package com.example.mailDemo.config; |
在需要测试异步行为的测试类里引入这个配置:
📄 文件:src/test/java/com/example/mailDemo/mail/WelcomeMailListenerTest.java(新增)
1 | package com.example.mailDemo.mail; |
直接 publishEvent 测试 @TransactionalEventListener 有一个问题:没有活跃事务时监听器不触发(第四章讲过的 fallbackExecution 默认行为)。测试 @TransactionalEventListener 的推荐方式是通过完整的 Service 方法调用(如 userService.register()),让 @Transactional 产生真实的事务上下文。
思路二:分层断言,把"事件是否发布"和"副作用是否发生"分开测试
这是更推荐的测试设计原则。把测试目标拆成两层:
第一层,测试发布者(UserService):只断言事件是否被正确发布,不关心邮件有没有发出去。用 @RecordApplicationEvents + ApplicationEvents 断言事件的类型、数量和字段内容。这层测试可以完全无视 MailService。
第二层,测试监听器(WelcomeMailListener):只断言监听器收到事件后是否正确调用了 MailService。用 @MockBean Mock 掉 MailService,用 verify() 断言调用次数和参数。这层测试不需要关心事件从哪里来。
1 | // 第一层:只测发布者,不测副作用 |
分层断言的好处是:当测试失败时,你能立刻定位是"事件没发出去"还是"监听器没有正确处理",而不是面对一个大而全的集成测试不知道从哪里开始排查。
失败场景的测试
用 Mockito 模拟 MailService 抛出异常,验证失败时是否正确发布了 MailSendFailedEvent:
1 |
|
6.2.2. 本节小结
| 要点 | 何时使用 | 关键动作 |
|---|---|---|
| 同步执行器替换 | 测试含 @Async 的监听器时 | 用 @TestConfiguration + SyncTaskExecutor 覆盖生产线程池 |
| 分层断言 | 所有事件相关测试 | 发布者测事件,监听器测副作用,两层独立 |
| 失败场景覆盖 | 补偿机制测试时 | doThrow 模拟异常,断言失败事件是否被正确发布 |
6.3. 本章总结
本章完成了 Spring Event 体系的测试闭环,从事件录制、断言粒度,到异步测试稳定性和失败场景覆盖,形成了一套可直接复用的测试模板。
本章回顾
覆盖范围:@RecordApplicationEvents 的启用方式与作用域规则(测试方法级隔离);ApplicationEvents.stream() 的断言粒度(类型、数量、字段内容);异步测试不稳定的根源与 SyncTaskExecutor 替换方案;分层断言的设计原则(发布者层与监听器层分离);失败场景的 Mockito 模拟与 MailSendFailedEvent 断言。
关键动作:@RecordApplicationEvents 加在测试类上;用 @TestConfiguration + @Primary 覆盖生产线程池;测试 @TransactionalEventListener 通过完整 Service 方法触发而非直接 publishEvent;失败场景用 doThrow + 断言失败事件。
产出物:可复用的事件测试模板,覆盖发布断言、监听器断言和异步失败场景。
| 核心要点 | 标准 |
|---|---|
| 事件录制 | @RecordApplicationEvents + @Autowired ApplicationEvents |
| 作用域 | 测试方法级,每个 @Test 独立隔离 |
| 异步测试 | 用 SyncTaskExecutor 覆盖生产线程池,消除时序不确定性 |
| 测试分层 | 发布者只断言事件,监听器只断言副作用 |
| 失败场景 | doThrow 模拟异常,断言 MailSendFailedEvent 发布 |
@TransactionalEventListener 测试 | 通过完整 Service 方法触发,提供真实事务上下文 |








