下单接口写完后,紧接着在同一个方法里写日志、发短信、更新统计,这样的代码我写了太多年。一开始觉得没什么,等接口多了、第三方通道不稳定、统计报表越来越复杂,这堆“旁路逻辑”就把核心业务死死绑住了。今天想聊的是我在 Spring Boot 项目里比较常用的解法:用异步事件总线把日志、通知、统计与核心业务解耦。这套思路特别适合已经在用 Spring Boot、又不希望为了解耦立刻引入消息中间件的团队,从单体应用内部先把代码边界梳理干净。对于刚接触 Spring 事件的开发者,这篇文章也能帮你把事件机制、异步线程池、事务边界这些点串起来。
事件总线的核心心智模型很朴素:核心业务只负责“宣布发生了什么”,至于谁去写审计日志、谁去发推送、谁去更新大盘数据,都由独立的监听器接收。这样一来,核心服务不再知道旁路逻辑的存在,新增一个通知渠道时,核心代码一行都不用改。后续真要演进到消息队列,这套“发布订阅”的思维也能平滑过渡。
1. 为什么要用事件总线:一次“什么都往里装”的改造
1.1 一段让接口响应变慢的耦合代码
我先把最典型的“坏味道”贴出来。假设有一个下单接口,核心服务里大概是这样的:
@Service public class OrderService { public OrderDTO createOrder(OrderCreateCommand command) { // 1. 校验与核心业务 Order order = buildOrder(command); orderRepository.save(order); // 2. 记录业务操作日志 operationLogService.write("order", order.getId(), "CREATE"); // 3. 发短信/APP推送通知用户 notifyService.sendSms(order.getUserId(), "您的订单已创建"); // 4. 统计:订单量+1,销售额累加 statisticService.increaseOrderCount(order.getAmount()); return OrderDTO.from(order); } }这段代码在功能上完全正确,跑一两年可能也不会出大问题。问题出在三个地方。
第一,响应时间被无关操作拖长。发短信如果走的是第三方通道,一次 HTTP 调用稳定在几十到几百毫秒;统计服务如果当天网络波动,整个下单 API 跟着遭殃。用户在下单这个动作里,完全不需要等短信发送的结果,他真正在意的是订单状态有没有落库。
第二,核心业务被旁路逻辑绑架。订单流程每增加一个新的通知需求,比如加一个微信模板消息,发布前还要加一行notifyService.sendWeChat(...)。久而久之,核心服务就变成一个“什么都往里装”的入口,所有横切关注点最终都汇到这里。
第三,故障边界不清晰。第三方短信通道超时了,订单保存本来已经成功,却因为异常直接回滚,用户端看到的结果是“下单失败”。这就是典型的垃圾耦合——一个旁路服务的稳定性反过来绑架了核心业务。
1.2 事件总线:把“直接调用”改成“对空气说话”
事件总线的核心改造,是把上面四处调用拆成“发布 + 监听”。核心业务只做一件事:
publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getUserId(), order.getAmount()));随后,无论有没有监听器响应,发布方都不关心。日志监听器收到事件就写日志,通知监听器收到事件就异步发短信,统计监听器收到事件就更新指标。谁都不再和下单方法直接绑定。
用一个生活化类比来说:传统写法是“顾客在厨房里对着厨师喊,我要一份红烧肉,顺便帮我把桌子擦了,再帮我算一下下次优惠”。事件总线更像是“顾客在前台下完单,广播器喊了一嗓子:‘新订单来了,单号 1024’”。后厨只做菜,保洁只擦桌,财务只记账,彼此之间不需要认识。之所以叫异步事件总线,是因为我们想让这些监听任务不阻塞主流程,后面会详细说这个点。
这里要强调一句:Spring Boot 发布事件时,监听器默认是同步执行的。如果监听器逻辑很重,发布事件本身就会拖慢主流程。所以我们才需要把监听器扔到独立的线程池里,否则事件总线只解决了代码结构的耦合,没解决性能耦合。
2. 事件机制基础:三个角色和一条线
2.1 事件的三个核心角色
Spring 的事件机制基于 Java 内置的观察者模式,落到代码里就是三个角色:
- 事件对象:定义一个 POJO 或继承
ApplicationEvent,用来承载业务事实的字段。 - 监听器:实现
ApplicationListener<T>接口,或者用@EventListener注解,处理它关心的事件。 - 发布器:注入
ApplicationEventPublisher,调用publishEvent把事件发给容器。
事件流转的顺序是:核心业务方法拿到请求,完成核心状态变更后,构造一个包含必要字段的事件对象,交给ApplicationEventPublisher。Spring 容器根据这个事件的类型,找到所有注册好的监听器,按预定的顺序触发它们。
Spring 的事件机制支持泛型事件,所以你完全可以用普通 POJO 作为事件,没必要强制继承ApplicationEvent。我建议给事件类定义一个统一的包和命名规范,比如OrderCreatedEvent、OrderPaidEvent。这比直接用 String 消息“传话”要严谨得多——事件类本身就是契约,字段命名就是协议的一部分。
一个基础的事件定义和监听器代码长这样:
// 事件定义,建议放在 domain 的 event 子包里 public class OrderCreatedEvent { private final Long orderId; private final Long userId; private final BigDecimal amount; public OrderCreatedEvent(Long orderId, Long userId, BigDecimal amount) { this.orderId = orderId; this.userId = userId; this.amount = amount; } // getter 省略 } // 监听器定义 @Component public class OrderLogListener { @EventListener public void onOrderCreated(OrderCreatedEvent event) { // 这里干写日志的活 } }2.2 发布事件的入口统一封装
发布事件不需要把发布器暴露到所有业务代码里。我通常会把发布动作包在一个领域服务中,或者直接注入ApplicationEventPublisher:
@Service public class OrderService { private final ApplicationEventPublisher publisher; public OrderService(ApplicationEventPublisher publisher) { this.publisher = publisher; } public void createOrder(OrderCreateCommand command) { Order order = orderRepository.save(...); publisher.publishEvent(new OrderCreatedEvent(order.getId(), order.getUserId(), order.getAmount())); } }这里提醒一个初学者常踩的坑:ApplicationEventPublisher发布事件时,如果监听器抛异常,默认会顺着调用栈抛回给publishEvent的调用方,导致核心业务受影响。后面“常见问题”里我会专门讲怎么隔离异常。
核心服务只需要面向ApplicationEventPublisher接口编程,不需要 import 任何监听器类。这就是依赖倒置的一种落地形式。后续在哪个模块增加新监听器,核心服务一行代码都不用改。
3. 异步执行的关键:线程池配置与默认“坑”
3.1 Spring 默认同步发布,怎么让它异步
先说结论:Spring Boot 中用publisher.publishEvent()发布事件,监听器默认是同步执行的。监听器里的耗时会被包含在核心请求的总耗时里,这违背了我们要“解耦性能”的初衷。
让监听器异步的主流做法有三种:
- 在监听器方法上标注
@Async。 - 实现
ApplicationEventMulticaster,自定义事件多播器。 - 通过
@Async配合自定义线程池,批量提交监听任务。
第一种最简单也最常用。在监听方法上加@Async后,Spring 会使用配置好的TaskExecutor来执行。
不过@Async有几个硬性生效条件:方法必须是 public、不能从同一个类内部调用自身、所在的 Bean 必须由 Spring 容器管理,且要在配置类上开启@EnableAsync。如果你漏掉@EnableAsync,所有@Async都会被静默忽略,直接同步执行。这个坑我见过不少次,排查半天都找不到原因。
3.2 线程池参数怎么给,不用照抄网上的
既然要异步,就必须有一个线程池。Spring Boot 默认会用SimpleAsyncTaskExecutor,但它每次都会新建线程,不是真正意义上的复用线程池,高并发下线程数会不可控,所以务必显式配置。
线程池参数取决于你业务里的事件数量,而不是照抄某个博客的配置。假设你的下单接口 QPS 是 500,每次下单产生 2 到 4 个事件,那么事件负载每秒最多也就 2000 多个。这类监听逻辑大多是 I/O 密集型,比如写库、调短信接口、更新 Redis 计数。
一个相对稳的配置如下:
@Configuration @EnableAsync public class AsyncEventConfig { @Bean("eventExecutor") public ThreadPoolTaskExecutor eventExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); // 核心线程:持续处理常规并发 executor.setCorePoolSize(8); // 最大线程:短暂高峰时扩容 executor.setMaxPoolSize(16); // 队列容量:缓冲突发流量 executor.setQueueCapacity(1000); // 线程名前缀,方便排查线程池状态 executor.setThreadNamePrefix("event-exec-"); // 拒绝策略:任务满时回退到调用线程执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }这个配置背后的逻辑是:
- 核心线程 8,平时 500 QPS 下,事件任务会被快速消化,8 个线程足够。
- 最大线程 16,如果某个第三方通知服务异常变慢,8 个核心线程处理不过来,队列满了以后会把线程扩展到 16。
- 队列容量 1000,给瞬时毛刺一个缓冲,不至于一有波动就触发拒绝策略。
- 拒绝策略选择
CallerRunsPolicy:如果连最大线程和队列都占满,新任务会回退到调用线程(可能是 Tomcat 的业务线程)去同步执行。这会造成一次响应变慢,但不会丢任务。对于日志、统计这类不敏感任务,丢一次勉强能接受;但对于“用户明确等待的确权通知”,我宁可让它降级同步也不丢。
然后,在监听器上标注:
@Async("eventExecutor") @EventListener public void onOrderCreated(OrderCreatedEvent event) { // ...执行异步逻辑 }多个监听器应该共享同一个线程池,避免每个监听器单独开线程池导致线程资源翻倍。
有一个更进阶的做法是直接定制ApplicationEventMulticaster,让所有异步事件统一下发到线程池。我建议不要一上来就用这个方案,控制粒度太粗,一旦有某个监听器希望同步执行,还得额外绕过多播器,不直观。用@Async注解粒度更细,更符合 Spring Boot 的惯例。
3.3 异步事务边界:数据一致性的老话题
事件异步执行后,会出现一个重要的新问题:主线程的事务可能已经提交,也可能还没提交。
如果事件是在事务内发布的,而事务还没提交,异步监听器在同一时刻查库就查不到最新数据。比如统计监听器要执行SELECT COUNT(*) FROM order WHERE user_id=?,如果订单事务还没提交,统计结果就少了这一条。
解决方案有三个层次:
- 用
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT),让监听器在事务提交后再触发。 - 确保事件中携带足够的信息,比如 orderId、amount,监听器尽量少去查库。
- 如果确实需要查库,在监听器里接受短时间的数据延迟,不要做实时强一致假设。
我强烈建议:凡是“事件触发后还需要回查主表数据”的场景,优先检查事务提交时机,否则你会遇到让人摸不着头脑的“偶发少数据”问题。
4. 实战拆解:日志、通知、统计三个事件的完整实现
4.1 事件对象到底要传什么字段
同时处理日志、通知、统计三类监听器时,最需要思考的是事件对象传什么。
我的经验是:事件对象不应当直接传递 Hibernate/JPA 实体,而应传递一个精简的 DTO。原因有二。第一,实体类大多带有懒加载关联,异步线程脱离了主事务会话之后,访问懒加载字段会抛LazyInitializationException。第二,事件本质是“已发生事实的契约”,只应该携带消费方真正需要的最小字段,少传给监听器,也就少暴露不必要的数据。
以OrderCreatedEvent为例,我倾向定义为:
public class OrderCreatedEvent { private final Long orderId; private final Long userId; private final BigDecimal amount; private final Instant occurredAt; private final String channel; // getters... }occurredAt一定要带。因为事件在异步线程执行时,如果监听器自己取new Date(),取到的时间可能已经比真实发生时间晚了几毫秒甚至几秒。对于统计报表这种对时间敏感的场景,必须用事件里的业务发生时间,而不是监听器执行时间。
4.2 日志事件:写操作审计,也要避免无限递归
日志监听器是最简单也最容易失控的。我们的目标是把“谁在什么时间对什么资源做了什么操作”记下来,而不是把核心业务里成堆的 debug 日志挪个地方。
我见过一种反向改造:把核心业务里所有log.info(...)全部替换成“发事件”。这非常危险。日志框架的作用是打印运行时细节,事件的作用是表达业务事实。两者只在“操作审计日志”这个场景下可以结合,不要混为一谈。
我的日志监听器大概长这样:
@Async("eventExecutor") @EventListener public void writeOperationLog(OrderCreatedEvent event) { auditLogService.write( AuditLog.of("ORDER_CREATE", event.getOrderId(), event.getUserId(), event.getOccurredAt()) ); }这里有一个容易被忽略的细节:如果不是写审计日志,而是想在业务日志中把事件内容打出来,你只需要在监听器内用日志框架正常输出。千万注意,如果监听器自己也去发布事件,比如审计切面监听了所有事件,再发布一个 “audit logged” 事件,就可能无限递归。如果你的监控体系确实需要给每个事件记审计,就一定要加一个标记字段,比如AuditLogEvent不进入审计监听器,或者限制只监听顶层业务事件。
4.3 通知事件:异步但必须兜底,不能无限重试
通知事件是三类事件里最能体现异步价值的。用户在意的是订单创建成功,短信或者 App 推送晚个一两秒毫无体感。但如果通知监听器在处理时把第三方渠道调崩了或者超时了,这个错误不应该反向污染主流程。
我的通知监听器会有三层设计。
第一层,直接用@Async把执行丢出请求线程。 第二层,对第三方响应设置明确的超时时间。用RestTemplate或 WebClient 设置连接超时和读超时,别用默认的无限等待。 第三层,引入重试,但必须有限次重试 + 退避。Spring Retry 的@Retryable注解很方便,不过要注意:重试失败后的兜底方案,要么记录一条失败日志交给人工处理,要么把任务写入死信表。
我习惯在通知监听器里维护一个notification_outbox表,记录event_id, channel, status。这样即使异步丢了,也能在后台任务里补偿扫描。
如果你担心“重发”会造成用户收到两次短信,那就必须在业务层面保证幂等。最简单的做法是在通知记录里加一个业务键:eventId + userId + channel,消费前先查询是否已存在成功的记录,存在就直接跳过。在分布式环境下还要考虑并发,生产上建议用数据库唯一索引兜底,而不是只在代码里判断一次。
4.4 统计事件:用聚合值代替明细,提高吞吐
统计这块和日志、通知都不太一样。如果用户每下一次单,我们都往统计表里 INSERT 一条明细,那么统计表很快会成为性能瓶颈。更合理的做法是用事件监听器去更新 Redis 中的聚合值。
例如,在OrderCreatedEvent监听器里:
@Async("eventExecutor") @EventListener public void updateOrderStat(OrderCreatedEvent event) { String dateKey = formatDate(event.getOccurredAt()); redisTemplate.opsForHash().increment("stat:order:daily:" + dateKey, "count", 1); redisTemplate.opsForHash().increment("stat:order:daily:" + dateKey, "amount", event.getAmount()); }然后,用一个定时任务每 30 秒或 1 分钟把这个 hash 结构批量写入 MySQL。这个方案的核心思想是“读写分离 + 批量落地”,避免高频业务在数据库层造成锁竞争。
这里有两个常见的坑。
第一个坑是 Redis 里的 value 不能保证强一致。业务如果真的要求统计绝对精确,比如财务对账,那 Redis 方案就不够,需要回到数据库事务或者事件溯源。大多数业务场景,统计延迟一分钟可接受,Redis 方案完全够用。
第二个坑是定时任务批量写库时,要处理“重复执行”的问题。我建议给统计批次加一个batchId,每次批量执行后把batchId更新到 Redis,任务重启后可以跳过已经写入的批次。
4.5 完整改造后的依赖关系
经过上面三步,核心业务OrderService不再依赖OperationLogService、NotifyService、StatisticService。它只依赖:
- 自己的
OrderRepository ApplicationEventPublisher
新增的三个监听器分别依赖各自的服务。整个模块的依赖方向从“核心指向旁路”反转成“旁路订阅核心”。
我在项目里更喜欢把事件类和监听器组织成这样:
com.example.order ├── api ├── service │ └── OrderService ├── event │ ├── OrderCreatedEvent │ ├── OrderLogListener │ ├── OrderNotifyListener │ └── OrderStatisticListener └── domain每个业务模块自带事件闭环,查找、修改、加新监听器都比较快。如果你把所有事件类都放到一个通用的common.event包,后期事件膨胀后会越来越难维护。
5. 进阶细节:事务同步、失败补偿与幂等
5.1 用 @TransactionalEventListener 对齐事务边界
前面提过@TransactionalEventListener,这应该是异步事件总线里最容易被忽略的注解。它有个属性叫phase,常用的有两个:
TransactionPhase.AFTER_COMMIT:事务提交之后执行。TransactionPhase.BEFORE_COMMIT:事务提交之前执行。
账务类、订单类这种“必须先落库再通知”的场景,基本都应该用AFTER_COMMIT。假设订单保存后发布一个“库存扣减成功”的事件,如果发送时事务还没提交,监听器先去扣减外部库存,主事务后续回滚,外部库存就扣错了。把事件发布挪到AFTER_COMMIT之后,才能保证“主业务成功,才触发后续逻辑”。
有个小细节是:当监听器同时是@TransactionalEventListener(AFTER_COMMIT)且方法上又标注@Async时,执行顺序是“先等事务提交完成,再把方法放进线程池执行”。这个组合在 Spring 中的实现依赖事务同步管理器,不同版本行为会有细微差异,最好在本地写一个测试验证。
5.2 失败补偿:从重试到死信表
异步事件不代表“必定成功”。监听器可能由于数据库故障、第三方接口不稳定等异常一直失败。我总结的补偿策略是分层的:
第一层:内存重试。用 Spring Retry,比如@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))。适合单次瞬时抖动。
第二层:本地重试表。如果内存重试仍失败,记录到event_retry表,由后台定时任务扫描,按固定间隔拉起重试。适合分钟级延迟都可接受的日志、统计。
第三层:死信表。重试超过 N 次的记录进入event_dead_letter表,人工或定时任务去分析修复。
这里要注意:重试可能造成事件被重复消费。如果事件监听逻辑不是幂等的,每次重试都可能产生副作用。最朴素的方案是在消费表里加processed_event_id唯一索引,消费前先尝试插入消费记录,插入成功才执行业务处理。插入失败说明已经处理过,直接跳过。
5.3 事件乱序与最终一致
异步事件总线毕竟不是消息队列,它不保证消息顺序,不保证至少一次投递的语义。尤其在多个监听器共享线程池时,执行顺序更无从谈起。如果你的统计对“事件的先后顺序”有硬要求,比如需要看“创建 - 支付 - 取消”的状态流转,那 Spring 事件机制并不适合承载状态流转,你应该考虑专门的事件流平台。
但在跨服务最终一致性场景里,Spring 事件也能起到“本地事务的补充”作用。一个常见的模式是:在本地事务里,把事件先写入 outbox 表,再通过某种机制把 outbox 行发布到事件总线;后面如果有 MQ,再把这个 outbox 行转发给 MQ。由于 outbox 表和业务数据在同一事务里,可以保证业务成功和事件持久化成功一致。这是“事务性发件箱”的轻量版实现。
6. 常见问题与排查技巧实录
6.1 为什么 @Async 没生效:先查调用路径
比起 Spring 的代理机制,最常见的是自调用。例如在OrderService内部写了一个方法:
@Async public void asyncMethod() { // do something }然后在同一个类里的另一个方法直接this.asyncMethod()。此时this不是代理对象,@Async不生效。解决办法有两个:要么把异步逻辑放到单独的 Spring Bean 里,要么从外部注入这个 Bean 的代理来调用。
另外,@EnableAsync不可缺少。我建议把@EnableAsync放在配置类,而不是随便放到某个业务组件上。
6.2 监听器里抛异常会不会拖垮主流程
默认情况下,事件广播循环会捕获异常并往上抛,所以主线程的publishEvent确实可能受监听器异常影响。我的习惯是:在每个监听器方法内部再做一次异常捕获,使用@Async后异常只会落到异步线程池里,不会污染主线程。
但要小心,异步方法抛出异常后默认会被吞掉,除非配置了AsyncUncaughtExceptionHandler。生产上,我强烈建议注册一个全局异常处理器,把异步异常统一记录到 error log 或告警系统,否则异常会默默消失。
@Configuration public class AsyncExceptionConfig implements AsyncConfigurer { @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> log.error("异步事件执行失败: " + method, ex); } }6.3 同类型事件有多个监听器,执行顺序怎么控制
Spring 支持用@Order注解控制监听器顺序。比如你希望日志监听器最先执行、通知其次、统计最后:
@Async("eventExecutor") @EventListener @Order(1) public void writeLog(OrderCreatedEvent event) { ... }但要注意:在@Async的前提下,@Order只决定任务提交到线程池的顺序,不能保证执行完成的顺序。多个监听器本身应该是互不依赖的。如果它们之间有顺序依赖,应该考虑合并成一个监听器,在内部串行完成。
6.4 排查事件是否发布/消费的通用技巧
事件机制分散在发布方和监听方,线上排查需要一些埋点。我通常会这样做:
- 给所有事件增加一个唯一
eventId,发布端生成。 - 在监听器里把
eventId + listenerName打印成一行 digest log,方便全链路跟踪。 - 用一个定时任务监控事件处理时间,只要发现某个监听器耗时超过 500ms,就告警。
这样避免了出问题就“盲人摸象”。
7. 改造后的收益对比与适用边界
7.1 一个模拟压测数据的参考
给一个我虚构但很典型的对比数据。改造前,下单接口的 TP99 在 800ms 左右,核心业务落库 120ms,日志 80ms,短信通知 350ms,统计更新 100ms,其余是网络与序列化开销。改造后,核心接口只保存订单 + 发布事件,TP99 降到了 180ms 左右。短信通知在异步线程里执行,用户端完全无感。统计从实时查库改成了 Redis 聚合,数据库写入压力也大幅下降。
需要说明的是,异步改造不是把“快”变魔术一样变出来。如果核心业务里本来就有一个必须同步等待的第三方服务,那该等的还是得等。异步能去掉的是那些“旁路逻辑”带来的无意义等待。
7.2 什么时候不应该用它:事件风暴与强一致性
Spring 事件总线适合事件数量不大、消费逻辑集中在一个 JVM 内的单体或微服务内部场景。如果业务已经发展到需要跨服务传递消息,比如订单服务要把创建事件告诉库存服务和积分服务,那就应该把事件发布到 RocketMQ 或 Kafka 这样的消息中间件。
还有一些场景要克制使用事件。如果一段业务逻辑里连续发布几十个事件,监听器之间又有隐藏依赖,代码会重新变得一团糟,这就是“事件风暴”。事件风暴的解药是控制事件数量,一个业务动作最多发布一个领域事件。如果发现还要发很多,先想想是不是领域划分有问题。
事件机制也不适合承载强一致需求。如果“下单成功”和“扣减库存”必须要么都成功,要么都失败,那你需要的是本地事务或者分布式事务,而不是异步事件。异步事件带来的是最终一致性,这是架构取舍。不能既要异步,又要强一致。
7.3 个人体会
最后分享一点我的真实感受:事件总线看起来是一个很小的技术点,但它倒逼你重新想清楚“一个业务动作到底产生了什么事实”。我是在一次压测发现第三方短信超时把整个下单接口打挂之后才认真去做的改造。改完后,代码里的组件关系清爽了很多,新增一个通知渠道时核心服务几乎不动,只需要加一个监听器。这种体感,是单纯把logger.info换成@Async完全给不了的。
如果你正准备做类似改造,我的建议很直接:先别急着上 MQ,从 Spring 自带的事件机制开始。它零成本、易理解、边界清晰,足够解决大部分“核心业务与日志/通知/统计的耦合”问题。等将来出现了跨服务的事件语义,再平滑迁移到消息中间件,那时你会发现自己已经对“发布订阅”这套心智模型很熟了。