前言

  • 故事背景
    jenkins部署时总是会有几秒钟接口调用报错,观察日志是因为流量被下发到已下线的服务,重启脚本在停止应用之前先调用nacos注销实例api后再重启依然会短暂出现此问题。项目架构是springcloud alibaba,通过openfeign进行微服务之间调用,猜测是LoadBalancer缓存问题。
  • 依赖版本
<dependencyManagement>
   <dependencies>
   	<dependency>
		    <groupId>com.alibaba.cloud</groupId>
		    <artifactId>spring-cloud-alibaba-dependencies</artifactId>
		    <version>2021.0.1.0</version>
		    <type>pom</type>
		    <scope>import</scope>
		</dependency>
		<dependency>
          <groupId>org.springframework.boot</groupId>
          <artifactId>spring-boot-dependencies</artifactId>
          <version>2.6.3</version>
          <type>pom</type>
          <scope>import</scope>
      </dependency>
      <dependency>
          <groupId>org.springframework.cloud</groupId>
          <artifactId>spring-cloud-dependencies</artifactId>
          <version>2021.0.1</version>
          <type>pom</type>
          <scope>import</scope>
      </dependency>
   </dependencies>
</dependencyManagement>

<dependencies>
	<dependency>
	    <groupId>com.alibaba.cloud</groupId>
	    <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
	    <exclusions>
	        <exclusion>
	            <groupId>org.springframework.cloud</groupId>
	            <artifactId>spring-cloud-starter-netflix-ribbon</artifactId>
	        </exclusion>
	    </exclusions>
	</dependency>
	<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-openfeign</artifactId>
    <version>3.1.1</version>
  </dependency>
</dependencies>
  • loadbalancer配置
spring:
  cloud:
    loadbalancer:
      #需要引入Spring Retry依赖
      retry:
        enabled: true

springcloud loadbalancer缓存原理

  1. 应用启动首先装配Caffeine一级缓存,缓存应用实例,降低注册中心负载,提升性能
    在这里插入图片描述
    在这里插入图片描述在这里插入图片描述
    从上图可以看出,可以通过设置spring.cloud.loadbalancer.cache来关闭一级缓存,其值默认是开启的。

  2. feign初次从loadbalance获取应用实例会触发装配ServiceInstanceListSupplier逻辑
    在这里插入图片描述
    在这里插入图片描述

从一级缓存中获取应用实例:
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

nacos获取服务实例原理

上面我们分析了从loadbalancer缓存中获取实例的源码,当loadbalancer缓存失效后,将从nacos调用HostReactor获取应用实例,HostReactor也是存在短暂的实例缓存问题
在这里插入图片描述在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
从上述源码分析可以看出,nacos会维护一个哈希表serviceInfoMap用来存储服务对应的实例信息,获取实例时如果服务对应的ServiceInfo在serviceInfoMap不存在,则立即从nacos注册中心拉取实例
在这里插入图片描述
如果ServiceInfo存在,会进入scheduleUpdateIfAbsent方法,此方法为每个服务设定了一个定时刷新实例信息的异步任务
在这里插入图片描述因此也有可能loadbalancer缓存失效后,代码会从这里拿到已下线的实例导致调用报错。

解决方案

通过上面的源码分析,根本原因是应用从nacos下线后,loadbalancer的一级缓存未移除下线实例以及nacos的HostReactor不是实时获取最新实例。有以下解决办法:

  1. 在重启脚本下线nacos实例后,等待一级缓存失效后(默认35s)再重启应用
  2. 禁用一级缓存(不建议),等待几秒(以便HostReactor拿到最新实例)后重启应用
  3. 监听nacos下线事件,手动将服务实例缓存从loadbalancer缓存中移除,并且将服务对应的ServiceInfo从serviceInfoMap中删除

方案实现

  • 采用方案3
    监听nacos下线事件,手动将服务实例缓存从loadbalancer缓存中移除,并且将服务对应的ServiceInfo从serviceInfoMap中删除
  • 代码实现
    • 思路
      nacos订阅需要删除缓存的服务名(serviceName),下线应用主动调用nacos实例注销接口后由nacos server触发自定义的订阅回调逻辑
    • nacos订阅源码分析
      在这里插入图片描述在这里插入图片描述

从上图可以看出默认只会订阅当前服务名,这也是为什么以下代码在其他应用主动下线后没有触发回调的原因

  • 编写指定服务nacos订阅与删除实例缓存逻辑
package com.xx.xx.xx.listener;

import cn.hutool.core.collection.CollUtil;
import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.NacosServiceManager;
import com.alibaba.nacos.api.naming.NamingService;
import com.alibaba.nacos.api.naming.listener.NamingEvent;
import com.alibaba.nacos.api.naming.pojo.Instance;
import com.alibaba.nacos.api.naming.pojo.ServiceInfo;
import com.alibaba.nacos.api.naming.utils.NamingUtils;
import com.alibaba.nacos.client.naming.NacosNamingService;
import com.alibaba.nacos.client.naming.core.HostReactor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.cache.Cache;
import org.springframework.cloud.client.ConditionalOnDiscoveryEnabled;
import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.loadbalancer.cache.LoadBalancerCacheManager;
import org.springframework.cloud.loadbalancer.cache.LoadBalancerCacheProperties;
import org.springframework.cloud.loadbalancer.core.CachingServiceInstanceListSupplier;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.ReflectionUtils;

import javax.annotation.Resource;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
import java.util.StringJoiner;

/**
 * @description nacos应用监听
 * @date 2024/7/29
 */
@Configuration
@ConditionalOnDiscoveryEnabled
@AutoConfigureAfter(LoadBalancerCacheProperties.class)
@Slf4j
public class NacosInstanceListener implements InitializingBean {

    @Resource
    private NacosServiceManager nacosServiceManager;

    @Resource
    private NacosDiscoveryProperties properties;

    @Resource
    private LoadBalancerCacheManager caffeineLoadBalancerCacheManager;

    @Override
    @SneakyThrows
    public void afterPropertiesSet() {
    //订阅的服务
        String serviceName = "xxx";
        NamingService namingService = nacosServiceManager.getNamingService(properties.getNacosProperties());
        namingService.subscribe(serviceName, properties.getGroup(), Arrays.asList(properties.getClusterName()), event -> {
            if (event instanceof NamingEvent) {
                Cache cache = caffeineLoadBalancerCacheManager.getCache(CachingServiceInstanceListSupplier.SERVICE_INSTANCE_CACHE_NAME);
                boolean clear = false;
                //删除nacos实例缓存
                HostReactor hostReactor = this.findHostReactor(namingService);
                String key = ServiceInfo.getKey(NamingUtils.getGroupedName(serviceName, properties.getGroup()), "");
                hostReactor.getServiceInfoMap().remove(key);
                if (cache != null) {
                    //删除loadbalancer实例缓存
                    cache.evict(serviceName);
                    clear = true;
                }

                List<Instance> instanceList = ((NamingEvent) event).getInstances();
                StringJoiner joiner = new StringJoiner("|");
                instanceList.forEach(instance -> joiner.add(instance.toInetAddr()));
                log.error("nacos实例变动回调,下线后最新实例:[{}]", joiner);

                List<ServiceInstance> list = cache.get(serviceName, List.class);
                StringJoiner instanceJoiner = new StringJoiner(",");
                if (CollUtil.isNotEmpty(list)) {
                    list.forEach(item -> instanceJoiner.add(item.getHost() + ":" + item.getPort()));
                }
                log.error("是否执行loadbalancer缓存清除:[{}],清除后的实例缓存:[{}]", clear, instanceJoiner);
            }
        });
    }

    private HostReactor findHostReactor(NamingService namingService) {
        Field field = ReflectionUtils.findField(NacosNamingService.class, "hostReactor");
        field.setAccessible(true);
        HostReactor hostReactor = (HostReactor) ReflectionUtils.getField(field, namingService);
        return hostReactor;
    }
}
  • 应用停止前主动调用nacos下线接口注销实例
    这一步一定要在即将下线的应用调用nacos客户端提供的方法去下线,对于临时实例,如果直接调用nacos下线接口会出现下线应用nacos客户端依旧发送心跳给注册中心导致应用又短暂地上线。以下是两种调用注销接口的实现,个人推荐方式2
  1. 通过调用下线应用提供的url接口实现
@RestController
@RequestMapping(path = "/nacos")
@Slf4j
public class NacosCtrl {

    @Resource
    private NacosServiceManager nacosServiceManager;

    @Resource
    private NacosDiscoveryProperties properties;

    @DeleteMapping(path = "/deregister/{token}")
    @SneakyThrows
    public String deregister(@PathVariable("token") String token) {
        Assert.isTrue("321".equals(token));

        NamingService namingService = nacosServiceManager.getNamingService(properties.getNacosProperties());
        try {
            log.error("开始停止");
            namingService.deregisterInstance(properties.getService(), properties.getGroup(), properties.getIp(), properties.getPort(), properties.getClusterName());
        } catch (NacosException e) {
            log.error("注销实例失败", e);
        }
        //nacos客户端注销到调用端接收事件删除缓存执行时间间隔,并发越高,cpu资源越吃紧时间间隔可能会越长
        TimeUnit.SECONDS.sleep(5);
        return "ok";
    }
}

实际效果:
脚本调用优雅停机接口
在这里插入图片描述2. 监听应用关闭事件,确保在应用关闭第一时间执行
试过几种注册关闭回调方法的方式,如:使用@PreDestroy注解与Runtime.getRuntime().addShutdownHook方式,但是最终发现spring源码中注册了很多同类型的钩子方法,无法保证我注册的在其他钩子方法执行之前执行,最后通过观察关闭时候的debug级别日志找到了思路。
在这里插入图片描述在这里插入图片描述

从上述日志可以看出这个事件回调是在应用关闭前第一时间触发,我们只需要在该钩子方法中插入我们的nacos注销逻辑即可,代码如下:

import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.cloud.nacos.NacosServiceManager;
import com.alibaba.nacos.api.exception.NacosException;
import com.alibaba.nacos.api.naming.NamingService;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.availability.ApplicationAvailabilityBean;
import org.springframework.boot.availability.AvailabilityChangeEvent;
import org.springframework.boot.availability.ReadinessState;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

/**
 * @description 覆盖ApplicationAvailabilityBean
 */
@Slf4j
public class ApplicationAvailabilityListener extends ApplicationAvailabilityBean {

    @Resource
    private NacosServiceManager nacosServiceManager;

    @Resource
    private NacosDiscoveryProperties properties;

    @Override
    public void onApplicationEvent(AvailabilityChangeEvent<?> event) {
        super.onApplicationEvent(event);
        Object state = event.getState();
        if (state instanceof ReadinessState && ReadinessState.REFUSING_TRAFFIC.equals(state)) {
            System.out.println("收到关闭消息========");
            this.deregisterNacos();
        }
    }

    @SneakyThrows
    private void deregisterNacos() {
        NamingService namingService = nacosServiceManager.getNamingService(properties.getNacosProperties());
        try {
            log.error("开始停止");
            namingService.deregisterInstance(properties.getService(), properties.getGroup(), properties.getIp(), properties.getPort(), properties.getClusterName());
        } catch (NacosException e) {
            log.error("注销实例失败", e);
        }
        TimeUnit.SECONDS.sleep(10);
    }
}
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.availability.ApplicationAvailabilityAutoConfiguration;
import org.springframework.boot.availability.ApplicationAvailabilityBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @description 替换ApplicationAvailabilityAutoConfiguration
 * @see org.springframework.boot.autoconfigure.availability.ApplicationAvailabilityAutoConfiguration
 */
@Configuration(proxyBeanMethods = false)
@EnableAutoConfiguration(exclude = {ApplicationAvailabilityAutoConfiguration.class})
@AutoConfigureBefore(ApplicationAvailabilityAutoConfiguration.class)
public class WebAvailabilityAutoConfiguration {

    @Bean
    public ApplicationAvailabilityBean applicationAvailability() {
        return new ApplicationAvailabilityListener();
    }
}

实际效果:
在这里插入图片描述

使用go-stress压测接口,可以观察到下游服务无报错
在这里插入图片描述
在这里插入图片描述

更多推荐