Note 23. Spring Event 一文详解

第一章. 先有问题,再有 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

整个系列使用下表所列的版本,请在动手之前核对一下本地环境:

组件版本说明
Java17 或 21(LTS)Spring Boot 3.5 最低要求 Java 17,推荐使用 21
Spring Boot3.5.x底层对应 Spring Framework 6.2
MyBatis-Plus3.5.12Spring Boot 3.x 须使用 mybatis-plus-spring-boot3-starter
Lombok随 Spring Boot BOM 管理无需手动指定版本
Simple Java Mail8.12.x轻量邮件发送库,与 Jakarta Mail 兼容
Maven3.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<!-- pom.xml 依赖部分(仅列出本系列相关依赖) -->
<dependencies>

<!-- Web 层:提供 REST 接口能力 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!--
MyBatis-Plus:Spring Boot 3.x 必须使用带 spring-boot3 字样的 starter。
注意:不要再额外引入 mybatis-spring-boot-starter,否则会产生版本冲突。
-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.12</version>
</dependency>

<!-- H2 内存数据库:本地开发无需安装任何数据库即可运行 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

<!--
Lombok:版本由 Spring Boot BOM 统一管理,不需要手写版本号。
annotationProcessorPaths 配置见下方 build 块。
-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- 邮件发送:本系列选用 Simple Java Mail,而非 Spring 自带的 JavaMailSender -->
<dependency>
<groupId>org.simplejavamail</groupId>
<artifactId>simple-java-mail</artifactId>
<version>8.12.6</version>
</dependency>

<!-- 测试支持:包含 JUnit 5 与 Spring Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>

</dependencies>

application.yml 此时只需要让 H2 与 MyBatis-Plus 能正常启动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# src/main/resources/application.yml

spring:
datasource:
url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 # 内存数据库,DB_CLOSE_DELAY=-1 防止连接关闭时数据库提前销毁
driver-class-name: org.h2.Driver
username: sa
password:
sql:
init:
mode: always # 每次启动都执行 schema.sql,确保表结构最新
h2:
console:
enabled: true # 开启 H2 可视化控制台,浏览器访问 /h2-console 查看数据

mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 在控制台打印 SQL,便于后续章节观察执行细节
global-config:
db-config:
id-type: auto # 主键策略:数据库自增

MyBatis-Plus 不像 JPA 那样能自动建表,需要手动提供建表 SQL。在 resources 目录下新建 schema.sql

1
2
3
4
5
6
7
8
9
10
-- src/main/resources/schema.sql

-- 用户表:每次启动时重建,开发阶段方便调试
DROP TABLE IF EXISTS users;

CREATE TABLE users (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '用户 ID,自增主键',
username VARCHAR(64) NOT NULL UNIQUE COMMENT '用户名,全局唯一',
email VARCHAR(128) NOT NULL COMMENT '用户邮箱'
);

配置项说明: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
2
3
4
5
6
7
8
src/main/java/com/example/mailDemo
├── user
│ ├── User.java # 用户实体
│ ├── UserMapper.java # MyBatis-Plus 数据库操作接口
│ └── UserService.java # 注册业务逻辑(本章重点)
├── mail
│ └── MailService.java # 邮件发送服务
└── MailDemoApplication.java # 启动类

先把启动类上的 Mapper 扫描路径加上,否则 MyBatis-Plus 找不到 Mapper 接口:

📄 文件:src/main/java/com/example/mailDemo/MailDemoApplication.java(修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.mailDemo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.example.mailDemo.**.mapper") // 告诉 MyBatis-Plus 去哪里找 Mapper 接口
public class MailDemoApplication {
public static void main(String[] args) {
SpringApplication.run(MailDemoApplication.class, args);
}
}

用户实体用 Lombok + MyBatis-Plus 注解来写:

📄 文件:src/main/java/com/example/mailDemo/user/User.java(新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.mailDemo.user;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data // Lombok:自动生成 getter、setter、toString、equals、hashCode
@Builder // Lombok:提供链式构建器,如 User.builder().username("xx").build()
@NoArgsConstructor // Lombok:生成无参构造器(MyBatis-Plus 反射建实体时需要)
@AllArgsConstructor // Lombok:生成全参构造器(配合 @Builder 使用)
@TableName("users") // MyBatis-Plus:指定对应的数据库表名
public class User {

@TableId(type = IdType.AUTO) // 主键策略:数据库自增,insert 后自动回填 id 字段
private Long id;

private String username;

private String email;
}

Mapper 接口只需继承 BaseMapper,单表 CRUD 全部开箱即用,无需手写任何 SQL:

📄 文件:src/main/java/com/example/mailDemo/user/UserMapper.java(新增)

1
2
3
4
5
6
package com.example.mailDemo.user;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;

// 继承 BaseMapper<User> 后,insert、selectById、updateById、deleteById 等方法自动可用
public interface UserMapper extends BaseMapper<User> {}

邮件服务先写成"只打印日志"的占位版本,真实的 Simple Java Mail 集成放到后续章节:

📄 文件:src/main/java/com/example/mailDemo/mail/MailService.java(新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.example.mailDemo.mail;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

@Slf4j
@Service
public class MailService {

/**
* 发送欢迎邮件。
* 当前为占位实现,第五章接入 Simple Java Mail 后替换此处逻辑。
*/
public void sendWelcomeMail(String toEmail, String username) {
log.info("【邮件发送】收件人:{},用户名:{},欢迎邮件已发出", toEmail, username);
}
}

现在是最关键的部分——UserService。按照最自然的"直接调用"思路,注册方法会这样写:

📄 文件:src/main/java/com/example/mailDemo/user/UserService.java(新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.mailDemo.user;

import com.example.mailDemo.mail.MailService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor // Lombok:为所有 final 字段自动生成构造器,替代手写的构造器注入
public class UserService {

private final UserMapper userMapper;
private final MailService mailService; // 注册服务直接持有邮件服务的引用

@Transactional
public User register(String username, String email) {
log.info("【注册流程】开始,用户名:{}", username);

// 用 Builder 构建实体,避免出现参数顺序错误的低级失误
User newUser = User.builder()
.username(username)
.email(email)
.build();

userMapper.insert(newUser); // insert 执行后,newUser.getId() 已被自动回填
log.info("【注册流程】用户已入库,ID:{}", newUser.getId());

// 数据库写完后,立刻调用邮件服务
mailService.sendWelcomeMail(email, username);

log.info("【注册流程】完成");
return newUser;
}
}

这段代码能跑,逻辑清晰,没有任何问题。把它存下来,先不急着改,接下来看看随着需求演化,它会走向哪里。

1.2.2. 直接调用带来的三个真实痛点

需求第二张卡来了:

注册成功后,除了发欢迎邮件,还要发一条短信通知,同时往审计日志表里写一条记录。

按直接调用的思路,UserService 会变成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

private final UserMapper userMapper;
private final MailService mailService;
private final SmsService smsService; // 新增
private final AuditService auditService; // 新增

@Transactional
public User register(String username, String email) {
User newUser = User.builder().username(username).email(email).build();
userMapper.insert(newUser);

mailService.sendWelcomeMail(email, username);
smsService.sendWelcomeSms(phone, username); // 新增
auditService.recordRegistration(newUser.getId()); // 新增

return newUser;
}
}

这里有三个问题,每一个在项目初期都不显眼,但随着系统生长会越来越刺手。

第一个问题: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
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.mailDemo.user;

/**
* 事件语义:用户注册已完成(完成时态,描述业务事实)。
*
* 使用 record 定义,字段天然 final 且不可变——
* 监听器在处理过程中无法修改事件内容,避免多个监听器之间互相干扰数据。
*
* @param userId 已注册的用户 ID,供需要查询详情的监听器使用
* @param email 收件人邮箱,邮件监听器直接取用,无需再查库
* @param username 用户名,用于邮件正文的个性化渲染
*/
public record UserRegisteredEvent(Long userId, String email, String username) {}
Java 记录类Java 16 正式引入,自动生成构造器、getter、equals、hashCode,所有字段默认 final,非常适合作为不可变的数据载体

UserService 改造后,注入的依赖从"一堆下游服务"变成了 ApplicationEventPublisher

📄 文件:src/main/java/com/example/mailDemo/user/UserService.java(修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example.mailDemo.user;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

private final UserMapper userMapper;
private final ApplicationEventPublisher eventPublisher; // 只注入这一个"广播器"

@Transactional
public User register(String username, String email) {
log.info("【注册流程】开始,用户名:{}", username);

User newUser = User.builder()
.username(username)
.email(email)
.build();

userMapper.insert(newUser);
log.info("【注册流程】用户已入库,ID:{}", newUser.getId());

// 宣布事实:用户注册成功了。至于谁来响应、怎么响应,UserService 不关心
eventPublisher.publishEvent(
new UserRegisteredEvent(newUser.getId(), email, username)
);

log.info("【注册流程】完成");
return newUser;
}
}

邮件发送的逻辑,现在由一个独立的监听器类承接:

📄 文件:src/main/java/com/example/mailDemo/mail/WelcomeMailListener.java(新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package com.example.mailDemo.mail;

import com.example.mailDemo.user.UserRegisteredEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class WelcomeMailListener {

private final MailService mailService;

/**
* 监听用户注册完成事件,触发欢迎邮件发送。
*
* 方法参数的类型 UserRegisteredEvent 就是"订阅声明":
* Spring 看到这个方法签名后,会在每次 UserRegisteredEvent 被发布时自动调用它。
*/
@EventListener
public void onUserRegistered(UserRegisteredEvent event) {
log.info("【邮件监听器】收到注册事件,目标邮箱:{}", event.email());
mailService.sendWelcomeMail(event.email(), event.username());
}
}

改造后的文件结构:

1
2
3
4
5
6
7
8
9
10
src/main/java/com/example/mailDemo
├── user
│ ├── User.java # 不变
│ ├── UserMapper.java # 不变
│ ├── UserRegisteredEvent.java # [新增] 事件对象,承载"注册成功"这个事实
│ └── UserService.java # [修改] 只发布事件,不再直接调用任何下游
├── mail
│ ├── MailService.java # 不变
│ └── WelcomeMailListener.java # [新增] 监听器,独立承接邮件发送逻辑
└── MailDemoApplication.java # 不变

现在如果产品经理再来一张"注册后还要发短信"的需求卡,只需要新建一个 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 负责在事件发布时自动匹配并调用。

三者的协作关系如下:

1773735421112

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,邮件监听器如果需要用户的手机号、注册时间、会员等级,自己去查。

另一类是必要的渲染参数。"必要"指的是:这个字段在事件发生那一刻有意义,且不应该要求监听器再绕一圈查库才能拿到。比如 emailusername——发邮件时如果还得回头查一次用户表,就是多一次 IO,而这两个值在注册时就在手边,直接打进事件里更合理。

理解了原则,再来看 UserRegisteredEvent 的最终形态:

📄 文件:src/main/java/com/example/mailDemo/user/UserRegisteredEvent.java(修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.mailDemo.user;

/**
* 用户注册已完成事件。
*
* 字段选择依据:
* userId — 业务主键,监听器需要扩展数据时可凭此查询
* email — 渲染参数,邮件/短信发送直接取用,避免监听器再查库
* username — 渲染参数,欢迎邮件个性化称呼需要
*
* 没有放的字段:
* User 实体对象 — 见下方 ⚠️ 说明
* HttpServletRequest — 见下方 ⚠️ 说明
*/
public record UserRegisteredEvent(Long userId, String email, String username) {}

严禁在事件对象里放 User 实体、HttpServletRequest、EntityManager、ThreadLocal 中的任何对象。

User 实体整个塞进事件看起来很方便,但有三个问题:第一,JPA/MyBatis-Plus 的实体对象可能持有持久化上下文的引用,事件传到异步线程后,该上下文已经关闭,访问懒加载字段会直接报错;第二,实体对象通常是可变的,两个监听器并发运行时可能互相污染数据;第三,它把事件的"数据契约"和数据库模型强绑定在一起,数据库字段一改,所有监听器都受影响。

HttpServletRequest 更危险:请求对象是线程绑定的,异步监听器运行在另一个线程,拿到的 Request 要么是空的,要么是已经回收的——访问时会直接抛异常,而且很难复现,因为它取决于主线程和异步线程的执行时序。

2.1.2. 三种写法的对比与取舍

Spring Event 支持三种事件对象定义方式,但它们不是平等的选择——有清晰的优先顺序。

1
2
// 推荐写法:Java 16+ record,所有字段自动 final,天然不可变
public record UserRegisteredEvent(Long userId, String email, String username) {}

字段自动不可变,无需手写构造器、getter,代码量最少,语义最明确。这是本系列全程使用的写法。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 次选写法:普通 Java 类 + Lombok,手动保证不可变性
@Getter
public final class UserRegisteredEvent {
private final Long userId;
private final String email;
private final String username;

public UserRegisteredEvent(Long userId, String email, String username) {
this.userId = userId;
this.email = email;
this.username = username;
}
}

如果项目的 Java 版本低于 16,用这种写法。final 类防止被继承后篡改字段,所有字段 final 保证不可变。

1
2
3
4
5
6
7
8
9
10
11
// 不推荐:Spring 4.2 之前的历史写法,现在没有必要
public class UserRegisteredEvent extends ApplicationEvent {
private final String email;
private final String username;

public UserRegisteredEvent(Object source, String email, String username) {
super(source); // source 通常传 this(发布者本身),但监听器基本不会用到它
this.email = email;
this.username = username;
}
}

继承 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
2
3
4
5
6
7
UserRegisteredEvent    ✅ 用户已注册(完成时)
OrderPlacedEvent ✅ 订单已下单(完成时)
PasswordResetEvent ✅ 密码已重置(完成时)

UserRegisterEvent ⚠️ 含糊,是"注册中"还是"注册完"?
SendWelcomeMailEvent ❌ 这是一个命令,不是事实——事件不应该告诉监听器"去做什么"
UserEvent ❌ 太宽泛,描述不了任何具体业务事实

"用完成时"不只是命名风格,它隐含了一个约束:发布者只有在业务动作真正完成后才能发布这个事件。UserRegisteredEvent 的名字本身就在提醒你:用户入库之后才能发布它,不能在 insert 之前就发出去。

粒度的问题比命名更容易踩坑。来看一个错误示范:

1
2
3
4
5
6
7
8
// ❌ 错误:一个事件试图描述两件事
public record UserRegisteredAndMailRequestedEvent(
Long userId,
String email,
String username,
String mailTemplateName, // 这是"发什么邮件"的决策,属于邮件发送事件的职责
boolean needSms // 这是"要不要发短信"的决策,属于短信发送事件的职责
) {}

这个事件的问题在于:它把"注册完成"这个事实,和"后续要做什么"这两个决策混在了一起。一旦邮件模板名称发生变化,或者短信发送被移除,都要回来修改这个事件——而修改事件对象会波及所有监听器。

正确的拆分:

1
2
// ✅ 正确:每个事件只描述一件事
public record UserRegisteredEvent(Long userId, String email, String username) {}

邮件用哪个模板、要不要发短信,这些是监听器自己内部的业务逻辑,由监听器自己决定,不应该写进事件里。UserRegisteredEvent 只描述"用户注册完成了"这个事实,其余的细节各自封装。

如果将来真的有一个场景需要区分"普通用户注册"和"企业用户注册"发不同邮件,正确的做法是拆成两个事件——PersonalUserRegisteredEventEnterpriseUserRegisteredEvent——而不是在同一个事件里加 userType 字段然后让监听器去 if-else。

2.2.2. 本节小结

本节确定了事件的命名约定和粒度控制原则。

要点何时使用关键动作
完成时态命名每次定义事件时XxxedEventXxxCompletedEvent 格式
一个事件一种事实字段超过四五个时检查是否混入了决策性字段,考虑拆分
事件不下命令命名评审时事件名中出现动词原形(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
2
3
4
5
6
7
8
9
10
11
12
// 事件继承体系示例
public class BaseMailEvent {}
public class WelcomeMailEvent(String email) extends BaseMailEvent {}
// 以下三个监听器,当发布 WelcomeMailEvent 时,全部都会触发:
@EventListener
public void onWelcomeMail(WelcomeMailEvent event) {} // 精确匹配,优先推荐

@EventListener
public void onBaseMail(BaseMailEvent event) {} // 父类匹配,同样触发

@EventListener
public void onAnyObject(Object event) {} // Object 是所有类的祖先,所有事件都触发

用父类或 Object 做参数类型的监听器要谨慎:应用内所有事件都会路由进来,Spring 框架自身也会发布内部事件(如 ContextRefreshedEvent),很容易出现意料之外的触发。

实际开发里,绝大多数监听器用精确的事件类型做参数就足够了。接下来是更重要的内容——监听器配置正确但就是不触发,通常是以下几个原因之一:

原因一:监听器类没有被 Spring 扫描到

@EventListener 需要标注在 Spring Bean 的方法上。如果监听器类忘记加 @Component(或 @Service@Bean 等),Spring 根本不知道这个类的存在,事件发出去就是一片寂静。

检查方式:在监听器类上加 @Component,确认它在 @SpringBootApplication 所在包的子路径下。

原因二:监听方法不是 public

Spring 默认通过代理来调用 @EventListener 方法。私有方法(private)和包私有方法对代理不可见,标注 @EventListener 也不会生效。

1
2
3
4
5
6
7
// ❌ 不会触发:方法是 private
@EventListener
private void onUserRegistered(UserRegisteredEvent event) { ... }

// ✅ 正确:方法是 public
@EventListener
public void onUserRegistered(UserRegisteredEvent event) { ... }

原因三:监听器 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.mailDemo.mail;

import com.example.mailDemo.user.UserRegisteredEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class WelcomeMailListener {

private final MailService mailService;

/**
* condition 里的 SpEL 表达式可以读取事件对象的字段。
* #event 是固定变量名,指向当前发布的事件对象。
* 这里的含义:只有 email 字段不为空时,才执行邮件发送。
*/
@EventListener(condition = "#event.email() != null && !#event.email().isBlank()")
public void onUserRegistered(UserRegisteredEvent event) {
log.info("【邮件监听器】收到注册事件,目标邮箱:{}", event.email());
mailService.sendWelcomeMail(event.email(), event.username());
}
}

condition 里的表达式要尽量轻量,只做空值保护或简单字段判断这类事情。

不要把复杂业务逻辑写进 SpEL——比如查库、调用 Service、计算折扣。SpEL 表达式不受 Spring 的事务管理,没有正常的异常处理链,出了问题极难调试。复杂条件应该写在监听方法体的 if 语句里。

再来看 classes 属性。它解决的是"多种事件触发同一个处理动作"的场景:

📄 文件:src/main/java/com/example/mailDemo/mail/AuditMailListener.java(新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.mailDemo.mail;

import com.example.mailDemo.user.UserRegisteredEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class AuditMailListener {

/**
* classes 属性:声明这个方法监听哪些事件类型。
* 使用 classes 时,方法参数必须改为 Object(因为无法用单一具体类型同时接收多种事件)。
*
* 适合场景:多个事件触发完全相同的处理逻辑(如审计日志写入)。
* 不适合场景:不同事件需要不同的处理分支——那种情况应该拆成两个独立的监听方法。
*/
@EventListener(classes = {UserRegisteredEvent.class})
public void onMailableEvent(Object event) {
log.info("【审计监听器】事件类型:{},进入审计记录", event.getClass().getSimpleName());
// 审计写入逻辑...
}
}

classes 属性和 condition 属性可以同时使用,但当参数类型是 Object 时,condition 里的字段访问需要先做类型转换,可读性会变差。如果逻辑差异较大,拆成两个监听方法更清晰。

3.2.2. @Order 排序、返回值再发布与泛型事件的限制

多个监听器的执行顺序

同一种事件有多个监听器时,默认执行顺序是不确定的——Spring 不保证任何固定顺序。如果业务逻辑要求"先写审计日志、再发邮件",用 @Order 显式声明:

1
2
3
4
5
6
7
8
9
10
11
12
// Order 值越小,优先级越高,越先执行
@Order(1)
@EventListener
public void onUserRegistered_audit(UserRegisteredEvent event) {
// 先执行:写审计日志
}

@Order(2)
@EventListener
public void onUserRegistered_mail(UserRegisteredEvent event) {
// 后执行:发邮件
}

谨慎依赖 @Order。如果两个监听器的业务逻辑存在顺序依赖,通常意味着它们之间有隐式的数据耦合——后执行的监听器依赖前一个监听器的副作用结果。这种情况下应该考虑用事件链(下面会讲)或直接方法调用,而不是靠排序来维系。@Order 更适合"独立逻辑的优先级调度",比如日志记录优先于业务处理。
@Order 在同步场景下生效。一旦监听器标注了 @Async 变成异步执行,顺序保证消失——异步线程的调度由操作系统决定,@Order 的值此时没有意义。

监听方法返回值触发事件链

监听方法如果有返回值,且返回值不为 null,Spring 会把这个返回值再次当作新事件发布出去:

1
2
3
4
5
6
@EventListener
public MailSentEvent onUserRegistered(UserRegisteredEvent event) {
mailService.sendWelcomeMail(event.email(), event.username());
// 返回非 null 值时,Spring 自动将其发布为新事件
return new MailSentEvent(event.userId(), event.email());
}

这个机制可以构建事件链:UserRegisteredEvent → 发邮件 → MailSentEvent → 写发送记录。

但要注意两个限制:第一,事件链超过两跳之后,从代码里很难追踪事件的完整流向,出了问题排查起来很痛苦;第二,异步监听方法(标注了 @Async)的返回值不会触发再发布,Spring 无法在异步线程里回收返回值。

实际开发里,能用"发布者主动发两个事件"解决的场景,不要用事件链。事件链适合"第二个事件的发布时机依赖第一个监听器的处理结果"这类场景。

泛型事件的类型擦除限制

假设你定义了一个泛型事件:

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 有隐患的写法:泛型事件
public record DomainEvent<T>(T payload) {}

// 发布时:
eventPublisher.publishEvent(new DomainEvent<>(userRegisteredData));
eventPublisher.publishEvent(new DomainEvent<>(orderPlacedData));

// 监听时——问题出现了:
@EventListener
public void onUserDomainEvent(DomainEvent<UserRegisteredData> event) { ... }
// 由于 Java 的类型擦除,运行时 DomainEvent<UserRegisteredData> 和 DomainEvent<OrderPlacedData>
// 都只是 DomainEvent,Spring 无法区分,两个发布都会触发这个方法

Java 泛型在运行时会被擦除,DomainEvent<UserRegisteredData>DomainEvent<OrderPlacedData> 在字节码层面是同一个类型。Spring 的事件路由依赖运行时类型信息,泛型参数在路由阶段已经不存在了。

解决方式很简单,也是本系列全程遵循的原则:按业务语义定义具体的事件类,不用泛型做通用事件包装

1
2
3
// ✅ 正确:具体类型,无歧义
public record UserRegisteredEvent(Long userId, String email, String username) {}
public record OrderPlacedEvent(Long orderId, Long userId, BigDecimal amount) {}

每个事件类型对应一种业务事实,类型名字本身就是文档,路由也不会出问题。

3.2.3. 本节小结

本节覆盖了 @EventListener 的三类进阶用法和两个边界限制。

要点何时使用关键动作
condition 空值保护事件字段可能为空时#event.field() != null 做前置检查
@Order 显式排序多监听器有顺序依赖时值小的先执行,异步场景下排序失效
返回值再发布后续事件依赖当前处理结果时控制链深度不超过两跳,异步时无效
泛型事件绝大多数场景不使用,用具体业务类型代替

3.3. 本章总结

本章完整覆盖了 @EventListener 的使用规范,从基础的类型匹配机制,到不触发的五类原因排查,再到条件监听、多事件监听、排序控制、事件链与泛型限制。

本章回顾

覆盖范围:事件类型匹配的继承规则;监听器不触发的五类诊断路径(Bean 未注册、方法非 public、条件装配、类型不匹配、内部调用);conditionclasses 属性的用法边界;@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() 的执行顺序画清楚:

mermaid-diagram-2026-03-17-213216

邮件在事务提交之前就发出去了。一旦后续因为任何原因触发回滚,结果是:数据库里没有这个用户,但欢迎邮件已经进了对方的收件箱。

这个时序问题不是假想场景。在真实项目里,可能是 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.mailDemo.mail;

import com.example.mailDemo.user.UserRegisteredEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
@RequiredArgsConstructor
public class WelcomeMailListener {

private final MailService mailService;

/**
* 事务提交成功后才执行邮件发送。
*
* phase = AFTER_COMMIT(默认值,可省略不写,此处显式保留增加可读性):
* 等待发布方的事务成功 commit 之后,再调用此方法。
* 如果事务回滚,此方法不会被调用——邮件因此不会误发。
*
* fallbackExecution = false(默认值):
* 如果发布事件时根本没有活跃事务,此方法不会执行。
* 见下方 ⚠️ 说明。
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onUserRegistered(UserRegisteredEvent event) {
log.info("【邮件监听器】事务已提交,开始发送欢迎邮件,目标邮箱:{}", event.email());
mailService.sendWelcomeMail(event.email(), event.username());
}
}

注解替换完成后,执行时序变成:

1
2
3
4
5
6
7
8
9
10
11
开启事务

userMapper.insert(newUser)

eventPublisher.publishEvent(...) ← 事件被暂存,监听器此时不执行

事务成功提交

【广播器在提交后调用监听器】
WelcomeMailListener.onUserRegistered() 执行
mailService.sendWelcomeMail() ← 此时数据已落库,邮件安全发出

关于其他事务阶段

@TransactionalEventListener 支持四个 phase,但大多数场景只需要一个:

phase触发时机适合做什么
AFTER_COMMIT事务成功提交后发邮件、短信、调用外部系统——不可撤回的副作用
BEFORE_COMMIT事务即将提交前在同一事务内做最后的数据校验(失败可触发回滚)
AFTER_ROLLBACK事务回滚后记录回滚日志、清理临时资源
AFTER_COMPLETION事务结束后(无论提交还是回滚)释放资源,无论结果如何都要执行的清理

本系列主业务——发欢迎邮件——使用 AFTER_COMMIT,这也是你在项目里最常用到的阶段。

fallbackExecution 的行为与取舍

默认情况下,如果发布事件时外层没有活跃的事务@TransactionalEventListener 标注的方法不会执行——事件会被静默丢弃。

1
2
3
4
5
6
// 假设某个工具方法没有 @Transactional:
public void nonTransactionalMethod() {
eventPublisher.publishEvent(new UserRegisteredEvent(...));
// 因为没有活跃事务,WelcomeMailListener 不会触发
// 没有报错,没有日志,事件就这样消失了
}

这是最难排查的静默 bug 之一——发布者认为事件发出去了,监听器什么反应都没有,控制台没有任何报错。排查时第一步就是确认发布点是否在 @Transactional 方法内。

fallbackExecution = true 可以改变这个行为,让无事务时也触发监听器。但对于邮件发送场景,不建议开启它,原因很具体:如果 register() 方法上 @Transactional 漏写了,数据可能根本没落库,但 fallbackExecution = true 会让邮件照样发出去,此时邮件发出、数据无记录,问题更难排查。让监听器在无事务时静默不触发,反而是一个更容易发现"漏写事务"这个 bug 的保护机制。

监听器内部需要写库时的事务传播选择

AFTER_COMMIT 阶段执行时,发布方的事务已经结束了,此时监听器内部是没有活跃事务的。如果监听器需要写库(比如记录邮件发送日志),必须自己开启一个新事务:

📄 文件:src/main/java/com/example/mailDemo/mail/MailAuditListener.java(新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.example.mailDemo.mail;

import com.example.mailDemo.user.UserRegisteredEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
@RequiredArgsConstructor
public class MailAuditListener {

private final MailAuditMapper mailAuditMapper;

/**
* 记录邮件发送日志到数据库。
*
* @Transactional(propagation = REQUIRES_NEW):
* AFTER_COMMIT 阶段没有活跃事务,如果直接使用默认的 REQUIRED,
* 会因为没有事务可以加入而导致写库操作在无事务状态下执行(不受回滚保护)。
* REQUIRES_NEW 强制开启一个全新的独立事务,确保审计日志的写入是原子的。
*
* 注意:@TransactionalEventListener@Transactional 必须同时标注,
* 两者分别控制"何时触发"和"触发后如何管理自己的事务",职责不同。
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void onUserRegistered(UserRegisteredEvent event) {
log.info("【审计监听器】记录邮件发送日志,userId:{}", event.userId());
// ...写审计日志到数据库...
}
}

如果在 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 的原因;监听器不触发的排查路径。

关键动作:将邮件、短信等不可撤回副作用的监听器注解替换为 @TransactionalEventListenerphase 使用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
HTTP 请求进入(Tomcat 工作线程 T1)

UserService.register() 开始

数据写库 + 事务提交 ← 用时 ~5ms

AFTER_COMMIT 回调触发

WelcomeMailListener.onUserRegistered()
mailService.sendWelcomeMail() ← 连接 SMTP、发送邮件,用时 ~800ms

邮件发送完毕

HTTP 响应返回给客户端 ← 客户端等了 ~800ms
T1 线程才释放回线程池

对用户来说,一次"点击注册"要等将近一秒才能得到响应,而其中绝大部分时间花在了发邮件上。更糟糕的是:如果 SMTP 服务器响应慢,或者网络抖动,这个等待时间会更长;在高并发时,大量 Tomcat 工作线程都阻塞在邮件发送上,可用线程快速耗尽,整个服务的响应能力骤降。

异步化之后,时序变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
HTTP 请求进入(Tomcat 工作线程 T1)

UserService.register() 开始

数据写库 + 事务提交 ← 用时 ~5ms

AFTER_COMMIT 回调触发

WelcomeMailListener.onUserRegistered() 把任务提交到邮件专属线程池

立刻返回

HTTP 响应返回给客户端 ← 客户端只等了 ~5ms
T1 线程释放

(与此同时,邮件专属线程池中的线程 T2 在后台发送邮件)

注册接口的响应时间从 ~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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.example.mailDemo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableAsync // 开启 @Async 注解的处理能力,缺少此注解时 @Async 标注的方法仍会同步执行
@MapperScan("com.example.mailDemo.**.mapper")
public class MailDemoApplication {
public static void main(String[] args) {
SpringApplication.run(MailDemoApplication.class, args);
}
}

@EnableAsync 缺失是最常见的"@Async 不生效"原因——标注了 @Async,方法依然同步跑,控制台也没有任何报错,因为 Spring 只是没有代理它而已。

步骤 2:配置邮件发送专属线程池

不建议让邮件发送使用 Spring Boot 默认的公共异步线程池(SimpleAsyncTaskExecutor)——它每次都创建新线程,没有线程复用,在高并发下会耗尽系统资源。我们给邮件发送配置一个有界的专属线程池:

📄 文件:src/main/java/com/example/mailDemo/config/AsyncConfig.java(新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.example.mailDemo.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.ThreadPoolExecutor;

@Slf4j
@Configuration
public class AsyncConfig {

/**
* 邮件发送专属线程池。
*
* Bean 名称 "mailTaskExecutor" 与监听器上 @Async("mailTaskExecutor") 对应,
* 指定异步任务投递到这个线程池,而不是公共线程池。
*/
@Bean("mailTaskExecutor")
public ThreadPoolTaskExecutor mailTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();

// 核心线程数:常驻线程,即使空闲也不会销毁
// 生产建议值:根据邮件发送的并发量评估,通常 2-4 即可
executor.setCorePoolSize(2);

// 最大线程数:核心线程不够用时,最多扩展到此数量
// 生产建议值:不超过核心数的 2 倍,避免线程调度开销过大
executor.setMaxPoolSize(4);

// 队列容量:任务等待队列的最大长度
// 生产建议值:根据邮件发送的峰值 QPS × 预估最大延迟时间来设定
// 设为 100 意味着最多允许 100 封邮件在队列里等待
executor.setQueueCapacity(100);

// 线程名前缀:出现在日志和线程 dump 里,方便定位问题
executor.setThreadNamePrefix("mail-async-");

// 拒绝策略:队列满且线程数达到最大时,由调用者线程直接执行(降级,而非直接丢弃)
// CallerRunsPolicy 是最保守的策略:不丢任务,但会让调用者线程阻塞,起到背压效果
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

// 等待所有任务执行完毕再关闭线程池(优雅停机)
// 生产必须设为 true,否则应用关闭时正在发送的邮件会被中断
executor.setWaitForTasksToCompleteOnShutdown(true);

// 等待的最大秒数,超时后强制关闭(防止关机卡住)
executor.setAwaitTerminationSeconds(30);

executor.initialize();
return executor;
}
}

步骤 3:在监听方法上标注 @Async

📄 文件:src/main/java/com/example/mailDemo/mail/WelcomeMailListener.java(修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package com.example.mailDemo.mail;

import com.example.mailDemo.user.UserRegisteredEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
@RequiredArgsConstructor
public class WelcomeMailListener {

private final MailService mailService;

/**
* @TransactionalEventListener:等事务提交后再触发(第四章已解释)
* @Async("mailTaskExecutor"):触发后把任务投递到邮件专属线程池异步执行
*
* 两个注解的职责完全不同:
* 前者控制"什么时候触发"(事务边界)
* 后者控制"在哪个线程执行"(线程模型)
* 两者可以同时使用,互不干扰。
*/
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async("mailTaskExecutor")
public void onUserRegistered(UserRegisteredEvent event) {
log.info("【邮件监听器】线程:{},开始发送欢迎邮件,目标邮箱:{}",
Thread.currentThread().getName(), event.email());
mailService.sendWelcomeMail(event.email(), event.username());
}
}

启动应用,触发一次注册,观察日志里的线程名称。如果看到类似 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package com.example.mailDemo.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.lang.reflect.Method;
import java.util.concurrent.ThreadPoolExecutor;

@Slf4j
@Configuration
public class AsyncConfig implements AsyncConfigurer {

@Bean("mailTaskExecutor")
public ThreadPoolTaskExecutor mailTaskExecutor() {
// ...(同上,省略重复配置)...
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(2);
executor.setMaxPoolSize(4);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("mail-async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}

/**
* 全局异步异常处理器。
*
* 当 @Async 标注的方法抛出未捕获异常时,Spring 会调用此处理器。
* 注意:此处理器只对返回 void 的异步方法生效。
* 返回 Future/CompletableFuture 的方法,异常由调用方在 get() 时获取。
*/
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new MailAsyncExceptionHandler();
}

/**
* 邮件发送异步异常处理器。
* 实际项目中可在此接入告警系统(钉钉、PagerDuty 等)或写入失败补偿队列。
*/
static class MailAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
@Override
public void handleUncaughtException(Throwable ex, Method method, Object... params) {
log.error("【异步邮件】发送失败,方法:{},参数:{},原因:{}",
method.getName(), params, ex.getMessage(), ex);
// 生产环境建议:将失败记录写入数据库或消息队列,供定时任务重试
}
}
}

只有日志记录还不够。"邮件发送失败"是一个需要补偿的业务事件——用户没收到欢迎邮件,应该有机制在稍后重试。更完整的设计是定义一个 MailSendFailedEvent,让监听器在捕获异常时发布它:

📄 文件:src/main/java/com/example/mailDemo/mail/MailSendFailedEvent.java(新增)

1
2
3
4
5
6
7
8
9
10
package com.example.mailDemo.mail;

/**
* 邮件发送失败事件。
*
* @param userId 目标用户 ID,用于补偿任务查询用户信息
* @param email 目标邮箱,重试时直接使用
* @param reason 失败原因描述,写入补偿记录,方便人工排查
*/
public record MailSendFailedEvent(Long userId, String email, String reason) {}

在监听器里捕获异常并发布失败事件:

📄 文件:src/main/java/com/example/mailDemo/mail/WelcomeMailListener.java(修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.example.mailDemo.mail;

import com.example.mailDemo.user.UserRegisteredEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

@Slf4j
@Component
@RequiredArgsConstructor
public class WelcomeMailListener {

private final MailService mailService;
private final ApplicationEventPublisher eventPublisher;

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async("mailTaskExecutor")
public void onUserRegistered(UserRegisteredEvent event) {
try {
log.info("【邮件监听器】开始发送欢迎邮件,目标邮箱:{}", event.email());
mailService.sendWelcomeMail(event.email(), event.username());
log.info("【邮件监听器】发送成功,目标邮箱:{}", event.email());
} catch (Exception ex) {
log.error("【邮件监听器】发送失败,目标邮箱:{},原因:{}", event.email(), ex.getMessage(), ex);
// 发布失败事件,由专门的补偿监听器处理重试或告警
eventPublisher.publishEvent(
new MailSendFailedEvent(event.userId(), event.email(), ex.getMessage())
);
}
}
}

在异步监听器里发布新事件时,新事件在当前异步线程中同步发布和处理。如果新事件的监听器也是异步的,它会再次提交到线程池——这属于正常链路,但要注意线程池容量是否足够。

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.mailDemo.user;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;
import org.springframework.transaction.annotation.Transactional;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@RecordApplicationEvents // 启用事件录制,测试执行期间所有发布的事件都会被记录
@Transactional // 每个测试方法结束后自动回滚,保持数据库清洁
class UserServiceTest {

@Autowired
private UserService userService;

/**
* ApplicationEvents 是 Spring 注入的特殊对象,持有当前测试方法内录制的所有事件。
* 必须用 @Autowired 注入,不能手动 new。
*/
@Autowired
private ApplicationEvents applicationEvents;

@Test
void 注册成功后应发布UserRegisteredEvent() {
// 执行注册
userService.register("testUser", "test@example.com");

// 断言事件类型与数量:注册一次,应该发布恰好一个 UserRegisteredEvent
assertThat(
applicationEvents.stream(UserRegisteredEvent.class)
).hasSize(1);
}

@Test
void 注册成功后事件应携带正确的用户信息() {
userService.register("alice", "alice@example.com");

// 取出第一个(也是唯一一个)UserRegisteredEvent,断言其字段内容
UserRegisteredEvent event = applicationEvents
.stream(UserRegisteredEvent.class)
.findFirst()
.orElseThrow();

assertThat(event.email()).isEqualTo("alice@example.com");
assertThat(event.username()).isEqualTo("alice");
assertThat(event.userId()).isNotNull(); // id 由数据库自增回填,只验证非空
}
}

@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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package com.example.mailDemo.config;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.core.task.TaskExecutor;

/**
* 测试专用异步配置。
*
* @Primary 确保此 Bean 优先级高于生产配置中的 mailTaskExecutor,
* 测试期间 @Async("mailTaskExecutor") 的调用会被路由到这个同步执行器。
*
* SyncTaskExecutor 不开启新线程,直接在当前线程里同步执行任务,
* 使得异步监听器的执行结果在断言时已经确定可见。
*/
@TestConfiguration
public class TestAsyncConfig {

@Bean("mailTaskExecutor")
@Primary
public TaskExecutor mailTaskExecutor() {
return new SyncTaskExecutor();
}
}

在需要测试异步行为的测试类里引入这个配置:

📄 文件:src/test/java/com/example/mailDemo/mail/WelcomeMailListenerTest.java(新增)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
package com.example.mailDemo.mail;

import com.example.mailDemo.config.TestAsyncConfig;
import com.example.mailDemo.user.UserRegisteredEvent;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.event.ApplicationEvents;
import org.springframework.test.context.event.RecordApplicationEvents;

import static org.mockito.Mockito.*;

@SpringBootTest
@RecordApplicationEvents
@Import(TestAsyncConfig.class) // 引入同步执行器,覆盖生产线程池
class WelcomeMailListenerTest {

@Autowired
private ApplicationEventPublisher eventPublisher;

/**
* MockBean:用 Mockito 的 Mock 对象替换 Spring 容器中的 MailService Bean,
* 避免测试时真的去连 SMTP 服务器发邮件。
*/
@MockBean
private MailService mailService;

@Autowired
private ApplicationEvents applicationEvents;

@Test
void 收到注册事件后应调用邮件服务发送欢迎邮件() {
// 直接发布事件(绕过 UserService 和事务,单独测试监听器行为)
// 注意:@TransactionalEventListener 在无事务时默认不触发,
// 此处测试会无法触发,需要结合 fallbackExecution 或改用 @EventListener 测试
// 推荐的替代方案见下方说明
eventPublisher.publishEvent(
new UserRegisteredEvent(1L, "test@example.com", "testUser")
);

// 断言 MailService.sendWelcomeMail 被调用了一次
verify(mailService, times(1))
.sendWelcomeMail("test@example.com", "testUser");
}
}

直接 publishEvent 测试 @TransactionalEventListener 有一个问题:没有活跃事务时监听器不触发(第四章讲过的 fallbackExecution 默认行为)。测试 @TransactionalEventListener 的推荐方式是通过完整的 Service 方法调用(如 userService.register()),让 @Transactional 产生真实的事务上下文。

思路二:分层断言,把"事件是否发布"和"副作用是否发生"分开测试

这是更推荐的测试设计原则。把测试目标拆成两层:

第一层,测试发布者(UserService):只断言事件是否被正确发布,不关心邮件有没有发出去。用 @RecordApplicationEvents + ApplicationEvents 断言事件的类型、数量和字段内容。这层测试可以完全无视 MailService

第二层,测试监听器(WelcomeMailListener):只断言监听器收到事件后是否正确调用了 MailService。用 @MockBean Mock 掉 MailService,用 verify() 断言调用次数和参数。这层测试不需要关心事件从哪里来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 第一层:只测发布者,不测副作用
@Test
void 注册方法应发布正确的事件() {
userService.register("alice", "alice@example.com");
assertThat(applicationEvents.stream(UserRegisteredEvent.class))
.hasSize(1)
.first()
.satisfies(e -> {
assertThat(e.email()).isEqualTo("alice@example.com");
assertThat(e.username()).isEqualTo("alice");
});
}

// 第二层:只测监听器,Mock 掉真实依赖
@Test
void 监听器收到事件后应调用邮件服务() {
// 直接发布事件,绕过 UserService,隔离测试监听器
eventPublisher.publishEvent(new UserRegisteredEvent(1L, "b@b.com", "bob"));
verify(mailService, times(1)).sendWelcomeMail("b@b.com", "bob");
}

分层断言的好处是:当测试失败时,你能立刻定位是"事件没发出去"还是"监听器没有正确处理",而不是面对一个大而全的集成测试不知道从哪里开始排查。

失败场景的测试

用 Mockito 模拟 MailService 抛出异常,验证失败时是否正确发布了 MailSendFailedEvent

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void 邮件发送失败时应发布MailSendFailedEvent() {
// 让 MailService 抛出异常,模拟 SMTP 连接失败
doThrow(new RuntimeException("SMTP 连接超时"))
.when(mailService).sendWelcomeMail(anyString(), anyString());

eventPublisher.publishEvent(new UserRegisteredEvent(1L, "c@c.com", "carol"));

// 断言失败事件被发布
assertThat(applicationEvents.stream(MailSendFailedEvent.class))
.hasSize(1)
.first()
.satisfies(e -> assertThat(e.reason()).contains("SMTP 连接超时"));
}

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 方法触发,提供真实事务上下文