标签:
问题描述
项目使用spring cloud gateway作为网关,nacos作为微服务注册中心,项目搭建好后正常访问都没问题,但是有个很烦人的小瑕疵:
- 当某个微服务重启后,通过网关调用这个服务时有时会出现503 Service Unavailable(服务不可用)的错误,但过了一会儿又可以访问了,这个等待时间有时很长有时很短,甚至有时候还不会出现 导致每次重启某个项目都要顺便启动gateway项目才能保证立即可以访问,时间长了感觉好累,想彻底研究下为什么,并彻底解决
接下来介绍我在解决整个过程的思路,如果没兴趣,可以直接跳到最后的最终解决方案
gateway感知其它服务上下线
首先在某个微服务上下线时,gateway的控制台可以立即看到有对应的输出
某服务下线gateway输出
某服务上线gateway输出
这说明nacos提供了这种监听功能,在注册中心服务列表发生时可以第一时间通知客户端,而在我们的依赖spring-cloud-starter-alibaba-nacos-discovery中显然已经帮我们实现了这个监听
所以也就说明gateway是可以立即感知其它服务的上下线事件,但问题是明明感知到某个服务的上线,那为什么会出现503 Service Unavailable的错误,而且上面的输出有时出现了很久,但调用依然是503 Service Unavailable,对应的某服务明明下线,这是应该是503 Service Unavailable状态,可有时确会有一定时间的500错误
ribbon
为了调查事情的真相,我打开了gateway的debug日志模式,找到了503的罪魁祸首
503的控制台输出
在503错误输出前,有一行这样的日志Zone aware logic disabled or there is only one zone,而报这个信息的包就是ribbon-loadbalancer,也就是gateway默认所使用的负载均衡器
我的gateway配置文件路由方面设置如下
routes: - id: auth uri: lb://demo-auth predicates: - Path=/auth/** filters: - StripPrefix=1
其中在uri这一行,使用了lb:// ,代表使用了gateway的ribbon负载均衡功能,官方文档说明如下 Note that this example also demonstrates (optional) Spring Cloud Netflix Ribbon load-balancing (defined the lb prefix on the destination URI)
ribbon再调用时首先会获取所有服务列表(ip和端口信息),然后根据负载均衡策略调用其中一个服务,选择服务的代码如下
package com.netflix.loadbalancer; public class ZoneAwareLoadBalancer<T extends Server> extends DynamicServerListLoadBalancer<T> { // 选择服务的方法 public Server chooseServer(Object key) { if (!ENABLED.get() || getLoadBalancerStats().getAvailableZones().size() <= 1) { logger.debug("Zone aware logic disabled or there is only one zone"); return super.chooseServer(key); } ...
public class LoadBalancerStats implements IClientConfigAware { // 一个缓存所有服务的map volatile Map<String, List<? extends Server>> upServerListZoneMap = new ConcurrentHashMap<String, List<? extends Server>>(); // 获取可用服务keys public Set<String> getAvailableZones() { return upServerListZoneMap.keySet(); }
可以看到ribbon是在LoadBalancerStats中维护了一个map来缓存所有可用服务,而问题的原因也大概明了了:gateway获取到了服务变更事件,但并没有及时更新ribbon的服务列表缓存
ribbon的刷新缓存机制
现在的实际情况是:gateway获取到了服务变更事件,但并没有马上更新ribbon的服务列表缓存,但过一段时间可以访问说明缓存又刷新了,那么接下来就要找到ribbon的缓存怎么刷新的,进而进一步分析为什么没有及时刷新
在LoadBalancerStats查找到更新缓存的方法是updateZoneServerMapping
public class LoadBalancerStats implements IClientConfigAware { // 一个缓存所有服务的map volatile Map<String, List<? extends Server>> upServerListZoneMap = new ConcurrentHashMap<String, List<? extends Server>>(); // 更新缓存 public void updateZoneServerMapping(Map<String, List<Server>> map) { upServerListZoneMap = new ConcurrentHashMap<String, List<? extends Server>>(map); // make sure ZoneStats object exist for available zones for monitoring purpose for (String zone: map.keySet()) { getZoneStats(zone); } }
那么接下来看看这个方法的调用链,调用链有点长,最终找到了DynamicServerListLoadBalancer下的updateListOfServers方法,首先看DynamicServerListLoadBalancer翻译过来"动态服务列表负载均衡器",说明它有动态获取服务列表的功能,那我们的bug它肯定难辞其咎,而updateListOfServers就是它刷新缓存的手段,那么就看看这个所谓的"动态服务列表负载均衡器"是如何使用updateListOfServers动态刷新缓存的
public class DynamicServerListLoadBalancer<T extends Server> extends BaseLoadBalancer { // 封装成一个回调 protected final ServerListUpdater.UpdateAction updateAction = new ServerListUpdater.UpdateAction() { @Override public void doUpdate() { updateListOfServers(); } }; // 初始化 public DynamicServerListLoadBalancer(IClientConfig clientConfig, IRule rule, IPing ping, ServerList<T> serverList, ServerListFilter<T> filter, ServerListUpdater serverListUpdater) { ... this.serverListUpdater = serverListUpdater; // serverListUpdate赋值 ... // 初始化时刷新服务 restOfInit(clientConfig); } void restOfInit(IClientConfig clientConfig) { ... // 开启动态刷新缓存 enableAndInitLearnNewServersFeature(); // 首先刷新一遍缓存 updateListOfServers(); ... } // 开启动态刷新缓存 public void enableAndInitLearnNewServersFeature() { // 把更新的方法传递给serverListUpdater serverListUpdater.start(updateAction); }
可以看到初始化DynamicServerListLoadBalancer时,首先updateListOfServers获取了一次服务列表并缓存,这只能保证项目启动获取一次服务列表,而真正的动态更新实现是把updateListOfServers方法传递给内部serverListUpdater.start方法,serverListUpdater翻译过来就是“服务列表更新器”,所以再理一下思路:
DynamicServerListLoadBalancer只所以敢自称“动态服务列表负载均衡器”,是因为它内部有个serverListUpdater(“服务列表更新器”),也就是serverListUpdater.start才是真正为ribbon提供动态更新服务列表的方法,也就是罪魁祸首
那么就看看ServerListUpdater到底是怎么实现的动态更新,首先ServerListUpdater是一个接口,它的实现也只有一个PollingServerListUpdater,那么肯定是它了,看一下它的start方法实现
public class PollingServerListUpdater implements ServerListUpdater { @Override public synchronized void start(final UpdateAction updateAction) { if (isActive.compareAndSet(false, true)) { // 定义一个runable,运行doUpdate放 final Runnable wrapperRunnable = new Runnable() { @Override public void run() { .... try { updateAction.doUpdate(); // 执行更新服务列表方法 lastUpdated = System.currentTimeMillis(); } catch (Exception e) { logger.warn("Failed one update cycle", e); } } }; // 定时执行 scheduledFuture = getRefreshExecutor().scheduleWithFixedDelay( wrapperRunnable, initialDelayMs, refreshIntervalMs, // 默认30 * 1000 TimeUnit.MILLISECONDS ); } else { logger.info("Already active, no-op"); } }
至此真相大白了,原来ribbon默认更新服务列表依靠的是定时任务,而且默认30秒一次,也就是说假如某个服务重启了,gateway的nacos客户端也感知到了,但是ribbon内部极端情况需要30秒才会重新获取服务列表,这也就解释了为什么会有那么长时间的503 Service Unavailable问题
而且因为定时任务,所以等待时间是0-30秒不等,有可能你刚重启完就获取了正常调用没问题,也有可能刚重启完时刚获取完一次,结果就得等30秒才能访问到新的节点
解决思路
问题的原因找到了,接下来就是解决了,最简单暴力的方式莫过于修改定时任务的间隔时间,默认30秒,可以改成10秒,5秒,1秒,只要你机器配置够牛逼
但是有没有更优雅的解决方案,我们的gateway明明已经感知到服务的变化,如果通知ribbon直接更新,问题不就完美解决了吗,这种思路定时任务都可以去掉了,性能还优化了
具体解决步骤如下
- 写一个新的更新器,替换掉默认的PollingServerListUpdater更新器 更新器可以监听nacos的服务更新 收到服务更新事件时,调用doUpdate方法更新ribbon缓存
接下来一步步解决
首先看上面DynamicServerListLoadBalancer的代码,发现更新器是构造方法传入的,所以要找到构造方法的调用并替换成自己信息的更新器
在DynamicServerListLoadBalancer构造方法上打了个断点,看看它是如何被初始化的(并不是gateway启动就会初始化,而是首次调用某个服务,给对应的服务创建一个LoadBalancer,有点懒加载的意思)
构造方法断点
debugger
看一下debugger的函数调用,发现一个doCreateBean>>>createBeanInstance的调用,其中createBeanInstance执行到如下地方
createBeanInstance
熟悉spring源码的朋友应该看得出来DynamicServerListLoadBalancer是spring容器负责创建的,而且是FactoryBean模式。
这个bean的定义在spring-cloud-netflix-ribbon依赖中的RibbonClientConfiguration类
package org.springframework.cloud.netflix.ribbon; @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties @Import({ HttpClientConfiguration.class, OkHttpRibbonConfiguration.class, RestClientRibbonConfiguration.class, HttpClientRibbonConfiguration.class }) public class RibbonClientConfiguration { ... @Bean @ConditionalOnMissingBean public ServerListUpdater ribbonServerListUpdater(IClientConfig config) { return new PollingServerListUpdater(config); } ... }
也就是通过我们熟知的@Configuration+@Bean模式创建的PollingServerListUpdater更新器,而且加了个注解@ConditionalOnMissingBean
也就是说我们自己实现一个ServerListUpdater更新器,并加入spring容器,就可以代替PollingServerListUpdater成为ribbon的更新器
最终解决方案
我们的更新器是要订阅nacos的,收到事件做update处理,为了避免ribbon和nacos耦合抽象一个监听器再用nacos实现
1.抽象监听器
/** * @Author pq * @Date 2022/4/26 17:19 * @Description 抽象监听器 */ public interface ServerListListener { /** * 监听 * @param serviceId 服务名 * @param eventHandler 回调 */ void listen(String serviceId, ServerEventHandler eventHandler); @FunctionalInterface interface ServerEventHandler { void update(); } }
自定义ServerListUpdater
public class NotificationServerListUpdater implements ServerListUpdater { private static final Logger logger = LoggerFactory.getLogger(NotificationServerListUpdater.class); private final ServerListListener listener; public NotificationServerListUpdater(ServerListListener listener) { this.listener = listener; } /** * 开始运行 * @param updateAction */ @Override public void start(UpdateAction updateAction) { // 创建监听 String clientName = getClientName(updateAction); listener.listen(clientName, ()-> { logger.info("{} 服务变化, 主动刷新服务列表缓存", clientName); // 回调直接更新 updateAction.doUpdate(); }); } /** * 通过updateAction获取服务名,这种方法比较粗暴 * @param updateAction * @return */ private String getClientName(UpdateAction updateAction) { try { Class<?> bc = updateAction.getClass(); Field field = bc.getDeclaredField("this$0"); field.setAccessible(true); BaseLoadBalancer baseLoadBalancer = (BaseLoadBalancer) field.get(updateAction); return baseLoadBalancer.getClientConfig().getClientName(); } catch (Exception e) { e.printStackTrace(); throw new IllegalStateException(e); } }
实现ServerListListener监控nacos并注入bean容器
@Slf4j @Component public class NacosServerListListener implements ServerListListener { @Autowired private NacosServiceManager nacosServiceManager; private NamingService namingService; @Autowired private NacosDiscoveryProperties properties; @PostConstruct public void init() { namingService = nacosServiceManager.getNamingService(properties.getNacosProperties()); } /** * 创建监听器 */ @Override public void listen(String serviceId, ServerEventHandler eventHandler) { try { namingService.subscribe(serviceId, event -> { if (event instanceof NamingEvent) { NamingEvent namingEvent = (NamingEvent) event; // log.info("服务名:" + namingEvent.getServiceName()); // log.info("实例:" + namingEvent.getInstances()); // 实际更新 eventHandler.update(); } }); } catch (NacosException e) { e.printStackTrace(); } } }
把自定义Updater注入bean
@Configuration @ConditionalOnRibbonNacos public class RibbonConfig { @Bean public ServerListUpdater ribbonServerListUpdater(NacosServerListListener listener) { return new NotificationServerListUpdater(listener); } }
到此,大工告成,效果是gateway访问的某微服务停止后,调用马上503,启动后,马上可以调用
总结
本来想解决这个问题首先想到的是nacos或ribbon肯定留了扩展,比如说改了配置就可以平滑感知服务下线,但结果看了文档和源码,并没有发现对应的扩展点,所以只能大动干戈来解决问题,其实很多地方都觉得很粗暴,比如获取clientName,但也实在找不到更好的方案,如果谁知道,麻烦评论告诉我一下
实际上我的项目更新器还保留了定时任务刷新的逻辑,一来刚接触cloud对自己的修改自信不足,二来发现nacos的通知都是udp的通知方式,可能不可靠,不知道是否多余
nacos的监听主要使用namingService的subscribe方法,里面还有坑,还有一层缓存,以后细讲
标签: 来源:
本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享; 2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关; 3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关; 4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除; 5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。