本文 的 原文 地址
本文 的 原文 地址
尼恩说在前面:
在40岁老架构师 尼恩的读者交流群(50+)中,最近有小伙伴拿到了一线互联网企业如得物、阿里、滴滴、极兔、有赞、shein 希音、shopee、百度、网易的面试资格,遇到很多很重要的面试题:
- 微服务 如何 优雅上线、优雅下线?
- RPC怎么做无损升级?
- 微服务发布的时候,RPC怎么做零呼损?
- 微服务升级时,RPC里怎么避免调用方业务受损呢?
前几天 小伙伴面试 滴滴,遇到了这个问题。但是由于 没有回答好,导致面试挂了。
小伙伴面试完了之后,来求助尼恩。那么,遇到 这个问题,该如何才能回答得很漂亮,才能 让面试官刮目相看、口水直流。
所以,尼恩给大家做一下系统化、体系化的梳理,使得大家内力猛增,可以充分展示一下大家雄厚的 “技术肌肉”,让面试官爱到 “不能自已、口水直流”,然后实现”offer直提”。
当然,这道面试题,以及参考答案,也会收入咱们的 《尼恩Java面试宝典》V145版本PDF集群,供后面的小伙伴参考,提升大家的 3高 架构、设计、开发水平。
最新《尼恩 架构笔记》《尼恩高并发三部曲》《尼恩Java面试宝典》的PDF,请关注本公众号【技术自由圈】获取,后台回复:领电子书
一:应⽤上下线过程中 的 流量有损 问题
据统计,应⽤的事故大多发⽣在应⽤上下线过程中,有时是应⽤本身代码问题导致。
但有时我们也会发现尽管代码本身没有问题,但在应⽤上下线发布过程中仍然会出现短时间的服务调⽤报错,⽐如调⽤时出现 Connection refused 和 No instance 等现象。
常见的流量有损问题 出现的原因,包括但不限于以下⼏种:
- 初始化慢:应⽤刚启动接收线上流量进⾏资源初始化加载,由于流量太⼤,初始化过程慢,出现⼤量请求响应超时、阻塞、资源耗尽从⽽造成刚启动应⽤宕机。
- 注册太早:服务存在异步资源加载问题,当服务还未初始化完全就被注册到注册中⼼,导致调⽤时资源未加载完毕出现请求响应慢、调⽤超时报错等现象。
- 流量切换策略不合理: 在应用上线时,如果采用的是直接全量切换流量的方式,可能会导致新上线的实例瞬间承受较大的流量压力,进而出现性能问题,影响流量的正常处理。
- 服务下线慢: 服务停止了,但是 注册中⼼ meta data 元数据还在,服务列表存在延时,导致 下线后在⼀段时间内服务消费者还在 调⽤已下线实例,造成请求报错。
- 客户端 本地缓存问题: 负载均衡器(如 Ribbon、Spring Cloud LoadBalancer 等)采用的是基于本地缓存的服务实例列表进行流量分发。如果负载均衡器的本地缓存更新不及时,它可能仍然会将流量分发到已下线的实例上 ,造成请求报错。
二:微服务优雅上线, 优雅下线的几个维度
- 服务自身 维度
- 注册中心 维度
- RPC Client 维度
- 微服务网关 维度
下面就以下几个维度 对于微服务优雅发布方案进行分析:
2.1 服务自身维度:
下线时,能够执行资源释放,日志记录,数据持久化,安全退出等干工作。
- 处理没有完成的请求,不再接收新的请求.
- 池化资源的释放:数据库连接池,HTTP 连接池
- 处理线程的释放:请求处理线程的释放,记录日志等。
2.2 注册中心 维度:
使用服务注册与发现机制(如Eureka、Nacos、Zookeeper等)来管理服务的注册和发现。
这样可以确保在上线或下线服务时,其他服务能够及时感知到变化。
2.3 RPC Client(负载均衡) 维度:
使用负载均衡机制(如spring cloud loadbalance,Ribbon等)来分发流量到多个实例或节点上。
通过负载均衡,可以确保流量在服务上线或下线期间的平滑过渡,避免单个实例或节点过载,以及对已下线节点调用的错误问题发生
2.3 网关维度:
内网网关spring cloud gateway就是使用的负载均衡组件,因此在负载均衡组件剔除下线节点即可
三:微服务 自身维度 的 优雅上线
微服务 自身维度 的 优雅上线 的主要流程:
- 自动应用之后, 延迟注册
- 微服务就绪之后, 进行服务预热
- 然后才 平稳运行。
在Spring Cloud微服务架构中,实现延迟注册和服务预热
可通过以下方案优化服务启动流程:
3.1 延迟注册:控制服务实例的注册时机
对于初始化过程需要异步加载资源的复杂应⽤, 在启动过程,由于注册通常与应⽤初始化过程同步进⾏,从⽽出现应⽤还未完全初始化就已经被注册到注册中⼼供外部消费者调⽤,此时直接调⽤由于资源未加载完成可能会导致请求报错。
通过设置延迟注册,可让应⽤在充分初始化后再注册到注册中⼼对外提供服务。
核心场景:服务启动后等待依赖组件(如数据库、缓存)初始化完成再注册,避免接收未就绪的请求。
配置自动延迟注册(通用方案)
- # application.yml
- spring:
- cloud:
- service-registry:
- auto-registration:
- enabled: false # 禁用自动注册
- eureka:
- client:
- initial-instance-info-replication-interval-seconds: 30 # Eureka初次注册延迟(秒)
-
复制代码 通过监听应用启动事件 手动触发注册:- @Component
- public class DelayedRegistration implements ApplicationListener {
- @Autowired
- private ServiceRegistry serviceRegistry;
- @Override
- public void onApplicationEvent(ApplicationReadyEvent event) {
- // 确保依赖初始化完成后注册
- serviceRegistry.register();
- }
- }
复制代码 Nacos专属延迟注册
- spring:
- cloud:
- nacos:
- discovery:
- enabled: false # 关闭自动注册
- ephemeral: true # 临时实例(注册后自动维护心跳)
-
复制代码 通过API手动注册:- @PostConstruct
- public void init() {
- // 延迟5秒注册
- Executors.newSingleThreadScheduledExecutor()
- .schedule(() -> nacosDiscoveryProperties.registerInstance(), 5, TimeUnit.SECONDS);
- }
复制代码 3.2 服务预热:逐步接收流量
核心场景:实例启动后逐渐增加流量分配,避免冷启动时高并发导致崩溃。
在线上发布场景下,很多时候刚启动的冷系统直接处理⼤量请求,可能由于系统内部资源初始化不彻底从⽽出现⼤量请求超时、阻塞、报错甚⾄导致刚发布应⽤宕机等线上发布事故出现。
服务自热
在服务启动阶段,主动触发服务中各种功能的初始化,提前建立连接、加载数据等,使服务在正式接受流量前就处于热状态。
比如,可以遍历微服务的所有接口,主动发送请求,触发懒加载的代码逻辑,提前加载相关资源。
基于健康检查的预热
配置健康端点,在预热完成前返回OUT_OF_SERVICE状态:- @Component
- public class WarmupHealthIndicator implements HealthIndicator {
- private boolean isWarm = false;
- @Override
- public Health health() {
- if (!isWarm) {
- // 返回"不可用"状态,注册中心不分配流量
- return Health.outOfService().build();
- }
- return Health.up().build();
- }
- @PostConstruct
- public void warmup() throws InterruptedException {
- // 模拟预热过程(如加载缓存)
- Thread.sleep(30000);
- isWarm = true;
- }
- }
复制代码 动态权重调整(LoadBalancer)
⼩流量预热⽅法通过在服务消费端根据各个服务提供者实例的启动时间计算权重,结合负载均衡算法,逐步 控制刚启动应⽤流量从小到大放行,该开始是小流量, 随启动时间逐渐递增,慢慢递增到正常⽔平。
这样⼀个流量小到大放行过程,就是帮助刚启动运⾏进⾏预热。
详细 QPS 随时间变化曲线如图 所示:
⼩流量预热⽅法 可以根据时间来实现, 比如,可以自定义负载均衡规则,根据启动时间分配权重:- @Bean
- public ReactorLoadBalancer<ServiceInstance> weightedLoadBalancer(
- Environment environment, LoadBalancerClientFactory factory) {
- return new WeightedLoadBalancer(
- factory.getLazyProvider(name, ServiceInstanceListSupplier.class),
- name
- );
- }
- public class WeightedLoadBalancer extends RoundRobinLoadBalancer {
- @Override
- public Response<ServiceInstance> choose(Request request) {
- List<ServiceInstance> instances = getInstances();
- // 计算实例启动时间,新实例低权重
- instances.sort(Comparator.comparingLong(i -> i.getMetadata().get("startupTime")));
- return new DefaultResponse(instances.get(0));
- }
- }
复制代码 网关层流量控制
在Spring Cloud Gateway中按实例启动时间过滤请求:- @Bean
- public RouteLocator routes(RouteLocatorBuilder builder) {
- return builder.routes()
- .route("warmup_route", r -> r.path("/service/**")
- .filters(f -> f.filter(new WarmupFilter()))
- .uri("lb://target-service"))
- .build();
- }
- public class WarmupFilter implements GatewayFilter {
- @Override
- public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
- ServiceInstance instance = chooseInstance();
- Long startupTime = instance.getMetadata().get("startupTime");
- if (System.currentTimeMillis() - startupTime < 60000) {
- // 启动未满1分钟则返回错误或降级
- exchange.getResponse().setStatusCode(HttpStatus.TOO_EARLY);
- return exchange.getResponse().setComplete();
- }
- return chain.filter(exchange);
- }
- }
复制代码 例如开源微服务治理框架 Dubbo 原⽣就提供延迟注册功能。
参考:Dubbo延迟注册
开源 Dubbo 所实现的⼩流量服务预热模型计算如下公式所示:
模型中应用 QPS 对应的 f(x) 随调用时刻 x 线性变化,x 表示调用时刻的时间,startTime 是应用开始时间,warmupTime 是用户配置的应用预热时长,k 是常数,一般表示各实例的默认权重。
四:微服务 自身维度的 优雅下线
微服务自身 要实现 优雅下线, 还要做 很多 工作:
- 不再接收新的请求
- 处理没有完成的请求
- 池化资源的释放:数据库连接池,HTTP 连接池
- 处理线程的释放:请求处理线程的释放
- 日志记录
- 数据持久化
- 安全退出
- ...等
什么才是SpringCloud 优雅下线?
那么SpringCloud 优雅下线该如何实现呢?
要介绍清楚 SpringCloud 优雅下线实现机制,必须首先从JVM的优雅退出的基础知识开始。
4.1 SpringBoot应用如何优雅退出?
除了 微服务的无损下线,作为 SpringBoot应用, 还有 单体服务优雅停机的需求:
- 处理没有完成的请求,注意,不再接收新的请求
- 池化资源的释放:数据库连接池,HTTP 连接池
- 处理线程的释放:已经被连接的HTTP请求
这些前面介绍到 ,咱们就先用放在 JVM 处理钩子方法里边去了。
SpringBoot应用的优雅停机,实际上指的是内嵌WEB服务器的优雅停机。 随着版本升级,目前Spring Boot 优雅停机机制 更加完善了。
4.2 什么是Web 容器优雅停机行为
Web 容器优雅停机行为指的是在关闭容器时,停止 接收新的请求,但是让正在处理的请求完成后,再关闭容器。
这样可以避免正在处理的请求被中断,从而提高系统的可用性和稳定性。
一般来说,Web 容器的优雅停机行为需要满足以下几个条件:
(1) 等待正在处理的请求完成,不再接受新的请求。
(2) 如果等待时间超过了一定阈值,容器可以强制关闭。
(3) 在容器关闭之前,需要给客户端一个响应,告知他们当前正在关闭容器,不再接受新的请求
4.3 优雅停机具体行为
在服务器执行关闭(Kill pid)时,会预留一点时间使容器内部业务线程执行完毕,
增加了优雅停机配置后, 此时容器 也不允许新的请求进入。
目前版本的Spring Boot 优雅停机支持Jetty, Reactor Netty, Tomcat和 Undertow 以及反应式和基于 Servlet 的 web 应用程序都支持优雅停机功能。
新请求的处理方式跟web服务器有关,Reactor Netty、 Tomcat将停止接入请求,Undertow的处理方式是返回503。
具体行为,如下表所示:
web 容器名称行为说明tomcat 9.0.33+停止接收请求,客户端新请求等待超时。Reactor Netty停止接收请求,客户端新请求等待超时。Undertow停止接收请求,客户端新请求直接返回 503。不同的 Web 容器实现优雅停机的方式可能会有所不同,但是一般都会提供相关的配置选项或者 API 接口来实现这个功能。
另外,和SpringBoot内嵌的WEB服务器类似,其他的非SpringBoot内嵌WEB服务器,也可以进行设置。
下面是 Nginx 和 Apache 的优雅停机配置:
- Nginx 可以通过配置文件中的 worker_shutdown_timeout 选项来设置等待时间
- Apache 可以通过 graceful-stop 命令来实现优雅停机。
4.4 优雅停机的使用和配置
Tomcat 新版本配置非常简单,server.shutdown=graceful 就搞定了(注意,优雅停机配置需要配合Tomcat 9.0.33(含)以上版本)- server:
- port: 6080
- shutdown: graceful #开启优雅停机
- spring:
- lifecycle:
- timeout-per-shutdown-phase: 20s #设置缓冲时间 默认30s
-
-
复制代码 在设置了缓冲参数timeout-per-shutdown-phase 后,在规定时间内如果线程无法执行完毕则会被强制停机。
下面我们来看下停机时,加了优雅停日志和不加的区别:- //未加优雅停机配置
- Disconnected from the target VM, address: '127.0.0.1:49754', transport: 'socket'
- Process finished with exit code 130 (interrupted by signal 2: SIGINT)
复制代码 加了优雅停机配置后,
日志可明显发现的 Waiting for active requests to complete, 此时容器将在ShutdownHook执行完毕后停止。
4.5 SpringBoot应用的优雅停机如何触发
通过源码分析,大家也发现了,SpringBoot应用的优雅停机,是注册了 JVM 优雅退出的钩子方法
JVM 优雅退出的钩子方法如何触发的呢?常见的触发方式有:
- 方式一:kill PID
- 方式二:shutdown端点
方式一:kill PID
使用方式:kill java进程ID
kill命令的格式是 kill -Signal pid,其中 pid 就是进程的编号,signal是发送给进程的信号,
默认参数下,kill 发送 SIGTERM(15)信号给进程,告诉进程,你需要被关闭,请自行停止运行并退出。
kill、kill -9、kill -3的区别
- kill 会默认传15代表的信号为SIGTERM,这是告诉进程你需要被关闭,请自行停止运行并退出,进程可以清理缓存自行结束,也可以拒绝结束。
- kill -9 代表的信号是SIGKILL,表示进程被终止,需要立即退出,强制杀死该进程,这个信号不能被捕获也不能被忽略。
- kill -3可以打印进程各个线程的堆栈信息,kill -3 pid 后文件的保存路径为:/proc/${pid}/cwd,文件名为:antBuilderOutput.log
方式二:shutdown端点
Spring Boot 提供了/shutdown端点,可以借助它实现优雅停机。
使用方式:在想下线应用的application.yml中添加如下配置,从而启用并暴露/shutdown端点:- management:
- endpoint:
- shutdown:
- enabled: true
- endpoints:
- web:
- exposure:
- include: shutdown
-
复制代码 发送 POST 请求到/shutdown端点- curl -X http://ip:port/actuator/shutdown
复制代码 该方式本质和方式一是一样的,也是借助 Spring Boot 应用的 Shutdown hook 去实现的。
shutdown端点的源码分析
actuator 都使用了SPI的扩展方式,先看下AutoConfiguration,可以看到关键点就是ShutdownEndpoint- @Configuration(
- proxyBeanMethods = false
- )
- @ConditionalOnAvailableEndpoint(
- endpoint = ShutdownEndpoint.class
- )
- public class ShutdownEndpointAutoConfiguration {
- public ShutdownEndpointAutoConfiguration() {
- }
- @Bean(
- destroyMethod = ""
- )
- @ConditionalOnMissingBean
- public ShutdownEndpoint shutdownEndpoint() {
- return new ShutdownEndpoint();
- }
- }
复制代码 ShutdownEndpoint,的核心代码如下:- @Endpoint(
- id = "shutdown",
- enableByDefault = false
- )
- public class ShutdownEndpoint implements ApplicationContextAware {
-
- @WriteOperation
- public Map<String, String> shutdown() {
- if (this.context == null) {
- return NO_CONTEXT_MESSAGE;
- } else {
- boolean var6 = false;
- Map var1;
- try {
- var6 = true;
- var1 = SHUTDOWN_MESSAGE;
- var6 = false;
- } finally {
- if (var6) {
- Thread thread = new Thread(this::performShutdown);
- thread.setContextClassLoader(this.getClass().getClassLoader());
- thread.start();
- }
- }
- Thread thread = new Thread(this::performShutdown);
- thread.setContextClassLoader(this.getClass().getClassLoader());
- thread.start();
- return var1;
- }
- }
-
- private void performShutdown() {
- try {
- Thread.sleep(500L);
- } catch (InterruptedException var2) {
- Thread.currentThread().interrupt();
- }
- this.context.close(); //这里才是核心
- }
- }
复制代码 在调用了 this.context.close() ,其实就是AbstractApplicationContext 的close() 方法 (重点是其中的doClose())- /**
- * Close this application context, destroying all beans in its bean factory.
- * <p>Delegates to {@code doClose()} for the actual closing procedure.
- * Also removes a JVM shutdown hook, if registered, as it's not needed anymore.
- * @see #doClose()
- * @see #registerShutdownHook()
- */
- @Override
- public void close() {
- synchronized (this.startupShutdownMonitor) {
- doClose(); //重点:销毁bean 并执行jvm shutdown hook
- // If we registered a JVM shutdown hook, we don't need it anymore now:
- // We've already explicitly closed the context.
- if (this.shutdownHook != null) {
- try {
- Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
- }
- catch (IllegalStateException ex) {
- // ignore - VM is already shutting down
- }
- }
- }
- }
复制代码 doClose() 方法,又回到了前面的 Spring 的核心关闭方法。
doClose()在销毁 bean, 关闭容器之前会执行所有实现 Lifecycel 接口 bean 的 stop方法,并且会按 Phase 值分组, phase 大的优先执行。
SpringBoot应用 ,就是 封装了 JVM 退出 钩子 , 实现了 SpringBoot 自己的 退出 钩子 。
4.6 JVM的优雅退出
JVM的优雅退出机制,主要是通过 Hook实现的。
jvm 有shutdwonHook机制,中文习惯叫优雅退出。
VM的优雅退出Hook,和linux系统中执行SIGTERM(kill -15 或者 svc -d)时一样,会在退出前执行的一些操作,比如资源释放。
JVM 退出的钩子函数 的应用场景
首先看看,JVM 退出的钩子函数 的应用场景。
我们的java程序运行在JVM上,有很多情况可能会突然崩溃掉,比如:
一系列的问题,可能导致我们的JVM 进程挂掉。如果没有优雅停机, 此时直接直接关闭(kill -9),那么就会导致当前正在容器内运行的业务直接失败,在某些特殊的场景下产生脏数据。
JVM 退出的钩子函数是指在 JVM 进程即将退出时,自动执行用户指定的代码段。
这个功能的应用场景比较广泛,例如:
(1) 资源释放:在 JVM 退出时,需要释放一些资源,比如关闭数据库连接、释放文件句柄等,可以使用钩子函数来自动执行这些操作。
(2) 日志记录:在 JVM 退出时,可以记录一些关键信息,比如程序运行时间、内存使用情况等,以便后续分析问题。
(3) 数据持久化:在 JVM 退出时,可以将一些重要的数据持久化到磁盘上,以便下次启动时可以恢复状态。
(4) 安全退出:在 JVM 退出时,可以执行一些清理操作,比如删除临时文件、关闭网络连接等,以确保程序的安全退出。
总之,钩子函数可以在 JVM 退出时执行一些自定义的操作,以便更好地管理和控制程序的运行。
JVM 退出 钩子 的使用
在java程序中,可以通过添加 退出 钩子( 特定接口的函数),实现在程序退出时关闭资源、优雅退出的功能。
如何做呢?
退出 钩子 是通过Runtime.addShutDownHook(Thread hook)来 添加。Runtime.addShutdownHook(Thread hook) 是 Java 中的一个方法,用于在 JVM 关闭时, 注册一个线程来执行清理操作。
Runtime.addShutdownHook(Thread hook) 每一次调用,就是注册一个线程 , 这个线程负责 执行 退出 钩子 清理逻辑,参考代码如下:- // 添加hook thread,重写其run方法
- Runtime.getRuntime().addShutdownHook(new Thread(){
- @Override
- public void run() {
- System.out.println("this is hook demo...");
- // jvm 退出的钩子逻辑
- }
- });
复制代码 Runtime.addShutdownHook(Thread hook) 可以调用多次,从而注册多个线程。
当 JVM 即将关闭时,会按照注册的顺序依次执行这些线程,以便进行一些资源释放、日志记录或其他清理操作。
这个方法可以在应用程序中用来确保在程序退出前执行一些必要的清理工作,例如关闭数据库连接或释放文件句柄等。
JVM 退出 钩子 的 触发场景
JVM 退出 钩子 在什么情况下会被调用呢?
上述我们展示了在程序异常情况下会被调用,还有没有其他场景呢?
- 程序正常退出
- 使用System.exit()
- 终端使用Ctrl+C触发的中断
- 系统关闭
- OutOfMemory宕机
- 使用Kill pid杀死进程( 注意:大家常常喜欢使用kill -9,这个是不会被调用的 JVM 退出 钩子 的)
- ..等等
Runtime.addShutDownHook(Thread hook)触发场景 ,详见下图
五:Nacos 注册中心 微服务 优雅上线、优雅下线
在分布式微服务场景下, 优雅发布 需通过 延迟暴露实例(上线)和 主动注销+停机缓冲(下线)实现流量无损切换,结合参数调优加速状态感知,并通过灰度验证确保流程可靠性。
从两个常见的注册中心Nacos和Eureka分析
5.1 Nacos 优雅发布 的 要求
首先,看看以 Nacos 做 注册中心 怎么做优雅发布 ?
对于优雅发布,要求是 Service Provider 上线(注册到 Nacos)后,服务能够正常地接收和处理请求,而 Service Provider 停服后,则不会再收到请求。
优雅发布有两个要求:优雅上线和优雅下线:
- 优雅上线:Service Provider 发布完成 之前,Service Consumer 不应该从服务列表中拉取到这个服务地址;
- 优雅下线:Service Provider 下线 之后,Service Consumer 不会从服务列表中拉取到这个服务地址。
解决了这两个问题,优雅发布就可以做到了。
5.2 Nacos Server 服务注册 与发现机制 的底层原理
跟其他的注册中心一样,Nacos 作为注册中心的使用如下图:
Service Provider 启动后注册到 Nacos Server,Service Consumer 则从 Nacos Server 拉取服务列表,根据一定算法选择一个 Service Provider 来发送请求。
Service Consumer 初始化时会从 Nacos Server 获取服务列表并更新本地缓存。后面 Service Consumer 之后会定时(默认间隔 1s )拉取服务列表并更新本地缓存。
Service Consumer 会向 Nacos Server 订阅服务列表,如果 Nacos Server 上的服务列表发生变化,会主动通知 Service Consumer。
代码如下:- //NacosNamingService 类
- public List<Instance> selectInstances(String serviceName, String groupName, List<String> clusters, boolean healthy,
- boolean subscribe) throws NacosException {
-
- ServiceInfo serviceInfo;
- String clusterString = StringUtils.join(clusters, ",");
- if (subscribe) {
- serviceInfo = serviceInfoHolder.getServiceInfo(serviceName, groupName, clusterString);
- if (null == serviceInfo) {
- serviceInfo = clientProxy.subscribe(serviceName, groupName, clusterString);
- }
- } else {
- serviceInfo = clientProxy.queryInstancesOfService(serviceName, groupName, clusterString, 0, false);
- }
- return selectInstances(serviceInfo, healthy);
- }
复制代码 在订阅的代码中,加入了定时更新服务列表的代码,如下:- //NamingClientProxyDelegate 类
- public ServiceInfo subscribe(String serviceName, String groupName, String clusters) throws NacosException {
- NAMING_LOGGER.info("[SUBSCRIBE-SERVICE] service:{}, group:{}, clusters:{} ", serviceName, groupName, clusters);
- String serviceNameWithGroup = NamingUtils.getGroupedName(serviceName, groupName);
- String serviceKey = ServiceInfo.getKey(serviceNameWithGroup, clusters);
- serviceInfoUpdateService.scheduleUpdateIfAbsent(serviceName, groupName, clusters);
- ServiceInfo result = serviceInfoHolder.getServiceInfoMap().get(serviceKey);
- if (null == result || !isSubscribed(serviceName, groupName, clusters)) {
- result = grpcClientProxy.subscribe(serviceName, groupName, clusters);
- }
- serviceInfoHolder.processServiceInfo(result);
- return result;
- }
复制代码 Nacos Server 会定时(每隔 5s)检查 Service Provider 是否健康(根据心跳来检查),如果 15s (默认,可以配置)没有收到心跳,则会把服务置为不健康,并且通知 Service Consumer。
代码如下:- //UnhealthyInstanceChecker 类
- public void doCheck(Client client, Service service, HealthCheckInstancePublishInfo instance) {
- if (instance.isHealthy() && isUnhealthy(service, instance)) {
- changeHealthyStatus(client, service, instance);
- }
- }
- private void changeHealthyStatus(Client client, Service service, HealthCheckInstancePublishInfo instance) {
- instance.setHealthy(false);
- NotifyCenter.publishEvent(new ServiceEvent.ServiceChangedEvent(service));
- NotifyCenter.publishEvent(new ClientEvent.ClientChangedEvent(client));
- NotifyCenter.publishEvent(new HealthStateChangeTraceEvent(System.currentTimeMillis(),
- service.getNamespace(), service.getGroup(), service.getName(), instance.getIp(), instance.getPort(),
- false, "client_beat"));
- }
复制代码 5.3 Nacos场景下: 服务 优雅上线(优雅发布)
如果没有优雅上线,那么存在的问题 主要在于: Service Provider 注册到 Nacos 后,服务还没有完成初始化,请求已经到来。 主要原因是 Service Provider 启动后立刻注册 Naocs,而本身提供的接口可能还没有初始化完成。
Nacos场景下 服务 的 优雅上线,基本上有两种实现方案:
- 优雅上线 方案1:延迟注册机制
服务实例启动时先完成本地预热(如 JVM 预热、缓存加载),待关键资源初始化完成后,再向 Nacos 注册服务。
例如在 Spring Boot 应用中,结合 @PostConstruct 或 ApplicationRunner 接口延迟注册
- 优雅上线 方案 2:健康检查联动
利用 Nacos 主动健康检查机制(如 HTTP 探针),在服务实例内部接口 /actuator/health 返回健康状态后,再允许外部流量接入。
通过配置 nacos.health-check-url 确保服务就绪后才被标记为可用节点。
优雅上线 方案1:延迟注册机制
在application.yml中关闭自动注册,手动控制注册时机:- spring:
- cloud:
- nacos:
- discovery:
- auto-register: false # 禁用自动注册
-
复制代码 使用ApplicationRunner在服务预热完成后触发注册:- @Component
- public class DelayedRegister implements ApplicationRunner {
- @Autowired
- private NacosDiscoveryProperties discoveryProperties;
- @Override
- public void run(ApplicationArguments args) {
- // 模拟预热过程
- warmUpCache(); //这个要自己实现
- initJVM(); //这个要自己实现
-
- // 手动注册
- discoveryProperties.registerInstance();
- }
- }
复制代码 此方案确保服务完全就绪后才对外暴露 。
优雅上线 方案 2:健康检查联动
在Nacos中配置Spring Boot Actuator的健康检查路径:- nacos:
- discovery:
- health-check-url: ${server.servlet.context-path}/actuator/health
- health-check-enabled: true
-
复制代码 自定义健康指标,确保关键组件初始化完成:- @Component
- public class CustomHealthIndicator implements HealthIndicator {
- @Override
- public Health health() {
- return cacheReady() && dbConnected()
- ? Health.up().build()
- : Health.down().build();
- }
-
- // cacheReady() && dbConnected() 这个要自己实现
- }
复制代码 Nacos会持续检查该接口,仅当返回UP状态时才允许流量接入 。
方案对比
方案适用场景优势延迟注册需严格保证服务就绪的场景能够完全避免未就绪服务被调用健康检查联动快速迭代的微服务架构与基础设施无缝集成,维护成本低5.4 Nacos场景下:优雅下线(优雅停止)
对于正常下线,Nacos Server 收到 Provider 发送的下线请求后,会通知订阅的 Server Consumer,而且 Consumer 也会每隔 1s 去更新本地服务列表,这个过程已经非常接近优雅下线了。
而对于异常下线,Nacos Server 采用心跳检测机制来更新服务列表。
心跳周期是 5s,Nacos Server 如果 15s 没收到心跳就才会将实例设置为不健康。
正常停服
正常下线的情况下,最优雅的方式是先向 Nacos Server 发送下线通知,发送通知一段时间(比如 5s)后,再停服。
比如增加一个 API 接口,服务下线之前增加 preStopHook 函数调用这个 API 接口来实现下线。
API 接口示例代码如下:- @GetMapping(value = "/nacos/deregisterInstance")
- public String deregisterInstance() {
- Properties prop = new Properties();
- prop.setProperty("serverAddr", "localhost");
- prop.put(PropertyKeyConst.NAMESPACE, "test");
- NacosNamingService client = new NacosNamingService(prop);
- client.deregisterInstance("springboot-provider", "192.168.31.94", 8083);
- return "success";
- }
复制代码 在使用 Ribbon 的场景,也需要考虑 Ribbon 更新本地缓存服务列表的机制,手动下线后,可以再等待 30s 后关闭服务。
服务故障
对于服务故障的情况,Nacos Server 需要采用心跳来检测服务在线,如果 15s 没收到心跳才会将实例设置为不健康,在 30s 没收到心跳才会把这个服务从列表中删除。
这个时间可以做优化设置:- spring.cloud.nacos.discovery.metadata.preserved.heart.beat.interval=1000 #心跳间隔5s->1s
- spring.cloud.nacos.discovery.metadata.preserved.heart.beat.timeout=3000 #超时时间15s->3s
- spring.cloud.nacos.discovery.metadata.preserved.ip.delete.timeout=5000 #删除时间30s->5s
复制代码 但是,Service Provider 故障情况下,即使做优化配置,也是很难让 Service Consumer 无感知。
极端情况下,可能 Provider 部分服务已经不能正常提供了,但还是会向 Nacos Server 发送心跳,这种情况可以采用服务本身的健康检查来通知 Nacos Server 服务下线。
六:Eureka注册中心 微服务 优雅上线、优雅下线
Eureka优雅上线
优雅上线需通过 延迟暴露实例(避免未就绪接收流量)和 参数调优(加速客户端感知)实现,确保服务初始化期间对外屏蔽,就绪后快速生效
延迟注册与预热机制
服务启动后优先完成 JVM 预热、线程池初始化等核心操作,延迟向 Eureka 注册实例,避免流量过早进入未就绪的实例。可通过 SmartLifecycle 或 ApplicationRunner 接口手动触发注册:- @Component
- public class DelayRegistration implements SmartLifecycle {
- @Autowired
- private EurekaClient eurekaClient;
- private boolean running = false;
- @Override
- public void start() {
- // 模拟预热 10 秒
- Thread.sleep(10000);
- eurekaClient.register(); // 手动触发注册
- this.running = true;
- }
- }
复制代码 健康检查联动
配置 Spring Boot Actuator 健康检查端点 /actuator/health,仅当服务初始化完成时返回 UP 状态。
确保 Eureka 健康检查机制正确标记实例状态,避免注册后因未就绪被标记为不可用。
Eureka 优雅下线
但如果的务发现组件使用的是 Eureka,那么默认最长会有 90 秒的延迟,其他应用才会感知到该服务下线,
这意味着:
该实例下线后的 90 秒内,其他服务仍然可能调用到这个已下线的实例。
Spring Boot 应用 退出的时候,如何在 Eureka 进行实例的主动删除呢?
可以借助 Spring Boot 应用的 Shutdown hook,结合 Eureka 的Client API,达到微服务实例优雅下线的目标。
Eureka 的两个核心的 Client API 如下:
- 执行eurekaAutoServiceRegistration.start()方法时,当前服务向 Eureka 注册中心注册服务;
- 执行eurekaAutoServiceRegistration.stop()方法时,当前服务会向 Eureka 注册中心进行反注册,注册中心收到请求后,会将此服务从注册列表中删除。
借助 Spring Boot 应用的 Shutdown hook ,微服务实例优雅下线的目标, 源码如下:
执行的结果,如下:
参数调优加速上下线感知
1、缩短客户端心跳间隔
调整 Eureka 客户端向服务端发送心跳的频率,加速实例健康状态同步:- eureka:
- instance:
- lease-renewal-interval-in-seconds: 5 # 心跳间隔(默认 30 秒)
-
复制代码 2、优化服务端缓存刷新
降低 Eureka Server 响应缓存的刷新周期,缩短服务列表同步延迟:- eureka:
- server:
- response-cache-update-interval-ms: 3000 # 服务端缓存刷新间隔(默认 30 秒)
复制代码 3、禁用冗余缓存层
关闭 Ribbon 和 Eureka 的本地缓存,减少客户端感知新实例的延迟:- ribbon:
- ServerListRefreshInterval: 5000 # Ribbon 缓存刷新间隔(默认 30 秒)
- eureka:
- server:
- use-read-only-response-cache: false # 禁用只读缓存
-
复制代码 4、缩短剔除失效实例的周期
调整 Eureka Server 检查失效实例的频率,默认 60 秒调整为 10 秒,加快下线实例的清理速度:- eureka:
- server:
- eviction-interval-timer-in-ms: 10000 # 失效实例检查周期(默认60秒)
-
复制代码 5、禁用冗余缓存层
关闭 Eureka Server 的只读缓存,减少服务列表同步延迟 :- eureka:
- server:
- use-read-only-response-cache: false
-
复制代码 缩短 Ribbon 本地缓存刷新周期至 5 秒,加速客户端感知实例变化:- ribbon:
- ServerListRefreshInterval: 5000 # 默认30秒
复制代码 七:RPC Client(负载均衡) 维度的优雅上线, 优雅下线 的方案设计
7.1 RPC Client 维度 优雅上线方案
延迟注册机制
这个与 前面介绍的 服务自身的 优雅上线 有关:
- 服务启动后先完成内部资源初始化(如缓存预热、连接池建立),再向注册中心注册实例。
- 通过spring.cloud.service-registry.auto-registration.enabled=false关闭自动注册,在ApplicationReadyEvent事件后手动触发注册。
流量渐进式接入
负载均衡器(如Spring Cloud LoadBalancer)根据实例启动时间动态分配权重,新实例初始权重设为10%,每分钟递增20%直至满负荷。
健康检查拦截
启动阶段健康端点返回OUT_OF_SERVICE状态,待预热完成后切换为UP,避免网关过早路由请求。
7.2 优雅下线方案
主动注销+双重缓存清理
- 接收停机信号后,立即调用DiscoveryClient.shutdown()向注册中心注销实例。
- 接收nacos 服务实例的变化信号后, 清理RPC Client (如Ribbon)的ServerList缓存:通过ServerListUpdater强制刷新服务列表。
流量摘除与请求排空
- 负载均衡器标记下线实例为draining状态,新请求不再路由至该实例。
- 通过@PreDestroy钩子等待现有请求完成(最长等待时间可配置)。
八:网关 维度 的 优雅上线、优雅下线
在使用Spring Cloud Gateway作为网关,Nacos2.x作为注册中心,Spring Cloud Loadbalancer 作为负载均衡的时候,如何实现 优雅下线?
关键是在服务下线后,Gateway 网关及时动态感知服务上下线,否则, 服务下线后,网关可能短暂报500的错误。
500 报错信息如下图:
问题原因:
Loadbalancer 有个缓存 CachingRouteLocator ,会缓存服务信息,这个缓存每35秒刷新一次。
服务在上线或者下线之后,未能及时刷新这个缓存, 所以导致网关会短暂的访问到已经下线的服务。
相应的源码如下:- public class CachingRouteLocator implements Ordered, RouteLocator,
- ApplicationListener<RefreshRoutesEvent>, ApplicationEventPublisherAware {
- private static final Log log = LogFactory.getLog(CachingRouteLocator.class);
- private static final String CACHE_KEY = "routes";
- private final RouteLocator delegate;
- private final Flux<Route> routes;
- private final Map<String, List> cache = new ConcurrentHashMap<>();
- private ApplicationEventPublisher applicationEventPublisher;
- public CachingRouteLocator(RouteLocator delegate) {
- this.delegate = delegate;
- routes = CacheFlux.lookup(cache, CACHE_KEY, Route.class)
- .onCacheMissResume(this::fetch);
- }
- private Flux<Route> fetch() {
- return this.delegate.getRoutes().sort(AnnotationAwareOrderComparator.INSTANCE);
- }
- @Override
- public Flux<Route> getRoutes() {
- return this.routes;
- }
- /**
- * Clears the routes cache.
- * @return routes flux
- */
- public Flux<Route> refresh() {
- this.cache.clear();
- return this.routes;
- }
- @Override
- public void onApplicationEvent(RefreshRoutesEvent event) {
- try {
- fetch().collect(Collectors.toList()).subscribe(list -> Flux.fromIterable(list)
- .materialize().collect(Collectors.toList()).subscribe(signals -> {
- applicationEventPublisher
- .publishEvent(new RefreshRoutesResultEvent(this));
- cache.put(CACHE_KEY, signals);
- }, throwable -> handleRefreshError(throwable)));
- }
- catch (Throwable e) {
- handleRefreshError(e);
- }
- }
- private void handleRefreshError(Throwable throwable) {
- if (log.isErrorEnabled()) {
- log.error("Refresh routes error !!!", throwable);
- }
- applicationEventPublisher
- .publishEvent(new RefreshRoutesResultEvent(this, throwable));
- }
- @Deprecated
- /* for testing */ void handleRefresh() {
- refresh();
- }
- @Override
- public int getOrder() {
- return 0;
- }
- @Override
- public void setApplicationEventPublisher(
- ApplicationEventPublisher applicationEventPublisher) {
- this.applicationEventPublisher = applicationEventPublisher;
- }
- }
复制代码 解决方案:
因为Nacos2.x使用长连接与服务相连,所以可以实时感知到有服务下线。
并且当某一个服务下线后,Nacos会实时通知给其他服务,并发布服务变更事件。
所以, 可以 通过监听Nacos发布的服务变更事件,实时清除CacheManager缓存,可以使得当需要相关数据时,重新加载最新的数据,从而 达到实时感知服务上下线的功能
[code]@Componentpublic class MyListener extends Subscriber { @Resource private CacheManager defaultLoadBalancerCacheManager; @PostConstruct public void init() { NotifyCenter.registerSubscriber(this); } @Override public void onEvent(InstancesChangeEvent instancesChangeEvent) { Cache cachingServiceInstanceListSupplierCache = defaultLoadBalancerCacheManager.getCache( CachingServiceInstanceListSupplier.SERVICE_INSTANCE_CACHE_NAME); if(Objects.nonNull(cachingServiceInstanceListSupplierCache)){ cachingServiceInstanceListSupplierCache.evict( instancesChangeEvent.getServiceName()); } } @Override public Class |