Spring Event 事件订阅踩坑


Spring Event 事件订阅框架,被网上一些人快吹上天了,然而我们在新项目中引入后发现,这个框架缺陷很多,玩玩可以,千万不要再公司项目中使用。还不如自己手写一个监听者设计模式,那样更稳定、可靠。

之前我已经被 Spring Event(事件发布订阅组件)坑过一次。那次是在服务关闭期间,有请求未处理完成,当调用 Spring Event 时,出现异常。

根源是:Spring 关闭期间,不得调用 GetBean,也就是无法使用 Spring Event 。详情点击这里查看

然而新项目大量使用了 Spring Event,在另一个 Task 服务还未来得及移除 Spring Event 的情况下,出现了类似的问题。

当领导听说新引入的 Spring Event 再次出现问题时,非常愤怒,因为一个月内出现了两次故障。在复盘会议上,差点爆粗口。

在上线过程中,丢消息了?

“五哥,你看一眼钉钉给你发的监控截图,线上好像有丢消息?” 旁边同事急匆匆的跟我说。

“线上有问题?强哥在上线,我让他先暂停下”,于是我赶紧通知强哥,先暂停发布。优先排查线上问题

怎么会有问题呢?我有点意外,正好我和强哥各有代码上线,我只改动很小一段代码。我对这次代码变更很自信,坚信不会有问题,所以我并没有慌乱和紧张。搁之前,我的小心脏早就怦怦跳了!

诡异的情况

出现问题的业务逻辑是 消费 A 消息,经过业务处理后,再发送 B 消息。

invalid image (图片无法加载)

从线上监控和日志分析,Task 服务收到了 A 消息,然后处理失败了。诡异之处是没有任何异常日志和异常打点,仿佛凭空消失了。

分析代码分支后,我和同事十分确信,任何异常退出的代码分支都有打印异常日志和上报异常监控打点,出现异常不可能不留一丝痕迹。

正当陷入困境之时,我们发现蹊跷之处。“丢消息”的时间只有 3 秒钟,之后便恢复正常。问题出在启动阶段,消息 A 进入 Task 服务,服务还未完全发布完成时,导致不可预测的情况发生。

当分析 Spring 源代码以后,我们发现原因出在 Spring Event……

在详细说明问题根源前,我简单介绍一下 SpringEvent 使用,熟悉它的读者,可以自行跳过。

Spring Event 的简单使用

声明事件

自定义事件需要继承 Spring ApplicationEvent。我选择使用泛型,支持子类可以灵活关联事件的内容。

invalid image (图片无法加载)

发布事件

使用 Spring 上下文 ApplicationContext 发布事件

invalid image (图片无法加载)

监听事件

监听器只需要 在方法上声明为 EventListener 注解,Spring 就会自动找到对应的监听器。Spring 会根据方法入参的事件类型和 发布的事件类型 自动匹配。

invalid image (图片无法加载)

服务启动阶段,Spring Event 注册严重滞后

在 Kafka 消费逻辑中,通过 Spring Event 发布事件,业务逻辑都封装在 Event Listenr 中。经过分析和验证后,我们终于发现问题所在。

当 Kafka 消费者已经开始消费消息,但 Spring Event 监听者还没有注册到 Spring ApplicationContext 中, 所以 Spring Event 事件发布后,没有 Event Listener 消费该事件。3 秒钟以后,Event Listener 被注册到 Spring 后,异常就消失了。

问题根源在:Event Listener 注册的时间点滞后于 init-method 的时间点!

invalid image (图片无法加载)

init-method ——— Kafka 开始监听的时间点

Kafka 消费者的启动点 在 Spring init-method 中,例如下面的 XML 中,init-method 声明 HelloConsumer 的初始化方法为 init 方法。在该方法中注册到 Kafka 中,抢占分片,开始消费消息。

如果在 init-method 方法中,成功注册到 Kafka,抢占到分片,然而 Spring Event Listener 还未注册到 Spring ,就会 “Spring 事件丢失” 的现象。

EventListener 注册到 Spring 的时间点

在 Spring 的启动过程中,EventListener 的启动点滞后于 init-method 。如下图 Spring 的启动顺序所示。

其中 init-method 在 InitializingBean 中被触发,而 EventListener 在 SmartInitializingSingleton 中初始化。由于启动顺序的先后关系,当 init-method 的执行时间较长时(例如连接超时),就会出现 Kafka 已开始消费,但 EventListener 还未注册的问题。

Spring 启动顺序

invalid image (图片无法加载)

InitializingBean 的初始化代码

通过分析 Spring 源代码。InitializingBean 阶段, invokeInitMethod 会执行 init-method 方法,Kafka 消费者就是在 init-method 执行完成后开始消费 kafka 消息。

invalid image (图片无法加载)

SmartInitializingSingleton

继续分析 Spring 源代码。 EventListenerMethodProcessor 是 SmartInitializingSingleton 子类,该类负责解析 Spring 中所有的 Bean,如果有方法添加 EventListener 注解,则将 EventListener 方法 注册到 Spring 中

以下是代码截图

invalid image (图片无法加载)

Spring Event 很好,我劝你别用

通过代码分析可以发现,在 Spring 中,init-method 方法会先执行,然后才会解析和注册 Event Listener。因此,在消费 Kafka 和注册 EventListener 之间存在一个时间间隔,如果在这期间发布了 Spring Event,该事件将无法被消费。

通常情况下,这个时间间隔非常短暂,但是当 init-method 执行较慢时,比如 Kafka 消费者 A 初始化很快,但是 Kafka 消费者 B 建立连接超时导致 init-method 执行时间较长,就会出现问题。在这段时间内,Kafka 消费者 A 发布的 Spring 事件无法被消费。

尽管这不是一个稳定必现的问题,但是当线上流量较大时,它发生的概率会增加,后果也会更严重。我们在上线 3 个月后,线上环境才首次遇到这个问题。

在《服务关闭期,Spring Event 消费失败》这篇文章中,有读者评论提到了这个问题。

invalid image (图片无法加载)

有朋友说: 这和 spring event 有什么关系,自己实现一套,不也有同样的问题吗?关键是得优雅停机啊!

他所说的是正确的,如果服务能够完美地进行优雅发布,即使是在大流量场景下,Spring Event 也不会出现问题。

然而,要确保服务的优雅发布是非常困难的,尤其是公司的项目,很多框架和组件都是基础架构组提供的,与其费力去改造公司的项目,不如不使用 Spring Event。

一般情况下,公司的项目通常会在 init-method 方法中,统一初始化消息队列 MQ 消费者。如果想要安全地使用 Spring Event,必须等到 Spring 完全发布完成之后才能初始化 Kafka 消费者。与其按照 Spring Event 的要求改造公司的项目,还不如不使用 Spring Event。

对于公司的项目来说,稳定性是最重要的。

与其冒着巨大的风险使用 Spring Event,不如自己手写事件监听模式,这样反而简易、安全、可靠。


LazzMan 2023年11月24日 16:02 收藏文档