找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

683

积分

0

好友

89

主题
发表于 9 小时前 | 查看: 0| 回复: 0

这是我司线上生产环境实际踩坑后,总结的极为宝贵的经验!

Spring Event框架实现了基于事件的发布订阅机制。开发者可以自定义事件,在某些业务场景发布事件,Spring 会将该事件广播给监听该事件的监听者。监听者可以实现Spring 的监听者接口 ApplicationListener 注册自己,也可以使用 EventListener 注解注册自己。

网上有大量入门级教程,本文不过多赘述,我们将直接切入生产实践中总结出的核心经验与最佳实践。

为什么说:业务系统一定要先实现优雅关闭服务,才能使用 Spring Event?

Spring 广播消息时,会先在 ApplicationContext 中查找所有的监听者,这需要执行 getBean 操作来获取 bean 实例。然而 Spring 有一个关键限制:在 ApplicationContext 关闭期间,不允许执行 getBean,否则会抛出异常。

这个知识点的获得代价高昂。它源于我们公司线上环境发生的一次真实故障,最终定位的原因就是此问题,希望大家务必重视!

前几天,线上系统出现了获取Bean失败的异常日志,调用堆栈令人困惑:为什么 getBean 会找不到对应的 Bean 呢?如下图所示。

Spring Bean创建失败异常堆栈截图

堆栈中的信息清晰地解释了原因:
Do not request a bean from a BeanFactory in a destroy method implementation

也就是说,在应用上下文关闭时,不得从上下文中获取 Bean。这个问题恰好出现在服务关闭期间。由于系统流量较高,日订单量达数百万,即使在低峰期单机并发度也不低,因此服务在关闭期间仍有少量流量进入或未处理完。

在这种情况下,如果使用 Spring Event 发布事件,Spring 将无法正常广播事件,必然抛出异常,最终导致业务处理失败!

切记! 在使用 Spring Event 之前,必须先治理服务,确保服务关闭时,能够先切断入口流量(如 HTTP、MQ、RPC 请求),然后再执行服务关闭和 Spring 上下文的销毁流程。

为什么服务启动阶段,Spring Event 事件会丢失?

我们公司遇到的情况是,Kafka Consumer 在 init-method 阶段就开始消费消息,但 Spring 的 EventListener 被注册进容器的时间点,却滞后于 init-method 的执行时间点。这导致 Kafka Consumer 在启动初期使用 Spring Event 发布事件时,根本找不到对应的监听者,从而造成消息处理丢失。

从下图中可以清晰地看到 init-method 的执行时间点与 EventListener 注册时间点的先后关系。

Spring Bean生命周期及EventListener注册时机示意图

简单来说:SpringBoot 会在容器完全启动完成后,才开启 HTTP 流量。这给了我们一个重要启示:应该在 Spring 启动完成后,再开启所有入口流量。RPC 和 MQ 的消费启动也应遵循此原则。因此,建议大家在 SmartLifecycle 或监听 ContextRefreshedEvent 等标志 Spring 启动完成的时机,再去注册服务、开启流量。

最佳实践是:改造系统,确保在 Spring 容器完全启动完成后,再开启所有入口流量(Http、MQ、RPC)。

什么业务场景适合发布-订阅模式?

每一位优秀的程序员都应该有自己的工具箱,能够针对不同的业务场景选择最合适的工具。

Spring Event 适合哪些业务场景呢?这由发布-订阅模式的核心特性决定:

  • 事件发布者并不关心事件如何被处理。
  • 事件发布者不关心事件处理的具体结果。
  • 事件订阅者可以有多个,可以异步订阅,也可以同步订阅。
  • 各个事件订阅者之间相互独立,互不依赖。

发布-订阅模式实现了发布方和订阅方之间的解耦。但是,它并不适合强一致性的业务场景。

强一致性场景不适合发布-订阅模式

强一致性的业务,例如电商提单场景。在提单阶段,库存扣减成功和订单创建成功必须保持完全一致。诸如“库存扣减失败但订单却创建成功”,或“订单创建失败但库存未回滚”等情况,都是必须避免的异常场景。

在提单场景中使用 Spring Event 会引入诸多问题。假设在提单前发布一个“前置事件”,事件的订阅者可能包含扣减库存、锁定优惠券等业务逻辑。如果库存扣减失败或资源锁定失败,整个提单流程都需要回滚。然而,Spring 的事件订阅模式无法提供这种“订阅者异常 -> 触发全局回滚”的能力。

事件发布者无法准确获知哪些订阅者消费失败,哪些成功了,因此也就无法精确地触发回滚流程。(当然,基于 Spring Event 强行实现回滚也是可能的,但方案会变得异常复杂且不优雅。)

最终一致性的业务非常适合发布-订阅模式

最终一致性场景则非常适合使用 Spring Event。

例如,在订单提单成功后,需要发送 MQ 消息、释放分布式锁等收尾工作,就可以使用 Spring Event 进行解耦。为什么可以呢?因为从业务上确保提单成功后,核心交易流程实际上已经完成,后续的收尾工作不应导致订单提单失败。

对于“提单成功事件”的订阅者而言,其执行结果只有一种——成功。即使出现临时失败,也应该通过重试机制确保最终成功。例如,发送提单成功的 MQ 消息、释放提单锁等,都是必须成功的业务逻辑。

再举一个我们公司的例子。在处理订单状态变更消息时,我们使用了 Spring Event 框架。这个场景涉及履约完成、退款完成、订单过期等多种事件,每个事件都关联一些独立的业务逻辑,并且都属于最终一致性场景。例如,履约完成后,需要将履约数据和订单金额等信息通知给结算系统。这个“通知”动作是最终一致性的,因为即便通知失败,也可以通过重试来解决,而无需回滚已经完成的履约过程。

如果不使用 Spring Event,我们就需要手动编写观察者模式,根据订单状态将消息分发给不同的观察者。或者,每新增一个业务逻辑,就新增一个 Kafka 消费组,并在代码中解析消息、根据状态路由事件。总之,我们需要自己实现事件的分发机制。

在这个场景下,使用 Spring Event 就非常合适。可以将每个状态变更封装为一个 Spring Event,每个业务逻辑则通过 @EventListener 注解注册为对应事件的监听器(需要注意:如果订阅者过多,可能会拉长单条 Kafka 消息的总处理时间,需要考虑异步或并行处理)。使用 Spring Event 框架比自己手写监听者模式要优雅和高效得多。

使用 Spring Event 必须具备额外的可靠性保证机制

Spring Event 适用于最终一致性场景,但为了确保可靠性,必须为其提供重试能力。通过 applicationContext.publishEvent(event) 发布事件时,Spring 会按顺序同步执行相关的订阅者。如果某个订阅者抛出异常,publishEvent 方法会向上抛出这个异常,使得事件发布者能够感知到订阅逻辑处理失败。

在发布事件时,我们必须考虑事件订阅逻辑出现异常的应对策略,这里有三种常见的解决办法:

1. 订阅者自行重试
订阅逻辑内部可以通过重试来保证最终成功。例如,使用 Spring Retry 的 @Retryable 注解,可以在方法抛出异常时自动重试。

以下代码示例中,当 performSuccess 方法抛出异常时,Spring 会重新执行该方法直至成功,最多重试 3 次,并且重试间隔会按规则递增。

@Retryable(value = Exception.class, maxAttempts = 3, backoff = @Backoff(delay = 100L, multiplier = 2))
public void performSuccess(PerformEvent event){
    // 业务逻辑
}

使用 @Retryable 注解前,需要引入 spring-retry 依赖。

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
    <version>1.2.4.RELEASE</version>
</dependency>

2. 订阅者依赖 Kafka 消费组重试
如果在 Kafka 消费者中使用 Spring Event,处理重试会非常容易。只需要在消费逻辑抛出异常时,让 Kafka 客户端返回消费失败即可,Kafka 会自动进行重试。此外,还可以将多次重试失败的消息发送到专门的死信队列(DLQ)进行后续处理。不同公司的 Kafka 重试方案可能不同,可根据实际情况选择。

3. 主动上报故障异常到故障管理平台
当业务重试超过最大次数仍然失败时,可以将故障信息上报到一个统一的故障管理平台。该平台消费故障消息并落库,为研发人员提供可视化的故障列表和详情页面。研发人员排查并修复问题后,可以在管理后台手动触发重试。管理后台通过预定义的 RPC SPI 接口调用业务系统执行重试,并反馈结果。

下图展示了一种故障可视化管理的系统架构思路:

故障可视化管理系统架构图

Spring Event 的订阅者逻辑务必保证幂等性

为了提高可靠性,我们需要引入额外的重试机制。而有重试,就必须有幂等

必须保证订阅者的业务逻辑具备幂等性。因为 Spring 本身并不跟踪哪些订阅者执行成功或失败。下一次重试(无论是应用内重试还是通过上游MQ重试导致的事件重新发布)时,Spring 会再次执行所有的订阅者。如果订阅逻辑不是幂等的,就可能导致数据重复处理,引发数据不一致的问题。

为什么有了消息队列 MQ,还需要 Spring Event?

曾经有读者评论,认为我们使用 Spring Event 的场景应该用 MQ 替代。这里需要解释一下:

Spring Event 和 MQ 都采用了发布-订阅模式,但 MQ 的能力更强大,架构也更“重”。MQ 更适合于应用(服务)之间的解耦、隔离和事件通知。例如,订单支付成功、订单完成等需要广播给下游多个微服务的事件,就非常适合使用 MQ。

然而,对于单个应用内部需要解耦的业务逻辑,Spring Event 是更轻量、更合适的选择。两者并不矛盾,它们是不同层次上的解耦工具。Spring Event 更加小巧灵活,适合在应用内实现模块间的松耦合,是 Java 开发者工具箱中一个非常实用的组件。

希望这些从真实生产教训中总结出的经验,能帮助你在使用 Spring Event 时少走弯路。如果你有更多关于事件驱动或分布式架构的实践经验,欢迎在 云栈社区 交流分享。




上一篇:Spring Boot 实用指南:49个核心工具类API解析与代码示例
下一篇:Three.js Skills发布:借助AI编程技能包,规避Three.js常见开发陷阱
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-25 16:48 , Processed in 0.260458 second(s), 42 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表