SpringCloudGateway实现实时刷新Nacos服务列表(使用Ribbon负载均衡器解决方案)
前言
- 在使用SpringCloudGateway搭配Ribbon负载均衡器拉取Nacos注册中心上服务列表时经常会遇到一种情况,下游服务刚刚启动时或者重启后会存在一段时间访问不到的问题,因为在Ribbon中拉取Nacos服务地址是由一个定时线程默认每隔30S去拉取的,也就是说下游服务刚刚上线有可能在30S内Ribbon是获取不到这个服务信息的,这就让人挺难受的,无论是线下测试还是线上部署我们都希望能尽快的获取到服务列表,这里提供两种解决方案。
错误信息
如果获取不到服务列表会抛出 503 SERVICE_UNAVAILABLE "Unable to find instance for 服务名称
解决方案一(订阅Nacos服务状态发生改变,主动更新本地服务列表)
核心代码实现
import com.alibaba.cloud.nacos.NacosDiscoveryProperties;
import com.alibaba.nacos.api.exception.NacosException;
import com.alibaba.nacos.api.naming.NamingService;
import com.netflix.loadbalancer.ILoadBalancer;
import com.netflix.loadbalancer.ZoneAwareLoadBalancer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.config.GatewayProperties;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.cloud.netflix.ribbon.SpringClientFactory;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.util.List;
/**
* @author kerwin
*/
@Slf4j
@Component
public class GatewayNacosServerStatusListener {
// 该对象会在RibbonAutoConfiguration中被加载,可以通过这个工厂获取对应服务负载均衡器,从而刷新指定rebbon中的服务地址信息
@Autowired
private SpringClientFactory springClientFactory;
// Gateway的路由信息配置会被提前加载好我们直接注入即可,一般我们会把路由id设置成各个服务名称,这里需要服务名称直接读取路由id
@Autowired
private GatewayProperties gatewayProperties;
// Nacos注册中心配置信息 包括我们需要的NamingService也能在里面获取到
@Autowired
private NacosDiscoveryProperties nacosDiscoveryProperties;
@PostConstruct
public void init() {
try {
//获取 NamingService
NamingService namingService = nacosDiscoveryProperties.namingServiceInstance();
//获取所有的服务名称 这里从gateway路由信息中获取
List<RouteDefinition> routes = gatewayProperties.getRoutes();
//订阅服务,服务状态刷新时,更新ribbon
routes.stream().forEach(item -> {
String service = item.getId();
//订阅服务状态发生改变时,刷新 ribbon 服务实例
try {
namingService.subscribe(service,(event -> {
ILoadBalancer loadBalancer = springClientFactory.getLoadBalancer(service);
if(loadBalancer != null){
log.info("刷新 ribbon 服务实例:{}",service);
((ZoneAwareLoadBalancer<?>) loadBalancer).updateListOfServers();
log.info("刷新 ribbon 服务实例成功:{}",service);
}
}));
} catch (NacosException e) {
log.error("订阅 nacos 服务失败,error:{}",e.getErrMsg());
e.printStackTrace();
}
});
} catch (Exception e) {
log.error("获取 nacos 服务信息失败,error:{}",e.getMessage());
e.printStackTrace();
}
}
}
原理分析
- 1、订阅Nacos订阅服务状态
- 这里我们通过依赖注入获取IOC容器中的NacosDiscoveryProperties,可以通过NacosDiscoveryProperties获取到NamingService,调用NamingService的subscribe方法订阅一个服务,传入参数有两个,第一个参数是服务名称,第二个参数是一个函数对象,这里假设订阅到订单服务order-service,如果订单服务上下或者下线或者启动多个订单服务每一次变动都会通知到二个参数的函数对象,可以在这个函数方法中实现手动更新Ribbon中的服务列表。
- 2、为什么直接使用GatewayProperties
- 理论上来说在Gateway这边需要调用多少个下游服务那么就需要对那些下游服务名称进行订阅,因为NamingService的subscribe方法是监听某一个服务是否变动,这里需要对每一个服务都进行订阅,我们可以直接注入GatewayProperties对象,这个对象是解析Gateway路由配置信息的解析好后会存储到一个数组中,通过getRoutes就能获取到所有的路由信息,我们一般都会将路由的id设置为服务名称所以这里直接使用是一个很好的选择,当然也可以自己通过配置文件指定需要监听那些服务。
- 3、SpringClientFactory是什么有什么作用
- SpringClientFactory这个工厂类主要是用来创建负载均衡器和获取负载均衡器的,会在RibbonAutoConfiguration中被加载到IOC容器中,这里要使用直接注入即可,我们通过对应的路由id调用springClientFactory.getLoadBalancer(“路由ID”)方法就能获取到对应的负载均衡器,这个负载均衡器使用的是ZoneAwareLoadBalancer,ZoneAwareLoadBalancer中有更新服务列表的方法updateListOfServers(),获取到对应服务的负载均衡器然后调用更新服务列表方法这样就实现了实时更新服务列表功能。
解决方案二(通过设置Ribbon定时拉取Nacos服务列表间隔时间)
要是觉得方案一麻烦又不需要那么高的实时性使用方案二是一个不错的选择。
- 前面有提到过可以修改Ribbon定时拉取服务列表的时间,默认是30s拉取一次服务列表,这里可以根据实际情况调整成对应值。
ribbon:
ServerListRefreshInterval: 15000 # 刷新所服务列表间隔时间 默认30000毫秒
- 其实还要考虑一下Nacos的配置,因为Nacos的心跳下线机制也会影响获取最新服务列表的准确性,这里附带Nacos心跳以及下线时间配置
spring:
cloud:
nacos:
metadata:
preserved.heart.beat.interval: 5000 #该实例在客户端上报心跳的间隔时间 默认5000。(单位:毫秒)
preserved.heart.beat.timeout: 5000 #该实例在不发送心跳后,从健康到不健康的时间 默认30000。(单位:毫秒)
preserved.ip.delete.timeout: 20000 #该实例在不发送心跳后,被nacos下掉该实例的时间 默认30000。(单位:毫秒)
天坑小提示
不知道大家有没有见到有博客说,自己去继承PollingServerListUpdater类,重写它的start方法,然后将UpdateAction对象引用到成员变量中,并且将重写的这个类注入到IOC容器中,然后在自己监听Nacos服务更新去调用UpdateAction.doUpdate()更新服务列表。
如果有这样做那么基本上已经换工作了^^,Gateway中超过1个下游服务这样做就会出大问题,如果只有一个下游服务倒是可以用。
错误代码示例
@Component
public class MyPollingServerListUpdater extends PollingServerListUpdater {
private UpdateAction updateAction;
@Override
public synchronized void start(UpdateAction updateAction) {
this.updateAction = updateAction;
super.start(updateAction);
}
public UpdateAction getUpdateAction() {
return updateAction;
}
}
@Slf4j
@Component
public class NacosServerStatusListener {
@Autowired
private MyPollingServerListUpdater listUpdater;
// Gateway的路由信息配置,一般我们会把路由id设置成各个服务名称,这里直接读取即可
@Autowired
private GatewayProperties gatewayProperties;
// Nacos注册中心配置信息 包括我们需要的NamingService也能在里面获取到
@Autowired
private NacosDiscoveryProperties nacosDiscoveryProperties;
@PostConstruct
public void init() {
try {
//获取 NamingService
NamingService namingService = nacosDiscoveryProperties.namingServiceInstance();
//获取所有的服务名称 这里从gateway路由信息中获取
List<RouteDefinition> routes = gatewayProperties.getRoutes();
//订阅服务,服务状态刷新时,更新ribbon
routes.stream().forEach(item -> {
String service = item.getId();
//订阅服务状态发生改变时,刷新 ribbon 服务实例
try {
namingService.subscribe(service,(event -> {
ServerListUpdater.UpdateAction updateAction = this.listUpdater.getUpdateAction();
if (updateAction != null) {
log.info("Ribbon 刷新 service:{}",service);
updateAction.doUpdate();
}
}));
} catch (NacosException e) {
log.error("订阅 nacos 服务失败,error:{}",e.getErrMsg());
e.printStackTrace();
}
});
} catch (Exception e) {
log.error("获取 nacos 服务信息失败,error:{}",e.getMessage());
e.printStackTrace();
}
}
}
错误原因
这种方法存在两个问题,这里不细说源码流程只分析一下出现的原因。
首先其实每个Getaway路由都有多个负载均衡器,每个下游服务都有一个而且都会独立管理一个自己的PollingServerListUpdater对象,这个对象中的start方法会开启一个定时线程去拉取对应服务的服务列表,这里不是全部拉取,而是只拉取对应服务下的服务列表,比如你的路由id为order、uri 为lb://order,那么这个定时线程只会去拉取order服务的服务列表,这里我们自己实例化了一个PollingServerListUpdater对象加载到IOC容器中去了,那么后续所有服务都会用这一个对象,有趣的是这个start方法有一个判断,判断是否已经被调用过了,如果已经被调用就不会在去创建定时线程拉取服务列表,那我们这里如果有两个服务都去使用这一个PollingServerListUpdater对象,那么只有第一个调用者能创建定时线程拉取服务列表,而后面的调用者都无法创建,除了第一个创建的负载均衡器其余都不会定时拉取服务列表。
还有一个问题,就是我们将UpdateAction作为成员变量那么也就是说只有最后一个被加载的负载均衡器才会被实时更新服务列表,因为全部的负载均衡器都调用的一个PollingServerListUpdater对象的start方法,后面调用就是直接替换了UpdateAction对象了,谁最后调用那么这个UpdateAction对象就是谁的。