Springboot程序启动慢及JVM上的随机数与熵池策略-/dev/random

问题描述

线上环境中很容易出现一个java应用启动非常耗时的情况,在日志中可以发现是session引起的随机数问题导致的

o.a.c.util.SessionIdGeneratorBase        : Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [170,241] milliseconds.
  • 1.

分析

在Springboot程序中有内置的tomcat,在tomcat给的优化文档中,有一项是关于随机数生成时,采用的“熵源”(entropy source)的策略。

他提到tomcat7的session id的生成主要通过java.security.SecureRandom生成随机数来实现,随机数算法使用的是”SHA1PRNG”

private String secureRandomAlgorithm = "SHA1PRNG";
  • 1.

在sun/oracle的jdk里,这个算法的提供者在底层依赖到操作系统提供的随机数据,在linux上,与之相关的是/dev/random和/dev/urandom。区别为:

/dev/random是阻塞的发生器
在读取时,/dev/random设备会返回小于熵池噪声总数的随机字节。/dev/random可生成高随机性的公钥或一次性密码本。若熵池空了,对/dev/random的读操作将会被阻塞,直到收集到了足够的环境噪声为止

而 /dev/urandom 则是一个非阻塞的发生器:
dev/random的一个副本是/dev/urandom (”unlocked”,非阻塞的随机数发生器),它会重复使用熵池中的数据以产生伪随机数据。这表示对/dev/urandom的读取操作不会产生阻塞,但其输出的熵可能小于/dev/random的。它可以作为生成较低强度密码的伪随机数生成器,不建议用于生成高强度长期密码。

这也并不是说明/dev/urandom不是做高强度的伪随机数生成器,这个讨论可以看这个讨论:/dev/urandom 不得不说的故事

解决方法

方法一

在 jre/lib/security/java.security 这个文件里面把

securerandom.source=file:/dev/random

改为

securerandom.source=file:/dev/./urandom

方法二

在启动参数中添加以下系统属性

-Djava.security.egd=file:/dev/./urandom
  • 1.

这个系统属性egd表示熵收集守护进程(entropy gathering daemon),但这里值为何要在dev和random之间加一个点呢?是因为一个jdk的bug,在这个bug的连接里有人反馈及时对 securerandom.source 设置为 /dev/urandom 它也仍然使用的 /dev/random,有人提供了变通的解决方法,其中一个变通的做法是对securerandom.source设置为 /dev/./urandom 才行

多说一嘴

在Docker中如何添加系统参数呢

首先在build镜像时 要使用ENTRYPOINT 举个例子

FROM jdk:alpine-security8
WORKDIR /

#解决中文乱码问题
ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

ADD sms-server.jar sms-server.jar
ADD application.properties application.properties
ADD bootstrap.properties bootstrap.properties
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
##使用如下的命令  添加-e JAVA_OPT是不起作用的
#ENTRYPOINT ["java","-jar","sms-server.jar"]
##要使用这条命令才行
ENTRYPOINT java ${JAVA_OPTS} -jar sms-server.jar
  •  

然后在启动命令中添加对应的-e参数就可以了 举个例子

docker run --name sms-server-security \
## 如下即可
-e JAVA_OPTS='-Djava.security.egd=file:/dev/./urandom' \
-e spring.cloud.nacos.discovery.server-addr=192.169.1.82:8848 \
-e spring.cloud.nacos.config.server-addr=192.169.1.82:8848 \
-e spring.cloud.nacos.config.ext-config[0].data-id=sms-server-node1.properties \
-p 8070:8090  \
-v /opt/sms_server/log:/log \
-v /opt/sms_server/nacos:/root/nacos \
-d \
3a1c93c34756

Timeout重构 (2).jpg

测试对Spring启动原理的理解程度

我举个例子,测试一下,你对Spring启动原理的理解程度。

  1. Rpc框架和Spring的集成问题。Rpc框架何时注册暴露服务,在哪个Spring扩展点注册呢?init-method 中行不行?

  2. MQ 消费组和Spring的集成问题。MQ消费者何时开始消费,在哪个Spring扩展点”注册“自己?init-method 中行不行?

  3. SpringBoot 集成Tomcat问题。如果出现已开启Http流量,Spring还未启动完成,怎么办?Tomcat何时开启端口,对外服务?

SpringBoot项目常见的流量入口无外乎 Rpc、Http、MQ 三种方式。一名合格的架构师必须精通服务的入口流量何时开启,如何正确开启?最近我遇到的两次线上故障都和Spring启动过程相关。(点击这里了解故障

故障的具体表现是:Kafka消费组已经开始消费,已开启流量,然而Spring 还未启动完成。因为业务代码中使用的Spring Event事件订阅组件还未启动(订阅者还未注册到Spring),所以处理异常,出了线上故障。根本原因是————项目在错误的时机开启 MQ 流量,然而Spring还未启动完成,导致出现故障。

正确的做法是:项目在Spring启动完成后开启入口流量,然而我司的Kafka消费组 在Spring init-method bean 实例化阶段就开启了流量,导致故障发生。出现这样的问题,说明项目初期的程序员没有深入理解Spring的启动原理。

接下来,我再次抛出 11 个问题,说明这个问题————深入理解Spring启动原理的重要性。

  1. Spring还未完全启动,在 PostConstruct 中调用 getBeanByAnnotation 能否获得准确的结果?
  2. 项目应该如何监听 Spring 的启动就绪事件?
  3. 项目如何监听Spring 刷新事件?
  4. Spring就绪事件和刷新事件的执行顺序和区别?
  5. Http 流量入口何时启动完成?
  6. 项目中在 init-method 方法中注册 Rpc 是否合理?什么是合理的时机?
  7. 项目中在 init-method 方法中注册 MQ 消费组是否合理? 什么是合理的时机?
  8. PostConstruct 中方法依赖ApplicationContextAware拿到 ApplicationContext,两者的顺序谁先谁后?是否会出现空指针!
  9. init-method、PostConstruct、afterPropertiesSet 三个方法的执行顺序?
  10. 有两个 Bean声明了初始化方法。 A使用 PostConstruct注解声明,B使用 init-method 声明。Spring一定先执行 A 的PostConstruct 方法吗?
  11. Spring 何时装配Autowire属性,PostConstruct 方法中引用 Autowired 字段什么场景会空指针?

精通Spring 启动原理,以上问题则迎刃而解。接下来,请大家和五哥,一起学习Spring的启动原理,看看Spring的扩展点分别在何时执行。

一起数数 Spring启动过程的扩展点有几个?

Spring的扩展点极多,这里为了讲清楚启动原理,所以只列举和启动过程有关的扩展点。

  1. BeanFactoryAware 可在Bean 中获取 BeanFactory 实例
  2. ApplicationContextAware 可在Bean 中获取 ApplicationContext 实例
  3. BeanNameAware 可以在Bean中得到它在IOC容器中的Bean的实例的名字。
  4. ApplicationListener 可监听 ContextRefreshedEvent等。
  5. CommandLineRunner 整个项目启动完毕后,自动执行
  6. SmartLifecycle#start 在Spring Bean实例化完成后,执行start 方法。
  7. 使用@PostConstruct注解,用于Bean实例初始化
  8. 实现InitializingBean接口,用于Bean实例初始化
  9. xml 中声明 init-method 方法,用于Bean实例初始化
  10. Configuration 配置类 通过@Bean注解 注册Bean到Spring
  11. BeanPostProcessor 在Bean的初始化前后,植入扩展点!
  12. BeanFactoryPostProcessor 在BeanFactory创建后植入 扩展点!

通过打印日志学习Spring的执行顺序

首先我们先通过 代码实验,验证一下以上扩展点的执行顺序。

  1. 声明 TestSpringOrder 分别继承以下接口,并且在接口方法实现中,日志打印该接口的名称。
 
Java
复制代码
public class TestSpringOrder implements
      ApplicationContextAware,
      BeanFactoryAware, 
      InitializingBean, 
      SmartLifecycle, 
      BeanNameAware, 
      ApplicationListener<ContextRefreshedEvent>, 
      CommandLineRunner,
      SmartInitializingSingleton {
 
Java
复制代码
@Override
public void afterPropertiesSet() throws Exception {
   log.error("启动顺序:afterPropertiesSet");
}

@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
   log.error("启动顺序:setApplicationContext");
}
  1. TestSpringOrder 使用 PostConstruct注解初始化,声明 init-method方法初始化。
 
Java
复制代码
@PostConstruct
public void postConstruct() {
   log.error("启动顺序:post-construct");
}

public void initMethod() {
   log.error("启动顺序:init-method");
}
  1. 新建 TestSpringOrder2 继承
 
Java
复制代码
public class TestSpringOrder3 implements
         BeanPostProcessor, 
         BeanFactoryPostProcessor {
   @Override
   public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
      log.error("启动顺序:BeanPostProcessor postProcessBeforeInitialization beanName:{}", beanName);
      return bean;
   }

   @Override
   public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
      log.error("启动顺序:BeanPostProcessor postProcessAfterInitialization beanName:{}", beanName);
      return bean;
   }

   @Override
   public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
      log.error("启动顺序:BeanFactoryPostProcessor postProcessBeanFactory ");
   }
}

执行以上代码后,可以在日志中看到启动顺序!

实际的执行顺序

 
log
复制代码
2023-11-25 18:10:53,748 [main] ERROR (TestSpringOrder3:37) - 启动顺序:BeanFactoryPostProcessor postProcessBeanFactory 
2023-11-25 18:10:59,299 [main] ERROR (TestSpringOrder:53) - 启动顺序:构造函数 TestSpringOrder
2023-11-25 18:10:59,316 [main] ERROR (TestSpringOrder:127) - 启动顺序: Autowired
2023-11-25 18:10:59,316 [main] ERROR (TestSpringOrder:129) - 启动顺序:setBeanName
2023-11-25 18:10:59,316 [main] ERROR (TestSpringOrder:111) - 启动顺序:setBeanFactory
2023-11-25 18:10:59,316 [main] ERROR (TestSpringOrder:121) - 启动顺序:setApplicationContext
2023-11-25 18:10:59,316 [main] ERROR (TestSpringOrder3:25) - 启动顺序:BeanPostProcessor postProcessBeforeInitialization beanName:testSpringOrder
2023-11-25 18:10:59,316 [main] ERROR (TestSpringOrder:63) - 启动顺序:post-construct
2023-11-25 18:10:59,317 [main] ERROR (TestSpringOrder:116) - 启动顺序:afterPropertiesSet
2023-11-25 18:10:59,317 [main] ERROR (TestSpringOrder:46) - 启动顺序:init-method
2023-11-25 18:10:59,320 [main] ERROR (TestSpringOrder3:31) - 启动顺序:BeanPostProcessor postProcessAfterInitialization beanName:testSpringOrder
2023-11-25 18:17:21,563 [main] ERROR (SpringOrderConfiguartion:21) - 启动顺序: @Bean 注解方法执行
2023-11-25 18:17:21,668 [main] ERROR (TestSpringOrder:58) - 启动顺序:SmartInitializingSingleton
2023-11-25 18:17:21,675 [main] ERROR (TestSpringOrder:74) - 启动顺序:start
2023-11-25 18:17:23,508 [main] ERROR (TestSpringOrder:68) - 启动顺序:ContextRefreshedEvent
2023-11-25 18:17:23,574 [main] ERROR (TestSpringOrder:79) - 启动顺序:CommandLineRunner

我通过在以上扩展点 添加 debug 断点,调试代码,整理出 Spring启动原理的 长图。过程省略…………

一张长图透彻解释 Spring启动顺序

Timeout重构 (2).jpg

实例化和初始化的区别

new TestSpringOrder(); new 创建对象实例,即为实例化一个对象;执行该Bean的 init-method 等方法 为初始化一个Bean。注意初始化和实例化的区别。

Spring 重要扩展点的启动顺序

  1. BeanFactoryPostProcessor
    BeanFactory初始化之后,所有的Bean定义已经被加载,但Bean实例还没被创建(不包括BeanFactoryPostProcessor类型)。Spring IoC容器允许BeanFactoryPostProcessor读取配置元数据,修改bean的定义,Bean的属性值等。

  2. 实例化Bean

    Spring 调用java反射API 实例化 Bean。 等同于 new TestSpringOrder();

  3. Autowired 装配依赖

    Autowired是 借助于 AutowiredAnnotationBeanPostProcessor 解析 Bean 的依赖,装配依赖。如果被依赖的Bean还未初始化,则先初始化 被依赖的Bean。在 Bean实例化完成后,Spring将首先装配Bean依赖的属性。

  4. BeanNameAware setBeanName

  5. BeanFactoryAware setBeanFactory

  6. ApplicationContextAware setApplicationContext

    在Bean实例化前,会率先设置Aware接口,例如 BeanNameAware BeanFactoryAware ApplicationContextAware 等

  7. BeanPostProcessor postProcessBeforeInitialization

    如果我想在 bean初始化方法前后要添加一些自己逻辑处理。可以提供 BeanPostProcessor接口实现类,然后注册到Spring IoC容器中。在此接口中,可以创建Bean的代理,甚至替换这个Bean。

  8. PostConstruct 执行

    接下来 Spring会依次调用 Bean实例初始化的 三大方法。

  9. InitializingBean afterPropertiesSet

  10. init-method 方法执行

  11. BeanPostProcessor postProcessAfterInitialization

    在 Spring 对Bean的初始化方法执行完成后,执行该方法

  12. 其他Bean 实例化和初始化

    Spring 会循环初始化Bean。直至所有的单例Bean都完成初始化

  13. 所有单例Bean 初始化完成后

  14. SmartInitializingSingleton Bean实例化后置处理

    该接口的执行时机在 所有的单例Bean执行完成后。例如Spring 事件订阅机制的 EventListener注解,所有的订阅者 都是 在这个位置被注册进 Spring的。而在此之前,Spring Event订阅机制还未初始化完成。所以如果有 MQ、Rpc 入口流量在此之前开启,Spring Event就可能出问题!

    所以强烈建议 Http、MQ、Rpc 入口流量在 SmartInitializingSingleton 之后开启流量。

Http、MQ、Rpc 入口流量必须在 SmartInitializingSingleton 之后开启流量。

  1. SmartLifecyle smart start 方法执行 Spring 提供的扩展点,在所有单例Bean的 EventListener等组件全部启动完成后,即Spring启动完成,则执行 start 方法。在这个位置适合开启入口流量!

Http、MQ、Rpc 入口流量适合 在 SmartLifecyle 中开启

  1. 发布 ContextRefreshedEvent 方法 该事件会执行多次,在 Spring Refresh 执行完成后,就会发布该事件!

  2. 注册和初始化 Spring MVC

    SpringBoot 应用,在父级 Spring启动完成后,会尝试启动 内嵌式 tomcat容器。在此之前,SpringBoot会初始化 SpringMVC 和注册DispatcherServlet到Web容器。

  3. Tomcat/Jetty 容器开启端口

    SpringBoot 调用内嵌式容器,会开启并监听端口,此时Http流量就开启了。

  4. 应用启动完成后,执行 CommandLineRunner

    SpringBoot 特有的机制,待所有的完全执行完成后,会执行该接口 run方法。值得一提的是,由于此时Http流量已经开启,如果此时进行本地缓存初始化、预热缓存等,稍微有些晚了! 在这个间隔期,可能缓存还未就绪!

    所以预热缓存的时机应该发生在 入口流量开启之前,比较合适的机会是在 Bean初始化的阶段。虽然 在Bean初始化时 Spring尚未完成启动,但是调用 Bean预热缓存也是可以的。但是注意:不要在 Bean初始化时 使用 Spring Event,因为它还未完成初始化 。

回答 关于 Spring 启动原理的若干问题

  1. init-method、PostConstruct、afterPropertiesSet 三个方法的执行顺序。

回答: PostConstruct,afterPropertiesSet,init-method

  1. 有两个 Bean声明了初始化方法。 A使用 PostConstruct注解声明,B使用 init-method 声明。Spring一定先执行 A 的PostConstruct 方法吗?

回答:Spring 会循环初始化Bean实例,初始化完成1个Bean,再初始化下一个Bean。Spring并没有使用这种机制启动,即所有的Bean先执行 PostConstruct,再统一执行afterProperfiesSet。 此外,A、B两个Bean的初始化顺序不确定,谁先谁后不确定。无法保证 A 的PostConstruct 一定先执行。除非使用 Order注解,声明Bean的初始化顺序!

  1. Spring 何时装配Autowire属性,PostConstruct方法中引用 Autowired 字段是否会空指针?

Autowired装配依赖发生在 PostConstruct之前,不会出现空指针!

  1. PostConstruct 中方法依赖ApplicationContextAware拿到 ApplicationContext,两者的顺序谁先谁后?是否会出现空指针!

ApplicationContextAware 会先执行,不会出现空指针!但是当Autowired没有找到对应的依赖,并且声明了非强制依赖时,该字段会为空,有潜在 空指针风险。

  1. 项目应该如何监听 Spring 的启动就绪事件。

通过SmartLifecyle start方法,监听Spring就绪 。适合在此开启入口流量!

  1. 项目如何监听Spring 刷新事件。

监听 Spring Event ContextRefreshedEvent

  1. Spring就绪事件和刷新事件的执行顺序和区别。

Spring就绪事件会先于 刷新事件。两者都可能多次执行,要确保方法的幂等处理,避免重复注册问题

  1. Http 流量入口何时启动完成。

SpringBoot 最后阶段,启动完成Spring 上下文,才开启Http入口流量,此时 SmartLifecycle#start 已执行。所有单例Bean和SpringEvent等组件都已经就绪!

  1. 项目中在 init-method 方法中注册 Rpc是否合理?什么是合理的时机?

init 开启Rpc流量非常不合理。因为Spring尚未启动完成,包括 Spring Event尚未就绪!

  1. 项目中在 init-method 方法中注册 MQ消费组是否合理? 什么是合理的时机?

init 开启 MQ 流量非常不合理。因为Spring尚未启动完成,包括 Spring Event尚未就绪!

  1. Spring还未完全启动,在 PostConstruct 中调用 getBeanByAnnotation能否获得准确的结果?

虽然未启动完成,但是Spring执行该getBeanByAnnotation方法时,会率先检查 Bean定义,如果Bean定义对应的 Bean尚未初始化,则初始化这些Bean。所以即便是Spring初始化过程中调用,调用结果是准确的。

源码级别介绍

SmartInitializingSingleton 接口的执行位置

下图代码说明了,Spring在初始化全部 单例Bean以后,会执行 SmartInitializingSingleton 接口。 image.png

Autowired 何时装配Bean的依赖

在Bean实例化之后,但初始化之前,AutowiredAnnotationBeanPostProcessor 会注入Autowired字段。 image.png

SpringBoot 何时开启Http端口

下图代码中可以看到,SpringBoot会首先启动 Spring上下文,完成后才启动 嵌入式Web容器,初始化SpringMVC,监听端口 image.png

Spring 初始化Bean的关键代码

下图我加了注释,Spring初始化Bean的关键代码,全在 这个方法里,感兴趣的可以自行查阅代码 。 AbstractAutowireCapableBeanFactory#initializeBean

image.png

Spring CommandLineRunner 执行位置

Spring Boot外部,当启动完Spring上下文以后,最后才启动 CommandLineRunner。 image.png image.png

总结

SpringBoot 会在Spring完全启动完成后,才开启Http流量。这给了我们启示:应该在Spring启动完成后开启入口流量。Rpc和 MQ流量 也应该如此,所以建议大家 在 SmartLifecype 或者 ContextRefreshedEvent 等位置 注册服务,开启流量。

例如 Spring Cloud Eureka 服务发现组件,就是在 SmartLifecype中注册服务的!

网上被吹爆的Spring Event事件订阅有缺陷,一个月内我被坑了两次!

 

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

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

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

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

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

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

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

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

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

诡异的情况

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

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

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

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

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

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

Spring Event的简单使用

声明事件

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

 
BaseEvent定义
复制代码
public class BaseEvent<T> extends ApplicationEvent {
    private final T data;
    
    public BaseEvent(T source) {
        super(source);
        this.data = source;
    }

    public T getData() {
        return data;
    }
}

发布事件

使用Spring上下文 ApplicationContext发布事件

 
发布事件
复制代码
applicationContext.publishEvent(new BaseEvent<>(param));

Idea为Spring提供了跳转工具,点击绿色按钮位置,就可以 跳转到事件的监听器列表。

image.png

监听事件

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

 
js
复制代码
@EventListener
public void handleEvent(BaseEvent<PerformParam> event) {
    //消费事件
}

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

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

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

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

image.png

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

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

<bean id="kafkaConsumer" class="com.helloworld.KafkaConsumer" init-method="init" destroy-method="destroy">

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

EventListener注册到Spring 的时间点

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

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

Spring 启动顺序 image.png

InitializingBean 的初始化代码

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

SmartInitializingSingleton

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

以下是代码截图 image.png

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消费失败》这篇文章中,有读者评论提到了这个问题。

image.png

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

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

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

对于公司的项目来说,稳定性非常重要。引入 SpringEvent 前,一定要确保服务的入口流量在正确的时点开启。

posted @ 2024-02-22 11:26  CharyGao  阅读(170)  评论(0编辑  收藏  举报