
下单接口写完后紧接着在同一个方法里写日志、发短信、更新统计这样的代码我写了太多年。一开始觉得没什么等接口多了、第三方通道不稳定、统计报表越来越复杂这堆“旁路逻辑”就把核心业务死死绑住了。今天想聊的是我在 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用来承载业务事实的字段。监听器实现ApplicationListenerT接口或者用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 发布事件的入口统一封装发布事件不需要把发布器暴露到所有业务代码里。我通常会把发布动作包在一个领域服务中或者直接注入ApplicationEventPublisherService 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。它只依赖自己的OrderRepositoryApplicationEventPublisher新增的三个监听器分别依赖各自的服务。整个模块的依赖方向从“核心指向旁路”反转成“旁路订阅核心”。我在项目里更喜欢把事件类和监听器组织成这样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 自带的事件机制开始。它零成本、易理解、边界清晰足够解决大部分“核心业务与日志/通知/统计的耦合”问题。等将来出现了跨服务的事件语义再平滑迁移到消息中间件那时你会发现自己已经对“发布订阅”这套心智模型很熟了。