Spring Event 入门:用事件发布订阅实现业务解耦
在实际项目中,我们经常会遇到这样的业务:
用户注册成功
↓
保存用户
↓
发送欢迎邮件
↓
初始化积分
↓
发送注册通知
↓
记录操作日志
刚开始业务比较简单的时候,我们可能直接全部写在一个 Service 方法里:
public void register(User user) {
// 保存用户
userRepository.save(user);
// 发送邮件
emailService.sendWelcomeEmail(user);
// 初始化积分
pointsService.initPoints(user.getId());
// 发送通知
notificationService.send(user);
// 记录日志
logService.save(user);
}
代码看起来好像没什么问题。
但是随着业务越来越复杂,这种写法的问题会越来越明显。
UserService 本来应该只关心“用户注册”,现在却同时依赖:
EmailService
PointsService
NotificationService
LogService
...
以后再增加一个“注册后赠送优惠券”的需求,还得继续修改:
register()
最终可能变成:
register()
├── 保存用户
├── 发邮件
├── 初始化积分
├── 发优惠券
├── 发消息
├── 记录日志
├── 数据统计
└── ...
这就是典型的业务耦合越来越严重。
而 Spring Event,就是解决这类问题的一种非常轻量的方式。
一、Spring Event 是什么?
Spring Event 是 Spring 提供的一套应用内事件发布订阅机制。
它的设计思想其实很简单:
发布者
↓
发布 Event
↓
Spring
↓
找到监听这个 Event 的 Listener
↓
执行 Listener
例如用户注册完成以后,不再直接调用:
emailService.send();
pointsService.init();
couponService.send();
而是只告诉系统:
“用户已经注册成功了”
也就是发布一个:
UserRegisteredEvent
至于注册之后需要干什么,UserService 不关心。
可能有:
邮件模块
积分模块
优惠券模块
日志模块
统计模块
它们各自监听:
UserRegisteredEvent
然后处理自己的事情。
这实际上就是经典的观察者模式。Spring 官方也将 ApplicationContext 的事件机制描述为观察者模式的一种实现。现代 Spring 还允许直接发布普通 Java 对象,不要求事件必须继承 ApplicationEvent。(Home )
二、Spring Event 到底是怎么实现解耦的?
理解 Spring Event 最关键的地方,其实不是会写:
@EventListener
而是理解:
它到底解除了什么耦合?
继续看之前的代码。
原来是:
public void register(User user) {
userRepository.save(user);
emailService.sendWelcomeEmail(user);
pointsService.initPoints(user.getId());
couponService.sendCoupon(user.getId());
}
这里的依赖关系是:
UserService
│
├── EmailService
├── PointsService
└── CouponService
UserService 必须认识所有业务模块。
如果以后增加:
RegisterStatisticsService
你还得修改:
UserService
于是业务之间产生了直接依赖。
使用 Spring Event 后:
UserService
↓
UserRegisteredEvent
↓
Spring Event
↙ ↓ ↘
邮件 积分 优惠券
UserService 只负责:
publisher.publishEvent(event);
它甚至不知道有没有:
EmailListener
PointsListener
CouponListener
以后新增一个:
StatisticsListener
原来的:
UserService
一行代码都不用修改。
这就是所谓的:
发布者和订阅者解耦。
真正发生变化的是依赖关系。
原来:
UserService
↓
EmailService
↓
PointsService
↓
CouponService
现在:
UserService
↓
UserRegisteredEvent
其他模块则依赖这个事件:
UserRegisteredEvent
↑
EmailListener
UserRegisteredEvent
↑
PointsListener
UserRegisteredEvent
↑
CouponListener
于是:
“调用某个具体业务”
变成了:
“声明某件事情已经发生”。
这是事件驱动设计里面非常重要的思想。
三、Spring Event 的三个核心角色
学习 Spring Event,只需要先理解三个东西:
Event
Publisher
Listener
Event 表示:
发生了什么事情
Publisher 表示:
谁把这个事情发布出去
Listener 表示:
谁关心这个事情
整个过程就是:
Publisher
↓
Event
↓
Listener
接下来直接写代码。
四、第一步:定义事件
假设我们的业务是:
用户注册成功
定义一个事件:
public record UserRegisteredEvent(
Long userId,
String email
) {
}
如果不用 Java record,普通 Java 类同样可以:
@Data
@AllArgsConstructor
public class UserRegisteredEvent {
private Long userId;
private String email;
}
这里不需要:
extends ApplicationEvent
Spring 从 4.2 开始支持直接发布任意普通对象;如果发布的不是 ApplicationEvent,Spring 会在内部将它包装成 PayloadApplicationEvent。(Home )
事件本身最好不要写大量业务逻辑。
它更像一个:
“消息载体”
负责告诉监听器:
发生了什么
+
处理这个事件需要哪些数据
例如:
UserRegisteredEvent
userId = 1001
email = hello@example.com
五、第二步:发布事件
Spring 提供了:
ApplicationEventPublisher
专门负责发布事件。
例如:
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final ApplicationEventPublisher eventPublisher;
public void register(User user) {
// 核心业务
userRepository.save(user);
// 发布“用户注册成功”事件
eventPublisher.publishEvent(
new UserRegisteredEvent(
user.getId(),
user.getEmail()
)
);
}
}
现在 UserService 做的事情就非常简单:
保存用户
↓
告诉系统:
用户注册成功了
至于后面发生什么,它不管。
这时候代码已经开始解耦了。
六、第三步:监听事件
接下来创建邮件监听器:
@Component
@RequiredArgsConstructor
public class EmailListener {
private final EmailService emailService;
@EventListener
public void handle(UserRegisteredEvent event) {
emailService.sendWelcomeEmail(
event.email()
);
}
}
再创建积分监听器:
@Component
@RequiredArgsConstructor
public class PointsListener {
private final PointsService pointsService;
@EventListener
public void handle(UserRegisteredEvent event) {
pointsService.initPoints(
event.userId()
);
}
}
再来一个优惠券监听器:
@Component
@RequiredArgsConstructor
public class CouponListener {
private final CouponService couponService;
@EventListener
public void handle(UserRegisteredEvent event) {
couponService.sendRegisterCoupon(
event.userId()
);
}
}
现在业务结构就变成:
UserService
│
│ publishEvent
↓
UserRegisteredEvent
│
├───────────────┐
↓ ↓
EmailListener PointsListener
│ │
↓ ↓
发送邮件 初始化积分
│
└───────────────→ CouponListener
↓
发优惠券
而 UserService 根本不知道这些 Listener 的存在。
七、以后增加业务会发生什么?
例如产品经理突然提出一个需求:
用户注册之后,需要把注册行为上报给数据统计系统。
如果是以前的代码,你可能需要:
public void register(User user) {
userRepository.save(user);
emailService.sendWelcomeEmail(user);
pointsService.initPoints(user.getId());
couponService.sendCoupon(user.getId());
statisticsService.report(user);
}
也就是说:
修改原有业务代码
但使用事件之后,只需要新增:
@Component
@RequiredArgsConstructor
public class StatisticsListener {
private final StatisticsService statisticsService;
@EventListener
public void handle(UserRegisteredEvent event) {
statisticsService.report(
event.userId()
);
}
}
UserService:
public void register(User user)
完全不用动。
这就是 Spring Event 一个非常大的价值:
增加事件响应逻辑时,尽可能做到“新增代码”,而不是不断修改核心业务代码。
八、但是有一个非常重要的问题:Spring Event 默认是同步的
很多人第一次学 Spring Event 会误以为:
publishEvent()
类似:
RabbitMQ
Kafka
发布完以后马上返回。
实际上并不是。
Spring 默认的事件监听是同步执行的。默认的 SimpleApplicationEventMulticaster 会在发布者线程中调用监听器,因此 publishEvent() 可能一直阻塞到对应监听器处理完成。(Home )
例如:
eventPublisher.publishEvent(event);
可能发生:
HTTP 请求线程
↓
register()
↓
publishEvent()
↓
EmailListener
耗时 2 秒
↓
PointsListener
耗时 1 秒
↓
CouponListener
耗时 1 秒
↓
publishEvent() 返回
那么整个请求可能多等待:
2 + 1 + 1 = 4 秒
所以:
@EventListener
本身解决的是:
代码结构上的解耦。
它并不自动解决:
线程上的解耦。
这个区别非常重要。
九、使用 @Async 实现异步事件
如果邮件、日志、向量化、统计等任务比较耗时,通常可以异步执行。
例如:
@Component
@RequiredArgsConstructor
public class EmailListener {
private final EmailService emailService;
@Async
@EventListener
public void handle(UserRegisteredEvent event) {
emailService.sendWelcomeEmail(
event.email()
);
}
}
这时候大概变成:
请求线程
↓
保存用户
↓
publishEvent()
↓
提交异步任务
↓
register() 继续执行
线程池
↓
EmailListener
↓
发送邮件
Spring 的 @Async 会把任务提交给 TaskExecutor,调用线程可以在任务提交后继续执行。(Home )
不过使用 @Async 之前,一般还需要:
@EnableAsync
开启异步能力。
十、自定义事件线程池
实际项目中不太建议所有异步任务都混用一个默认线程池。
可以专门为事件处理定义线程池。
例如:
@Configuration
@EnableAsync
public class AsyncEventConfig {
@Bean("eventTaskExecutor")
public Executor eventTaskExecutor() {
ThreadPoolTaskExecutor executor =
new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setKeepAliveSeconds(60);
executor.setThreadNamePrefix(
"event-handler-"
);
executor.setRejectedExecutionHandler(
new ThreadPoolExecutor.CallerRunsPolicy()
);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAwaitTerminationSeconds(30);
executor.initialize();
return executor;
}
}
然后指定监听器使用这个线程池:
@Component
@Slf4j
public class UserRegisteredListener {
@Async("eventTaskExecutor")
@EventListener
public void handle(
UserRegisteredEvent event) {
log.info(
"处理注册事件:{}",
event
);
}
}
Spring 官方支持直接在 @Async 中指定 Executor Bean:
@Async("eventTaskExecutor")
从而让不同类型的异步任务使用不同线程池。(Home )
这时候日志线程可能类似:
[event-handler-1]
[event-handler-2]
[event-handler-3]
而不是:
http-nio-8080-exec-1
说明任务已经进入异步线程池。
十一、同步事件和异步事件到底怎么选?
如果监听器逻辑非常轻量,并且:
监听器失败
=
主业务也应该失败
那么同步事件其实非常合适。
例如:
创建订单
↓
同步校验某些强业务规则
这种场景并不一定需要异步。
但是:
发送邮件
发送短信
记录非核心日志
生成缩略图
文件解析
AI 向量化
搜索索引更新
数据统计
这些操作通常不应该长时间阻塞主请求。
更加适合:
@EventListener
@Async
也就是说,判断的关键不是:
Event 要不要异步?
而是:
这个事件消费者是不是主业务成功的必要条件?
十二、异步 Event 还有一个非常重要的变化:异常
同步情况下:
Publisher
↓
Listener
↓
发生异常
↓
异常可能继续抛回 Publisher
如果 Publisher 正好在事务里面,那么监听器异常甚至可能影响原事务。
但是异步后:
请求线程
↓
发布事件
↓
异步线程处理
↓
Listener 抛异常
这个异常已经不能正常抛回原来的请求线程了。
Spring 官方文档明确说明,对于返回 void 的 @Async 方法,异步线程中的异常无法传给原调用者;默认情况下异常通常会被记录,也可以配置 AsyncUncaughtExceptionHandler 做统一处理。(Home )
所以:
@Async
虽然方便,却不能意味着:
“任务一定成功”
真正重要的异步任务,还需要考虑:
异常处理
重试
告警
补偿
幂等
十三、数据库事务是 Spring Event 最容易踩坑的地方
现在考虑一个真实问题。
用户注册:
@Transactional
public void register(User user) {
userRepository.save(user);
eventPublisher.publishEvent(
new UserRegisteredEvent(
user.getId(),
user.getEmail()
)
);
}
问题来了:
publishEvent()
执行的时候:
数据库事务可能还没有提交
流程其实可能是:
BEGIN
↓
INSERT user
↓
publishEvent()
↓
Listener 执行
↓
register() 结束
↓
COMMIT
如果 Listener 里面:
发送邮件
邮件已经发出去了。
结果最后:
COMMIT 失败
数据库里面根本没有这个用户。
于是出现:
用户注册失败
但是欢迎邮件已经发送成功
这显然不合理。
十四、@TransactionalEventListener
这种情况下可以使用:
@TransactionalEventListener
例如:
@Component
public class UserRegisteredListener {
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT
)
public void handle(
UserRegisteredEvent event) {
System.out.println(
"事务提交成功后执行"
);
}
}
这时候流程变成:
BEGIN
↓
保存用户
↓
发布事件
↓
register() 结束
↓
COMMIT
↓
事务提交成功
↓
执行 Listener
@TransactionalEventListener 默认阶段就是 AFTER_COMMIT,除此之外还支持 BEFORE_COMMIT、AFTER_ROLLBACK 和 AFTER_COMPLETION。如果发布事件时根本不存在事务,默认不会执行该事务监听器,除非开启 fallbackExecution。(Home )
对于这种业务:
数据库成功
↓
才执行后续动作
它通常比普通:
@EventListener
更加合适。
十五、实际项目常见组合:事务提交成功 + 异步执行
例如:
用户注册
↓
数据库事务成功
↓
异步发送邮件
可以直接组合:
@Component
@RequiredArgsConstructor
public class UserRegisteredListener {
private final EmailService emailService;
@Async("eventTaskExecutor")
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT
)
public void handle(
UserRegisteredEvent event) {
emailService.sendWelcomeEmail(
event.email()
);
}
}
这样设计的语义非常清楚:
数据库事务提交成功
↓
再触发监听器
↓
交给异步线程池
↓
发送邮件
也就是:
主业务可靠性
+
代码解耦
+
线程解耦
这也是 Spring Event 在业务系统中非常实用的一种组合。
十六、放到“文件上传 + AI 向量化”场景就更容易理解了
例如上传 Markdown 文件。
如果全部写在 Controller 或 Service:
public void upload(MultipartFile file) {
// 保存文件
saveFile(file);
// 保存数据库记录
saveDatabase();
// 解析 Markdown
parseMarkdown();
// 文档切片
splitDocument();
// 调用 Embedding 模型
embedding();
// 保存向量数据库
saveVectorStore();
}
这就意味着一次 HTTP 请求可能要完成:
文件 IO
+
数据库 IO
+
Markdown 解析
+
Embedding 网络请求
+
向量数据库写入
上传接口很可能变得非常慢。
而且文件上传服务还直接依赖:
MarkdownParser
EmbeddingModel
VectorStore
耦合非常严重。
更好的思路是:
上传文件
↓
保存文件
↓
保存数据库
↓
发布 MarkdownUploadedEvent
↓
接口返回
后台事件线程
↓
解析 Markdown
↓
切分 Document
↓
Embedding
↓
写入 VectorStore
定义事件:
public record MarkdownUploadedEvent(
Long fileId,
String filePath
) {
}
上传:
@Transactional
public void upload(MultipartFile file) {
String filePath = saveFile(file);
Long fileId =
saveFileRecord(filePath);
eventPublisher.publishEvent(
new MarkdownUploadedEvent(
fileId,
filePath
)
);
}
监听:
@Component
@RequiredArgsConstructor
public class MarkdownUploadedListener {
private final MarkdownVectorService
markdownVectorService;
@Async("eventTaskExecutor")
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT
)
public void vectorize(
MarkdownUploadedEvent event) {
markdownVectorService.vectorize(
event.fileId(),
event.filePath()
);
}
}
现在上传模块只需要负责:
文件上传成功
而向量化模块只负责:
收到文件上传成功事件
↓
执行向量化
这就是非常典型的业务解耦。
十七、Spring Event 什么时候特别适合用?
第一种情况是:
一个核心动作完成后
有多个附加动作
例如:
用户注册完成
→ 邮件
→ 积分
→ 优惠券
→ 日志
→ 统计
这类“一对多”的后续动作特别适合事件。
第二种情况是:
核心业务不应该知道后续具体实现
例如订单模块只应该知道:
订单支付成功
不应该知道:
库存怎么更新
积分怎么算
短信怎么发送
统计系统怎么上报
这时候可以发布:
OrderPaidEvent
第三种情况是:
耗时的非核心任务
例如:
图片处理
文件解析
AI 向量化
邮件
通知
数据统计
搜索索引
它们通常可以:
Event + Async
第四种情况是:
必须等数据库成功后才能做
例如:
订单真正创建成功
↓
发送订单通知
这种通常适合:
@TransactionalEventListener(
phase = AFTER_COMMIT
)
十八、什么时候不应该使用 Spring Event?
Spring Event 并不是越多越好。
例如一个非常简单的强依赖关系:
订单计算价格
↓
优惠计算
订单价格根本离不开优惠计算结果。
那么直接:
priceService.calculate();
反而更清晰。
如果强行改成:
PriceCalculateEvent
代码反而变得很难追踪。
因为看到:
publishEvent()
你根本不知道:
谁处理了它?
处理顺序是什么?
产生了什么副作用?
所以 Event 更适合:
“某件事情已经发生”
而不是:
“请帮我调用某个方法并马上返回结果”
这是非常重要的判断标准。
十九、Spring Event 不是 MQ
这也是必须搞清楚的一点。
Spring Event 本质上主要是:
同一个 Spring 应用
同一个 JVM
同一个 ApplicationContext
里面的组件通信机制。Spring 官方也把它定位为同一应用上下文中 Spring Bean 之间的简单通信机制。(Home )
例如:
Spring Boot 应用
UserService
↓
Spring Event
↓
EmailListener
但是如果你的架构是:
用户服务
↓
???
↓
邮件服务
两个服务分别运行在:
服务器 A
服务器 B
那么 Spring Event 就不是合适的跨服务通信方案。
这时候通常需要:
RabbitMQ
Kafka
RocketMQ
Pulsar
这样的消息系统。
还有一个特别重要的区别:
Spring Event 本身不是持久化消息队列。
如果你:
publishEvent()
↓
异步任务还没完成
↓
应用直接宕机
普通 Spring Event 并没有天然提供类似 MQ 的:
消息持久化
ACK
重试队列
消费确认
死信队列
跨进程投递
所以像:
支付成功
↓
必须保证财务系统最终收到
这种高可靠事件,不能只因为:
@Async
@EventListener
就认为已经可靠了。
这时候往往应该继续升级到:
事务消息
Outbox Pattern
RabbitMQ
Kafka
RocketMQ
等方案。
二十、普通 Event、Async Event、事务 Event、MQ 怎么选择?
可以建立这样一个判断思路:
只是同一个项目内部解耦?
↓
Event
监听任务比较耗时?
↓
Event + @Async
必须数据库提交成功之后执行?
↓
@TransactionalEventListener
数据库成功之后还要异步?
↓
@TransactionalEventListener
+
@Async
跨服务通信?
↓
MQ
消息绝对不能随应用宕机而丢失?
↓
MQ / Outbox / 可靠消息方案
这个判断逻辑,比单纯背:
@EventListener
有用得多。
二十一、还有几个很容易踩的坑
首先,不要往 Event 里面塞整个复杂业务对象。
例如:
new UserRegisteredEvent(user)
有时候方便,但事件异步执行的时候,这个对象后续可能发生变化。
更稳妥的方式通常是携带:
userId
email
orderId
fileId
filePath
这种事件真正需要的数据。
其次,不要因为用了:
@Async
就认为任务一定完成。
异步只是:
换线程执行
不是:
可靠消息
而且异步 void 监听器的异常不会重新抛回发布者,需要单独做好异常记录、告警或补偿。(Home )
再次,线程池一定要考虑容量。
例如使用:
new ThreadPoolExecutor.CallerRunsPolicy()
意味着线程池和队列全部满了以后,任务可能改由提交任务的线程执行。
于是原本:
HTTP 请求线程
↓
提交异步任务
↓
快速返回
在高负载时可能退化成:
HTTP 请求线程
↓
自己执行事件任务
↓
接口变慢
所以线程池参数不能简单照抄,需要结合实际任务耗时和流量设计。
二十二、事务监听还有一个高级坑
如果使用:
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT
)
那么进入监听器时,原事务已经提交。
Spring 官方特别提醒,虽然这时某些事务资源可能仍然处于可访问状态,但原事务已经结束,不能简单认为后续数据库修改还会随着原事务再次提交。需要进行新的事务性数据库操作时,应该明确开启新的事务。(Home )
例如可以把数据库修改交给另一个 Service:
@Service
public class PointsService {
@Transactional(
propagation = Propagation.REQUIRES_NEW
)
public void initPoints(Long userId) {
// 新事务
}
}
然后:
@TransactionalEventListener(
phase = TransactionPhase.AFTER_COMMIT
)
public void handle(
UserRegisteredEvent event) {
pointsService.initPoints(
event.userId()
);
}
这样事务边界会更加清晰。
二十三、回头再看“解耦”
没有事件之前:
UploadService
│
├── MarkdownParser
├── EmbeddingModel
├── VectorStore
├── NotificationService
└── ...
使用事件之后:
UploadService
↓
MarkdownUploadedEvent
↓
Spring Event
│
├── VectorListener
├── LogListener
└── NotificationListener
于是上传模块只表达:
Markdown 文件已经上传成功。
至于后面:
解析
切片
Embedding
向量存储
通知
统计
全部由其他模块自己决定。
这才是事件真正的意义。
它不是单纯为了少写几个:
service.xxx();
而是把系统从:
“我要调用谁”
转变成:
“发生了什么”
总结
Spring Event 入门真正需要掌握的核心流程其实非常简单:
定义 Event
↓
ApplicationEventPublisher
↓
publishEvent()
↓
@EventListener
↓
处理事件
如果任务比较耗时:
@EventListener
+
@Async
如果要求:
数据库成功以后
才允许处理事件
使用:
@TransactionalEventListener(
phase = AFTER_COMMIT
)
如果既要求事务成功,又不想阻塞当前线程:
@TransactionalEventListener
+
@Async
而如果已经涉及:
跨服务
消息不能丢
可靠重试
消费确认
高吞吐消息流
那么就应该开始考虑:
RabbitMQ
Kafka
RocketMQ
Outbox Pattern
而不是继续把 Spring Event 当消息队列使用。
所以理解 Spring Event 最重要的一句话是:
Spring Event 的核心不是“异步”,而是通过“事件”切断业务模块之间的直接调用关系。
发布者只需要告诉系统:
“某件事情已经发生了。”
至于谁关心、谁处理、未来增加多少个处理逻辑,都交给监听者自己完成。
这才是真正的业务解耦。