Spring Event 不是万能解耦:从一次停机异常看清它的边界

从停机异常、启动竞态、强一致与最终一致的区别出发,系统说明 Spring Event 的适用边界、重试幂等和优雅停机设计。

Spring Event 不是“轻量版 MQ”,也不是可以随手塞进关键交易链路的万能解耦工具。它真正适合的是同一个 Spring 应用内部、发布者不需要等待订阅者结果、失败可以通过重试或补偿收敛的事件通知。只要业务要求强一致、可回滚,或者服务正处于启动和关闭的边界阶段,就不能只看注解是否能找到监听器,更要把生命周期、入口流量和失败处理一起设计。

一次异常日志暴露的边界

服务关闭期间可能出现这样的故障:请求或未完成的处理仍在继续,业务代码发布事件时,Spring 需要从 ApplicationContext 取得监听器 Bean;但容器已经进入销毁流程,BeanFactory 不允许在 destroy 方法实现中继续请求 Bean,于是出现“Do not request a bean from a BeanFactory in a destroy method implementation”。这个现象并不意味着 @EventListener 平时不可靠,而是说明事件发布和容器生命周期之间存在一个经常被忽视的前提:发布动作必须发生在上下文仍可正常提供基础设施的阶段。

Spring 的事件机制会把事件发布交给 ApplicationEventPublisher,再根据事件类型找到监听方法。官方文档对标准事件、自定义事件和 @EventListener 的说明,重点是应用内的观察者式通知,并没有承诺它具备消息队列的持久化、跨进程投递或重放能力。把它当进程内机制,很多行为就容易理解;把它误当可靠消息系统,事故只是时间问题。

先把三种生命周期状态分开

生产系统里最容易混在一起的是“进程还活着”和“应用还能安全接收并发布事件”。两者不是同一件事。

  • 启动中:容器可能已经创建了部分 Bean,但监听器注册、连接池、消费线程和业务入口未必全部就绪。此时过早消费消息,事件可能没有匹配到监听者,或者订阅者依赖的资源还不可用。
  • 稳定运行:事件发布通常可以按预期完成,但订阅者异常会不会向上抛出、是否会影响主流程,取决于同步/异步配置和错误处理器。
  • 关闭中:应用可能仍收到 HTTP、RPC、MQ 或定时任务触发的业务调用;一旦这些调用继续发布事件,容器销毁与 Bean 查找就可能竞态。

因此,优雅停机不是最后发一个 SIGTERM 就结束。正确顺序通常是先从负载均衡、服务发现、网关和消息消费侧摘除实例,再停止新的入口流量;等待在途请求和消费任务进入可控状态,最后关闭线程池、连接和 Spring 上下文。对于 MQ,还要明确暂停消费、处理完已拉取消息、提交位点或转入重试队列的顺序。

强一致业务不要用事件通知代替事务编排

订单提交、库存扣减、优惠券锁定这类流程,核心问题不是模块之间是否解耦,而是失败时能否准确知道哪些步骤成功,并把整个状态恢复到可接受结果。若把扣库存放进一个普通 Spring Event 监听器,发布者可能无法以简单、清晰的方式表达“监听器失败就回滚提单”。多个监听器还会带来部分成功、执行顺序和异常传播的问题。

更稳妥的做法是把强一致步骤留在明确的应用服务或事务边界内:主流程负责决定成败,数据库事务负责本地原子性;跨服务动作再用可靠消息、事务消息、Outbox 或补偿状态机处理。事件可以用于“订单已成功”之后的通知,但不要用它掩盖提单本身的状态转换。

最终一致场景才是它的舒适区

履约完成后通知结算、订单完成后刷新搜索索引、退款完成后更新报表、业务状态改变后清理缓存,这些动作即使暂时失败,也可以通过重试、死信、人工补偿或定时扫描最终完成,而不必撤销已经确认的主业务结果。此时 Spring Event 能把应用内多个独立动作拆开,避免一个巨大方法不断增加 if/else 和分发分支。

但“最终一致”不等于“失败无所谓”。每个订阅者都应该有自己的成功定义、超时边界、重试策略和告警。同步监听器失败时,publishEvent 可能把异常传回发布者;异步监听器则需要显式配置线程池、错误处理和监控。若事件必须跨进程、跨实例或在进程重启后仍然存在,应该直接使用 MQ 或 Outbox,而不是给进程内事件额外堆出一套不完整的持久化。

重试会放大幂等要求

只要引入重试,就必须假设同一事件会被处理多次。特别是多个监听器共同处理一个事件时,重试可能重新执行已经成功的订阅者。幂等不能只停留在“代码大概不会重复”,而应落到可检查的约束:以事件 ID 或业务单号建立去重记录,数据库写入使用唯一键,外部调用带幂等键,状态转换限定允许的前置状态,重复请求返回同一结果。

@EventListener
public void handle(CheckoutCompleted event) {
    settlementClient.notify(event.orderId());
}

如果使用 Spring Retry,重试次数、退避间隔和可重试异常要按下游能力设置,不能对所有 Exception 无限重试。Kafka 场景则应把消费重试、死信和位点提交策略放在消费者层统一治理,避免监听器内部重试与 Kafka 重试叠加后造成延迟失控。超过自动重试边界后,要进入故障记录、告警和人工重放流程。

启动与关闭验收清单

  • 启动阶段先确认监听器、线程池、数据库和外部客户端全部就绪,再开放 HTTP、RPC 和 MQ 入口;不要让 init-method 中的消费线程抢在应用完成刷新前开始处理业务。
  • 关闭阶段先摘流,再停消费,再等待在途任务,最后关闭 ApplicationContext;将每个阶段的时间预算和超时动作写入运行手册。
  • 为事件增加唯一 ID、业务键、生产时间、重试次数和来源信息,日志中同时记录发布、开始消费、成功、失败和最终处置。
  • 明确同步事件是否允许阻塞主请求,异步事件失败由谁接管;任何“失败后再说”的订阅者都不应直接进入生产。
  • 对重复投递、监听器部分成功、应用启动时早到消息、关闭阶段残留请求做集成测试,而不是只测一条正常路径。

判断 Spring Event 是否合适,先问一句:主业务结果已经确定了吗?如果没有,使用显式的事务流程;如果已经确定,后续动作可以重试收敛,且只在当前进程内解耦,Spring Event 才是合适的轻量工具。它的价值在于减少应用内耦合,不在于替代可靠消息基础设施。相关机制可直接查阅 Spring 官方事件文档;故障案例的完整上下文见 掘金案例