Spring @EventListener 事件驱动解耦实战:同步转异步、事务绑定与顺序控制
一个「用户注册」的 service 方法,时间长了往往会变成一坨:存库、发欢迎邮件、送积分、写审计日志、推送到风控......业务主流程被一堆「注册成功后顺便要做的事」塞满,加一个下游就得改一次注册代码,还得改它的单测。
Spring 的事件机制(ApplicationEventPublisher + @EventListener)就是拆这坨的利器:主流程只管「发一个事件」,谁关心谁自己订阅。这篇把发布订阅、异步、事务绑定、顺序这几个实战要点一次讲清,并点出最容易踩的「事务里发邮件」坑。
从耦合的写法开始
注册方法一开始长这样,什么都往里塞:
java
@Service
public class UserService {
// 一堆下游依赖全怼进来,注册和它们死死耦合
private final UserRepository userRepo;
private final EmailService emailService;
private final PointService pointService;
private final AuditService auditService;
public void register(RegisterCmd cmd) {
User user = userRepo.save(new User(cmd.username(), cmd.email()));
emailService.sendWelcome(user); // 加一个下游就改这里
pointService.grantSignupBonus(user);
auditService.log("register", user.getId());
}
}
问题很直接:UserService 依赖了四个东西,新增「注册后发短信」又要改它、改它的测试;而且发邮件慢,把注册接口拖慢了。
第一步:定义事件并发布
先把「注册成功」抽象成一个事件对象(用 record 最干净):
java
// 一个不可变的事件载体
public record UserRegisteredEvent(Long userId, String username, String email) {}
主流程只注入 ApplicationEventPublisher,存完库发个事件就收工:
java
@Service
public class UserService {
private final UserRepository userRepo;
private final ApplicationEventPublisher publisher; // Spring 自动注入
public UserService(UserRepository userRepo, ApplicationEventPublisher publisher) {
this.userRepo = userRepo;
this.publisher = publisher;
}
@Transactional
public void register(RegisterCmd cmd) {
User user = userRepo.save(new User(cmd.username(), cmd.email()));
// 只管发事件,不关心谁在听
publisher.publishEvent(
new UserRegisteredEvent(user.getId(), user.getUsername(), user.getEmail()));
}
}
UserService 现在只依赖仓库和发布器,下游全砍掉了。
第二步:用 @EventListener 订阅
每个下游各写各的监听器,互不打扰:
java
@Component
public class WelcomeEmailListener {
private final EmailService emailService;
public WelcomeEmailListener(EmailService emailService) {
this.emailService = emailService;
}
// 方法参数类型就是订阅的事件类型
@EventListener
public void on(UserRegisteredEvent event) {
emailService.sendWelcome(event.email());
}
}
@Component
public class SignupBonusListener {
private final PointService pointService;
public SignupBonusListener(PointService pointService) {
this.pointService = pointService;
}
@EventListener
public void on(UserRegisteredEvent event) {
pointService.grantSignupBonus(event.userId());
}
}
想加「注册后发短信」?新写一个 SmsListener 就行,碰都不用碰 UserService。这就是事件驱动解耦的价值。
关键坑:默认是同步的,而且在同一个事务里
很多人以为 publishEvent 是异步的,错 。默认 @EventListener 是同步执行 的:publishEvent 会阻塞,直到所有监听器跑完才返回。这意味着:
- 发邮件慢,注册接口照样被拖慢(没解决性能问题)。
- 更危险的是------监听器和发布方跑在同一个事务里 。如果
WelcomeEmailListener抛异常,默认会把register的事务一起回滚,用户根本没注册成功。「发邮件失败导致注册失败」,这是线上事故重灾区。
第三步:@TransactionalEventListener 绑定事务提交
正确姿势是让下游在事务真正提交之后 才执行,用 @TransactionalEventListener:
java
@Component
public class WelcomeEmailListener {
private final EmailService emailService;
public WelcomeEmailListener(EmailService emailService) {
this.emailService = emailService;
}
// 只在事务成功提交后才触发;事务回滚则不发邮件
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(UserRegisteredEvent event) {
emailService.sendWelcome(event.email());
}
}
phase 可选 AFTER_COMMIT(默认,提交后)、AFTER_ROLLBACK、AFTER_COMPLETION、BEFORE_COMMIT。用 AFTER_COMMIT 就保证了「数据真落库了才发邮件」,不会出现「邮件发了但事务回滚、用户其实没注册」的鬼故事。
注意一个陷阱:@TransactionalEventListener 只有在存在活动事务时才会触发 。如果发事件的方法没有 @Transactional,这个监听器会被静默跳过。想让「没事务时也执行」,加 fallbackExecution = true。
第四步:异步执行,别拖慢主流程
绑定了 AFTER_COMMIT 只是解决了正确性,发邮件仍是同步的、还在拖时间。加 @Async 让它扔到线程池后台跑:
java
@Configuration
@EnableAsync // 别忘了开启异步,否则 @Async 是哑的
public class AsyncConfig {
@Bean
public Executor eventExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(4);
executor.setMaxPoolSize(8);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("event-");
executor.initialize();
return executor;
}
}
java
@Component
public class WelcomeEmailListener {
// AFTER_COMMIT 保证数据落库,@Async 让它后台异步跑,不阻塞注册接口
@Async("eventExecutor")
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(UserRegisteredEvent event) {
emailService.sendWelcome(event.email());
}
}
两个提醒:一是 @Async 一旦异步,监听器里的异常不会再传播给主流程 ,得自己在监听器里 try-catch 记日志,否则异常被线程池悄悄吞掉;二是别用默认线程池(SimpleAsyncExecutor 每次新建线程,不复用),一定自定义一个有界队列的池,防止任务堆积打爆内存。
控制多个监听器的执行顺序
同一个事件有多个监听器时,默认顺序不确定。要指定顺序用 @Order,数字越小越先执行:
java
@Component
public class AuditListener {
@Order(1) // 先记审计
@EventListener
public void on(UserRegisteredEvent event) { /* ... */ }
}
@Component
public class SignupBonusListener {
@Order(2) // 再送积分
@EventListener
public void on(UserRegisteredEvent event) { /* ... */ }
}
但要清醒:一旦监听器加了 @Async 各自跑在不同线程,@Order 就不保证真实完成顺序了,它只保证提交给线程池的先后。有强顺序依赖的逻辑,别拆成两个异步监听器。
小结
- 用
ApplicationEventPublisher.publishEvent+@EventListener把「主流程」和「下游副作用」解耦,加下游不再改主流程代码。 - 默认同步、且在同一事务里:监听器抛异常会回滚主流程,「发邮件失败导致注册失败」就是这么来的。
- 正确性用
@TransactionalEventListener(AFTER_COMMIT)保证「数据落库后才执行副作用」;注意没事务时它默认不触发,需要fallbackExecution=true。 - 性能用
@Async+@EnableAsync+ 自定义有界线程池,并记得异步后异常要自己 catch,别让线程池吞掉。 - 顺序用
@Order;但异步之后@Order只管提交顺序不管完成顺序。
一句话记忆:发事件解耦,AFTER_COMMIT 保正确,@Async 保性能------三者缺一,不是拖慢主流程就是把主流程拖下水。