当前位置: 首页 > news >正文

【Nacos】服务发布之优雅预热上线方案

目录

  • 一、背景
  • 二、注册时机
    • 2.1、注册机制
    • 2.2、分析源码找到注册时机
  • 三、注册前心跳健康检测
    • 3.1、方案实施
    • 3.2、源码分析
    • 3.3、优化代码
  • 四、流量权重配置
  • 五、总结
    • 5.1、整体完整流程:
    • 5.2、流程图:
    • 5.1、优化方案完整代码:

一、背景

有些面向广大C端的微服务,类似商品服务,每日需要承担十万级的QPS每个节点可能会高达数千QPS的。每一秒的抖动都可能对大量用户造成影响。因此在高频产品迭代的前提下,平稳的进行服务发布和新老服务替换是一个必要的能力。

以商品服务为例子product-service,承载了商品的查询与新增等功能,整体QPS 31k+单机qps 1.2k+,正常平均响应时间10ms以下

在这里插入图片描述

在这里插入图片描述

  • 优雅方案部署前状态

可以看到部署优雅功能前,启动阶段会导致100ms+的响应抖动。这个说明启动瞬间会有部分用户体验受到较大的影响,是值得研究优化的点

在这里插入图片描述

  • 优雅方案部署后状态

部署后很直观可以看到,平均响应能保持正常的10ms以下

在这里插入图片描述

二、注册时机

2.1、注册机制

商品微服务架构是基于行业内流行的Spring Cloud架构体系,因此存在注册中心(nacos)的概念,专门维护所有可用服务节点的信息。所有服务可以在注册中心进行注册,并基于注册中心提供的其他节点的信息进行相应的调用,这就是所谓的注册机制。Nacos源码详细讲解可以看《【Nacos】Nacos源码保姆级解析》

2.2、分析源码找到注册时机

为了解决应用启动时出现的慢调用问题,首要步骤是检查注册时机的合理性。是否存在服务尚未准备就绪,就被过早地部署到生产环境中,从而导致调用端无法接收到正常的响应?

在深入分析源代码后,确认Nacos的注册时机是依赖于Spring的生命周期管理机制。具体来说,它监听的是WebServerInitializedEvent事件,即内置Tomcat服务器完全启动的那一刻。因此,可以观察到Nacos的心跳(beat)紧随Tomcat的17000端口启动之后进行注册并完成。这表明,自Tomcat启动并监听17000端口后,Nacos就已经记录了该服务器,并可以开始接收对应的请求。

相关日志记录如下:

public void onApplicationEvent(WebServerInitializedEvent event) {
		bind(event);
}
## tomcat端口已启动
[18:01:15.217] INFO  [TID: N/A] org.springframework.boot.web.embedded.tomcat.TomcatWebServer 220 start - Tomcat started on port(s): 17000 (http) with context path ''
## 增加本机nacos心跳
[18:01:15.331] INFO  [TID: N/A] com.alibaba.nacos.client.naming.beat.BeatReactor 81 addBeatInfo - [BEAT] adding beat: BeatInfo{port=17000, ip='127.0.0.1', weight=1.0, serviceName='DEFAULT_GROUP@@product-v1', cluster='DEFAULT', metadata={preserved.register.source=SPRING_CLOUD}, scheduled=false, period=5000, stopped=false} to beat map.
## 注册本机服务进nacos
[18:01:15.333] INFO  [TID: N/A] com.alibaba.nacos.client.naming.net.NamingProxy 230 registerService - [REGISTER-SERVICE] public registering service DEFAULT_GROUP@@product-v1 with instance: Instance{instanceId='null', ip='127.0.0.1', port=17000, weight=1.0, healthy=true, enabled=true, ephemeral=true, clusterName='DEFAULT', serviceName='null', metadata={preserved.register.source=SPRING_CLOUD}}
## 本机在nacos注册完成
[18:01:15.338] INFO  [TID: N/A] com.alibaba.cloud.nacos.registry.NacosServiceRegistry 75 register - nacos registry, DEFAULT_GROUP product-v1 127.0.0.1:17000 register finished

关键源码如下:

在这里插入图片描述

理论上,tomcat是在spring的context准备完后才正式启动端口的,所以此时应该bean都已经完成了实例化

在这里插入图片描述

但凡事都有特例,仔细查看源码,会发现启动时实例化的bean是有限制条件的,如下图。

在这里插入图片描述

/**
 * Return whether this bean is "abstract", i.e. not meant to be instantiated
 * itself but rather just serving as parent for concrete child bean definitions.
 */
@Override
public boolean isAbstract() {
	return this.abstractFlag;
}

/**
 * Return whether this a <b>Singleton</b>, with a single shared instance
 * returned from all calls.
 * @see #SCOPE_SINGLETON
 */
@Override
public boolean isSingleton() {
	return SCOPE_SINGLETON.equals(this.scope) || SCOPE_DEFAULT.equals(this.scope);
}

/**
 * Return whether this bean should be lazily initialized, i.e. not
 * eagerly instantiated on startup. Only applicable to a singleton bean.
 * @return whether to apply lazy-init semantics ({@code false} by default)
 */
@Override
public boolean isLazyInit() {
	return (this.lazyInit != null && this.lazyInit.booleanValue());
}

很明显,如果你的bean标记了@Lazy,那肯定不会在这里被实例化。还有另外一种重要的使用场景,@RefreshScope,这个注解广泛应用于配合@Configuration动态刷新配置。debug了其触发时机,如下图是在spring的refreshContext的最后,afterRefresh生命周期之前,触发的RefreshScope实例化,而此时context、tomcat都已完成,所以当用户请求来读取这些@Lazy或者@RefreshScope的bean时就会临时进行实例化或者等待实例化完成,在大qps的场景下可能会造成卡顿,这也就是启动时会瞬时卡顿的一个原因

在这里插入图片描述

分析到这一步,其实已经大致能猜到原因,就是服务注册时机不正确。而像lazy或者refreshScope这种常用注解可能在实际工作中确实可能需要使用,因此单纯禁用并不是一个好办法。

所以解法很简单,就是修改nacos注册的时间,放弃nacos自动的注册时机,改成手动注册,把注册时机掌握在自己手上。目前选择的做法如最下面的代码块,在spring runner(选择这里是因为callRunnersafterRefresh之后)中异步起一个线程进行回调处理,其等待数秒之后,再手动进行注册。

/**
 * 进行nacos手动注册
 */
private void doNacosRegister(){
    log.warn("nacos手动注册流程开始");
    try {
        // 临时获取权限拿参数
        // 通过反射拿registration属性。即使 registration 是私有字段(private),也可以通过反射获取。
        // 公共属性(public)才能get方法获取
        Field declaredField = nacosAutoServiceRegistration.getClass().getDeclaredField("registration");
        // 设置 registration 字段为可访问状态。
        // 说明:
        // 如果 registration 字段是私有的(private),默认情况下无法通过反射直接访问。
        // setAccessible(true) 可以绕过 Java 的访问控制检查,允许访问私有字段。
        // 这是一个危险操作,因为它破坏了封装性,应谨慎使用。
        declaredField.setAccessible(true);
        // 通过反射从 nacosAutoServiceRegistration 对象中获取 registration 字段的值。
        // 将该值强制转换为 NacosRegistration 类型。
        NacosRegistration nacosRegistration = (NacosRegistration) declaredField.get(nacosAutoServiceRegistration);
        // 将 registration 字段的访问权限恢复为原始状态(通常是不可访问状态)。
        // 说明:
        // 这是一个可选操作,目的是恢复字段的访问控制,避免对其他代码产生影响。
        // 在实际开发中,这一步通常可以省略,因为 setAccessible(true) 的作用范围仅限于当前反射操作。
        declaredField.setAccessible(false);

        // 如果开启了自动注册 那么就直接返回
        if (nacosRegistration.isRegisterEnabled()) {
            log.warn("nacos已打开自动注册,跳过手动注册!");
            return;
        }

        // 手动注册
        nacosRegistration.getNacosDiscoveryProperties().setRegisterEnabled(true);
        nacosAutoServiceRegistration.start();
    } catch (Exception e) {
        throw new RuntimeException(e);
    } finally {
        log.warn("nacos手动注册流程结束");
    }
}

那就只有这一个卡点么?很明显不是,而且纯靠几秒的延迟也无法保证所有必要的bean都预热完成了,进一步探索。

三、注册前心跳健康检测

3.1、方案实施

包括mysqlredis在内的中间件在启动后采用懒加载机制不会主动创建连接,这样也有可能会造成卡顿。

继续探究了一下启动后可继续优化的方向。发现更加合理的方向是在注册流程中结合spring actuator健康检查机制,这样刚好可以和k8s集群监听的spring actuator的liveness保持逻辑判断的一致

spring actuator会在启动时检测包括mysql、redis等一系列中间件的连接状态,确认待各项指标全部ok后。此时再进行nacos注册可以解决该问题。

综上所述,对nacos注册进行了进一步的优化,使用健康检查进行连接预热,在注册流程里融合了spring actuator的健康检查机制,一方面可以确保实例完全可用,一方面可以解决中间件初次连接的问题。

例如心跳检测失败:

在这里插入图片描述

其实就是ES注册失败

在这里插入图片描述

心跳检测成功:

在这里插入图片描述

3.2、源码分析

以mysql举例,使用的Hikari连接池在datasource创建的时候采用的是懒加载模式,直到第一次调用getConnection才会真正和mysql进行连接。

在这里插入图片描述

com.zaxxer.hikari.HikariDataSource#getConnection()

在这里插入图片描述

spring actuator的健康检查机制可以解决此类问题,针对所有的中间件,不管你是否有主动进行getConnection,它都会在检查时主动getConnection

org.springframework.boot.actuate.jdbc.DataSourceHealthIndicator#doHealthCheck

在这里插入图片描述
在这里插入图片描述

同样的问题在redis的lettuce客户端也有一样的处理,不会主动创建连接直到首次调用。同样可以依靠redis的健康检查进行初次连接

org.springframework.boot.actuate.autoconfigure.health.HealthEndpointConfiguration.AdaptedReactiveHealthContributors#adapt(org.springframework.boot.actuate.health.ReactiveHealthIndicator)

在这里插入图片描述

3.3、优化代码

// 初次健康检查,预热
this.firstHealthCheck();

// 异步健康检查
CompletableFuture.supplyAsync(() -> {
    log.warn("异步监测健康状态开始");

    Boolean isUp = false;
    // 等待5秒才注册
    try {
        for (int i = 1; i <= CHECK_HEALTH_NACOS_REGISTER_MAX_TIMES; i++) {
            isUp = this.isUpStatus();
            log.warn("第{}次异步健康检测:{}", i, isUp);
            if (isUp){
                // 如果已启动,注册并中断循环
                this.doNacosRegister();
                break;
            }
            Thread.sleep(5000); // 模拟耗时操作
        }
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }
    return isUp;
}).thenAccept(result -> {
    if (result) {
        log.warn("异步监测健康状态结束");
    } else {
        System.exit(99);
        log.error("异步监测健康状态一直失败,请检查!");
    }
});

日志:

[14:52:57.978] WARN  [restartedMainraceId] com.dev.common.config.register.NacosDelayRegisterRunner 46 run - ---开始执行应用程序已启动,执行runner逻辑---
[14:52:57.979] WARN  [restartedMainraceId] com.dev.common.config.register.NacosDelayRegisterRunner 175 firstHealthCheck - 开始进行初次预热健康检查
[14:52:59.578] WARN  [restartedMainraceId] com.dev.common.config.register.NacosDelayRegisterRunner 178 firstHealthCheck - 初次预热健康检查完成:OUT_OF_SERVICE
[14:52:59.579] WARN  [ForkJoinPool.commonPool-worker-9raceId] com.dev.common.config.register.NacosDelayRegisterRunner 76 lambda$handleCommandLineArguments$0 - 异步监测健康状态开始
[14:52:59.653] WARN  [ForkJoinPool.commonPool-worker-9raceId] com.dev.common.config.register.NacosDelayRegisterRunner 83 lambda$handleCommandLineArguments$0 - 第1次异步健康检测:false
[14:53:04.717] WARN  [ForkJoinPool.commonPool-worker-9raceId] com.dev.common.config.register.NacosDelayRegisterRunner 83 lambda$handleCommandLineArguments$0 - 第2次异步健康检测:true
[14:53:04.717] WARN  [ForkJoinPool.commonPool-worker-9raceId] com.dev.common.config.register.NacosDelayRegisterRunner 122 doNacosRegister - nacos手动注册流程开始

如上述代码和日志,在注册前进行一次健康检查,然后起异步线程进行定时异步检查健康状态。可以看到哪怕是到了spring runner阶段,health check仍然处于不可用状态,直到第二次异步健康检测才变更为可用,此时再进行nacos手动注册最为合适

四、流量权重配置

至此已经基本解决优雅上线的问题,但出于精益求精的态度,并且针对C端高请求量的场景特点(低流量业务选做)。重新审视了一遍nacos的设计和架构,发现一个一直忽略的重要功能,权重。因此制定了进一步的优化方向:对用户流量进行控流,逐步预热上线。依托于nacos的权重weight机制,可以对用户流量进行设置从0.01至1的权重配置,逐步放大用户流量至全量,这样做可以更好预热服务,防止瞬间高请求量导致扩tomcat线程等操作的耗时。

新老版本nacos客户端代码编写及load-balancer的权重适配

在开发过程中发现,虽然nacos服务端有设置weight的地方,但实际上客户端的lb组件并没有针对weight做判断。查阅资料后及翻阅源码后证实了这一点,在2.x版本之前都是简单的轮询机制在之后的版本也需要特别打开开关才会采用weight的权重配置

org.springframework.cloud.loadbalancer.core.RoundRobinLoadBalancer#getInstanceResponse

在这里插入图片描述

因此针对新版本nacos,可以选择打开NacosLoadBalancer的开关,针对老版本,参考了NacosLoadBalancer的做法,自行实现了LoadBalancer核心算法代码如下,概括总结一下,各台机器的weight形成各自的区间,依靠随机数去命中区间,以此达到权重的效果。

/**
 * Random get one item with weight.
 *
 * @return item
 */
public T randomWithWeight() {
    Ref<T> ref = this.ref;
    double random = ThreadLocalRandom.current().nextDouble(0, 1);
    int index = Arrays.binarySearch(ref.weights, random);
    if (index < 0) {
        index = -index - 1;
    } else {
        return ref.items.get(index);
    }
    
    if (index < ref.weights.length) {
        if (random < ref.weights[index]) {
            return ref.items.get(index);
        }
    }
    
    if (ref.weights.length == 0) {
        throw new IllegalStateException("Cumulative Weight wrong , the array length is equal to 0.");
    }
    
    /* This should never happen, but it ensures we will return a correct
     * object in case there is some floating point inequality problem
     * wrt the cumulative probabilities. */
    return ref.items.get(ref.items.size() - 1);
}

注册流程加入权重

可以通过以下代码

// 注册前实例化节点,并配置weight
Instance instance = new Instance();
instance.setIp(nacosDiscoveryProperties.getIp());
instance.setPort(nacosDiscoveryProperties.getPort());
instance.setWeight(0.5);

五、总结

5.1、整体完整流程:

  • 关闭自动注册进行手动注册,且应在spring runner阶段

  • 注册前进行spring actuator的health check

  • health check返回成功后进行0.01weight的小流量注册

  • 逐步放大weight直至到1

  • 服务发布结束

5.2、流程图:

在这里插入图片描述

5.1、优化方案完整代码:

GitHub地址,有帮助麻烦给个star

import com.alibaba.cloud.nacos.registry.NacosAutoServiceRegistration;
import com.alibaba.cloud.nacos.registry.NacosRegistration;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.actuate.health.HealthComponent;
import org.springframework.boot.actuate.health.HealthEndpoint;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.lang.reflect.Field;
import java.util.concurrent.CompletableFuture;

import static org.springframework.boot.actuate.health.Status.UP;

/**
 * @author hanson.huang
 * @version V1.0
 * @ClassName NacosDelayRegisterRunner
 * @Description nacos优雅预热上线方案
 * @date 2025/3/14 14:22
 **/
@Component
@Slf4j
public class NacosDelayRegisterRunner implements ApplicationRunner {

    /**
     * 最大健康检查次数
     */
    private static final int CHECK_HEALTH_NACOS_REGISTER_MAX_TIMES = 10;

    @Resource
    private NacosAutoServiceRegistration nacosAutoServiceRegistration;

    @Resource
    private HealthEndpoint healthEndpoint;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 在这里编写应用程序启动后要执行的逻辑
        log.warn("---开始执行应用程序已启动,执行runner逻辑---");

        // 你还可以获取并处理命令行参数和应用程序参数
        handleCommandLineArguments(args);
    }

    /**
     * 读取程序启动参数并执行
     * @param args 启动参数
     */
    private void handleCommandLineArguments(ApplicationArguments args) {
        // 获取并处理命令行参数
        System.out.println("---命令行参数:---");
        for (String arg : args.getSourceArgs()) {
            System.out.println(arg);
        }

        // 获取并处理应用程序参数
        System.out.println("---应用程序参数:---");
        for (String name : args.getOptionNames()) {
            System.out.println(name + "=" + args.getOptionValues(name));
        }

        // 如果在启动参数手动设置了不注册nacos,就跳过手动注册,为了开发环境和backend
        if ( !checkDisableNacos(args.getSourceArgs()) ) {
            // 初次健康检查,预热
            this.firstHealthCheck();

            // 异步健康检查
            CompletableFuture.supplyAsync(() -> {
                log.warn("异步监测健康状态开始");

                Boolean isUp = false;
                // 等待5秒才注册
                try {
                    for (int i = 1; i <= CHECK_HEALTH_NACOS_REGISTER_MAX_TIMES; i++) {
                        isUp = this.isUpStatus();
                        log.warn("第{}次异步健康检测:{}", i, isUp);
                        if (isUp){
                            // 如果已启动,注册并中断循环
                            this.doNacosRegister();
                            break;
                        }
                        Thread.sleep(5000); // 模拟耗时操作
                    }
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                return isUp;
            }).thenAccept(result -> {
                if (result) {
                    log.warn("异步监测健康状态结束");
                } else {
                    System.exit(99);
                    log.error("异步监测健康状态一直失败,请检查!");
                }
            });
        }
    }

    private boolean checkDisableNacos(String[] args){
        System.out.println(System.getProperty("spring.cloud.nacos.discovery.register-enabled"));
        for (String arg : args) {
            if (StringUtils.contains(arg, "spring.cloud.nacos.discovery.register-enabled") && StringUtils.contains(arg,"false")
                    || StringUtils.equals(System.getProperty("spring.cloud.nacos.discovery.register-enabled"), "false")){
                return true;
            }
        }
        return false;
    }

    /**
     * 进行nacos手动注册
     */
    private void doNacosRegister(){
        log.warn("nacos手动注册流程开始");
        try {
            // 临时获取权限拿参数
            // 通过反射拿registration属性。即使 registration 是私有字段(private),也可以通过反射获取。
            // 公共属性(public)才能get方法获取
            Field declaredField = nacosAutoServiceRegistration.getClass().getDeclaredField("registration");
            // 设置 registration 字段为可访问状态。
            // 说明:
            // 如果 registration 字段是私有的(private),默认情况下无法通过反射直接访问。
            // setAccessible(true) 可以绕过 Java 的访问控制检查,允许访问私有字段。
            // 这是一个危险操作,因为它破坏了封装性,应谨慎使用。
            declaredField.setAccessible(true);
            // 通过反射从 nacosAutoServiceRegistration 对象中获取 registration 字段的值。
            // 将该值强制转换为 NacosRegistration 类型。
            NacosRegistration nacosRegistration = (NacosRegistration) declaredField.get(nacosAutoServiceRegistration);
            // 将 registration 字段的访问权限恢复为原始状态(通常是不可访问状态)。
            // 说明:
            // 这是一个可选操作,目的是恢复字段的访问控制,避免对其他代码产生影响。
            // 在实际开发中,这一步通常可以省略,因为 setAccessible(true) 的作用范围仅限于当前反射操作。
            declaredField.setAccessible(false);

            // 如果开启了自动注册 那么就直接返回
            if (nacosRegistration.isRegisterEnabled()) {
                log.warn("nacos已打开自动注册,跳过手动注册!");
                return;
            }

            // 手动注册
            nacosRegistration.getNacosDiscoveryProperties().setRegisterEnabled(true);
            nacosAutoServiceRegistration.start();
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            log.warn("nacos手动注册流程结束");
        }
    }

    /**
     * 进行初次健康检查
     */
    private void firstHealthCheck(){
        log.warn("开始进行初次预热健康检查");
        // 进行初次健康检查
        HealthComponent endpoint = healthEndpoint.health();
        log.warn("初次预热健康检查完成:" + endpoint.getStatus());
    }

    /**
     * 是否已启动
     * @return 是/否
     */
    private Boolean isUpStatus(){
        return UP.equals( healthEndpoint.health().getStatus() );
    }
}

其他注意事项:

  • healthcheck必须确保没有废弃中间件的引入,以免healthcheck一直不过

  • 启动时CPU资源一定要给足,否则启动过慢

  • weight参数的使用要谨慎,要确保各种客户端的兼容性

创作不易,不妨点赞、收藏、关注支持一下,各位的支持就是我创作的最大动力❤️

在这里插入图片描述

相关文章:

  • ArcGIS Pro将有文字标注底图切换为无标注底图(在线地图图源)
  • ubuntu20.04装nv驱动的一些坑
  • Java高频面试之集合-12
  • 「BigBig AGI 1.0 Demo 」来袭!揭示 AI 灵魂奥秘
  • STM32 RS232通信开发全解析 | 零基础入门STM32第五十九步
  • WebSocket与MQTT协议深度对比:选择合适的通信协议
  • 行为模式---模版模式
  • pjsip pjsua_media_config 结构体说明
  • CentOS7 服务器安装 Hadoop 和 Hive
  • 数组的介绍
  • ios 小组件和数据共享
  • 浅谈StarRocks数据库简介及应用
  • 插入排序算法的SIMD优化
  • 地下停车场调频广播覆盖:破解地下车库无线广播收听孤岛,技术赋能地下停车场FM调频无线广播覆盖
  • PixelCNN:基于自回归的图像生成模型及其数学原理
  • spring boot3 kafka集群搭建到使用
  • AI大模型测试用例生成平台
  • mysql 到 doris 挪移数据
  • IDEA中链接使用mysql数据库
  • 如何在 React 中实现错误边界?
  • 张涌任西安市委常委,已卸任西安市副市长职务
  • 公元1058年:柳永词为什么时好时坏?
  • 视频|王弘治:王太后,“先天宫斗圣体”?
  • 人才争夺战,二三线城市和一线城市拼什么?洛阳官方调研剖析
  • 外交部亚洲司司长刘劲松会见印度驻华大使罗国栋
  • 讲一个香港儿童的故事,《劏房的天空》获“周庄杯”特等奖