springcloud loadbalancer nacos无损发布
前言
- 故事背景
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缓存原理
-
应用启动首先装配Caffeine一级缓存,缓存应用实例,降低注册中心负载,提升性能



从上图可以看出,可以通过设置spring.cloud.loadbalancer.cache来关闭一级缓存,其值默认是开启的。 -
feign初次从loadbalance获取应用实例会触发装配ServiceInstanceListSupplier逻辑


从一级缓存中获取应用实例:



nacos获取服务实例原理
上面我们分析了从loadbalancer缓存中获取实例的源码,当loadbalancer缓存失效后,将从nacos调用HostReactor获取应用实例,HostReactor也是存在短暂的实例缓存问题




从上述源码分析可以看出,nacos会维护一个哈希表serviceInfoMap用来存储服务对应的实例信息,获取实例时如果服务对应的ServiceInfo在serviceInfoMap不存在,则立即从nacos注册中心拉取实例

如果ServiceInfo存在,会进入scheduleUpdateIfAbsent方法,此方法为每个服务设定了一个定时刷新实例信息的异步任务
因此也有可能loadbalancer缓存失效后,代码会从这里拿到已下线的实例导致调用报错。
解决方案
通过上面的源码分析,根本原因是应用从nacos下线后,loadbalancer的一级缓存未移除下线实例以及nacos的HostReactor不是实时获取最新实例。有以下解决办法:
- 在重启脚本下线nacos实例后,等待一级缓存失效后(默认35s)再重启应用
- 禁用一级缓存(不建议),等待几秒(以便HostReactor拿到最新实例)后重启应用
- 监听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
- 通过调用下线应用提供的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压测接口,可以观察到下游服务无报错


更多推荐



所有评论(0)