今天我们重点聊聊使用 Spring Event 最为关键的几个问题。这是我司线上生产环境实际踩坑后,总结的极为宝贵的经验!
Spring Event框架实现了基于事件的发布订阅机制。开发者可以自定义事件,在某些业务场景发布事件,Spring 会将该事件广播给监听该事件的监听者。监听者可以实现Spring 的监听者接口
ApplicationListener
注册自己,也可以使用
EventListener
注解注册自己。
这是Spring Event 的简短介绍,网上有大量的入门级教程,我在此不过多赘述,进入正文!
Spring 广播消息时,Spring会在
ApplicationContext
中查找所有的监听者,即需要 getBean 获取 bean 实例。然而 Spring 有个限制————
ApplicationContext
关闭期间,不得GetBean 否则会报错。
这个知识点得来不易。它是我们公司在线上环境发生故障后,最终定位的原因,大家一定要重视!
前几天,线上系统出现两条异常日志Get Bean时找不到对应的bean,调用堆栈让我非常迷惑,为什么Get Bean找不到对应的Bean呢? 如下图所示
堆栈中的信息 解释了原因。
Do not request a bean from a BeanFactory in a destroy method implementation
在应用上下文关闭时,不得从上下文中Get Bean。恰好,这个问题出现在服务关闭期间.....
由于系统流量较高,日订单几百万,即便在低峰期单机的并发度也是比较高的,所以服务在关闭期间有少量流量进来或未处理完。这个场景下,使用 Spring Event 发布事件,Spring 无法正常广播事件,一定会出现异常,导致处理失败!
大家一定要切记!使用 SpringEvent 之前,一定要先治理服务,确保服务关闭时,先切断入口流量(Http、MQ、RPC),然后再关闭服务,关闭 Spring 上下文!
详细的分析请参考:
https://juejin.cn/post/7281159113882468371
基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/ruoyi-vue-pro
视频教程:https://doc.iocoder.cn/video/
我们公司遇到的情况是, Kafka conumser 在
init-method
阶段开始消费,然而 Spring EventListener 被注册进 Spring 的时间点滞后于
init-method
时间点,所以 Kafka Consumer 中使用 Spring Event 发布事件时,没有找到监听者,出现消息处理丢失的情况。
从下图中可以看到
init-method
时间点 滞后于 EventListener 被注册的时间点。
简单来说:SpringBoot 会在Spring完全启动完成后,才开启Http流量。这给了我们启示:应该在Spring启动完成后开启入口流量。Rpc和 MQ流量 也应该如此,所以建议大家 在
SmartLifecype
或者
ContextRefreshedEvent
等位置 注册服务,开启流量。
最佳实践是:改造系统开启入口流量(Http、MQ、RPC)的时机,确保在Spring 启动完成后开启入口流量。
每一个优秀的程序员都应该有自己的工具箱,他能在不同的业务场景选择最合适的工具。
SpringEvent 适合哪些业务场景呢?这由订阅发布模式的特性决定
发布订阅模式实现了发布和订阅两个模块的解耦。但是对于强一致性的场景,并不适合使用发布订阅模式。
基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能
项目地址:https://github.com/YunaiV/yudao-cloud
视频教程:https://doc.iocoder.cn/video/
强一致性的业务例如提单场景。提单阶段,库存扣减成功和订单提单成功务必完全一致。库存扣减失败但提单成功;提单失败,库存未回滚等场景都是要避免发生的异常场景!
提单场景,使用 Spring Event会有很多问题。假设提单前,发布提单前置事件,事件订阅者的业务逻辑可能有扣减库存,锁定优惠券资源等操作。库存扣减失败或者锁定资源失败需要回滚整个提单流程,然而 Spring 事件订阅模式无法提供这种 订阅异常——>回滚 的能力。
事件发布者无法获知哪些订阅消费失败,哪些订阅者成功?无法准确的触发回滚流程。(如果基于 Spring Event 强行搞回滚,也可以做到,但方案会很复杂!)
最终一致性场景非常适合使用 Spring Event。
例如提单成功后,发布 MQ ,释放锁等资源,可使用 SpringEvent 解耦。为什么呢?因为业务上确保提单成功后,提单实际上已经成功,后续的收尾工作不应该触发订单提单失败。
在提单成功事件的订阅者中,只有一种执行结果——————成功。即使出现失败,也应该重试直至成功。例如 发布 提单成功MQ 消息,释放提单锁等资源都是务必成功的业务逻辑。
再来举一个例子,我们公司在处理订单消息时使用了Spring Event框架。在这个场景中,我们需要处理履约完成、退款完成、订单过期等事件,并且每个事件都有一些独立的业务逻辑,每一个业务场景都属于最终一致性的场景。举个例子,履约完成后需要将履约数据和订单金额等数据通知结算系统。这个业务场景是最终一致性场景,而不是强一致性,这是因为通知结算即便失败,重试即可,无需回滚履约过程。
如果我们不使用Spring Event,那么我就需要手动编写观察者模式,并将订单消息根据状态通知到相应的观察者中。又或者每当新增一个业务逻辑时,我需要新增一个Kafka消费组,并且在代码中解析订单消息,然后根据状态将事件发送给相应的订阅者。总之我需要把事件按照状态分发给对应的监听者。
在这个场景中,使用Spring Event非常适合。可以将每个事件封装为Spring Event,并且每个业务逻辑都可以通过
@EventListener
注解来注册对应状态的事件监听器(不过需要注意的是,如果订阅者过多,那么Kafka消息的消费时间可能会增加。那么该如何解决呢?)。使用 Spring Event 框架比自己手写监听者模式强多了。
Spring Event适用于需要保证最终一致性的业务场景,但为了确保可靠性,必须提供重试能力。通过使用
applicationContext.publishEvent(event)
方法发布事件,Spring会按顺序执行相关的订阅者。如果出现异常,publishEvent 方法会抛出异常,发布者能够感知订阅逻辑处理失败了。
在发布事件时,需要考虑事件订阅逻辑出现异常的情况,我提出三种解决办法
订阅逻辑可自行重试保证成功。例如使用 Spring retry注解可以保证出现异常时,重新执行该方法。
以下代码示例
performSuccess
方法抛出异常时,Spring 会重新执行该方法直至成功,最多重试 3 次,可设置间隔时间,重试间隔递增时间。
@Retryable (value = Exception.class , maxAttempts = 3 , backoff = @Backoff (delay = 100L , multiplier = 2 ))public void performSuccess (PerformEvent event) { }
使用 @Retryable 注解前,记得引入
spring-retry
pom 依赖