草庐IT

解决gateway使用nacos重启报503 Service Unavailable问题

jmysql 2023-12-03 原文

问题描述

项目使用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);
            }
    ...     

这就是上面的Zone aware logic..这行日志的出处,经调试发现在getLoadBalancerStats().getAvailableZones()这一步返回的服务是空列表,说明这里没有存储任何服务信息,所以才导致最终的503 Service Unavailable
继续跟进去看getAvailableZones的代码,如下

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方法,里面还有坑,还有一层缓存,以后细讲

2022Java毕业设计项目全套,进来白嫖_哔哩哔哩_bilibili

有关解决gateway使用nacos重启报503 Service Unavailable问题的更多相关文章

  1. ruby - 如何使用 Nokogiri 的 xpath 和 at_xpath 方法 - 2

    我正在学习如何使用Nokogiri,根据这段代码我遇到了一些问题:require'rubygems'require'mechanize'post_agent=WWW::Mechanize.newpost_page=post_agent.get('http://www.vbulletin.org/forum/showthread.php?t=230708')puts"\nabsolutepathwithtbodygivesnil"putspost_page.parser.xpath('/html/body/div/div/div/div/div/table/tbody/tr/td/div

  2. ruby - 使用 RubyZip 生成 ZIP 文件时设置压缩级别 - 2

    我有一个Ruby程序,它使用rubyzip压缩XML文件的目录树。gem。我的问题是文件开始变得很重,我想提高压缩级别,因为压缩时间不是问题。我在rubyzipdocumentation中找不到一种为创建的ZIP文件指定压缩级别的方法。有人知道如何更改此设置吗?是否有另一个允许指定压缩级别的Ruby库? 最佳答案 这是我通过查看ruby​​zip内部创建的代码。level=Zlib::BEST_COMPRESSIONZip::ZipOutputStream.open(zip_file)do|zip|Dir.glob("**/*")d

  3. ruby - 为什么我可以在 Ruby 中使用 Object#send 访问私有(private)/ protected 方法? - 2

    类classAprivatedeffooputs:fooendpublicdefbarputs:barendprivatedefzimputs:zimendprotecteddefdibputs:dibendendA的实例a=A.new测试a.foorescueputs:faila.barrescueputs:faila.zimrescueputs:faila.dibrescueputs:faila.gazrescueputs:fail测试输出failbarfailfailfail.发送测试[:foo,:bar,:zim,:dib,:gaz].each{|m|a.send(m)resc

  4. ruby-on-rails - 使用 Ruby on Rails 进行自动化测试 - 最佳实践 - 2

    很好奇,就使用ruby​​onrails自动化单元测试而言,你们正在做什么?您是否创建了一个脚本来在cron中运行rake作业并将结果邮寄给您?git中的预提交Hook?只是手动调用?我完全理解测试,但想知道在错误发生之前捕获错误的最佳实践是什么。让我们理所当然地认为测试本身是完美无缺的,并且可以正常工作。下一步是什么以确保他们在正确的时间将可能有害的结果传达给您? 最佳答案 不确定您到底想听什么,但是有几个级别的自动代码库控制:在处理某项功能时,您可以使用类似autotest的内容获得关于哪些有效,哪些无效的即时反馈。要确保您的提

  5. ruby - 在 Ruby 中使用匿名模块 - 2

    假设我做了一个模块如下:m=Module.newdoclassCendend三个问题:除了对m的引用之外,还有什么方法可以访问C和m中的其他内容?我可以在创建匿名模块后为其命名吗(就像我输入“module...”一样)?如何在使用完匿名模块后将其删除,使其定义的常量不再存在? 最佳答案 三个答案:是的,使用ObjectSpace.此代码使c引用你的类(class)C不引用m:c=nilObjectSpace.each_object{|obj|c=objif(Class===objandobj.name=~/::C$/)}当然这取决于

  6. ruby - 使用 ruby​​ 和 savon 的 SOAP 服务 - 2

    我正在尝试使用ruby​​和Savon来使用网络服务。测试服务为http://www.webservicex.net/WS/WSDetails.aspx?WSID=9&CATID=2require'rubygems'require'savon'client=Savon::Client.new"http://www.webservicex.net/stockquote.asmx?WSDL"client.get_quotedo|soap|soap.body={:symbol=>"AAPL"}end返回SOAP异常。检查soap信封,在我看来soap请求没有正确的命名空间。任何人都可以建议我

  7. python - 如何使用 Ruby 或 Python 创建一系列高音调和低音调的蜂鸣声? - 2

    关闭。这个问题是opinion-based.它目前不接受答案。想要改进这个问题?更新问题,以便editingthispost可以用事实和引用来回答它.关闭4年前。Improvethisquestion我想在固定时间创建一系列低音和高音调的哔哔声。例如:在150毫秒时发出高音调的蜂鸣声在151毫秒时发出低音调的蜂鸣声200毫秒时发出低音调的蜂鸣声250毫秒的高音调蜂鸣声有没有办法在Ruby或Python中做到这一点?我真的不在乎输出编码是什么(.wav、.mp3、.ogg等等),但我确实想创建一个输出文件。

  8. ruby-on-rails - 'compass watch' 是如何工作的/它是如何与 rails 一起使用的 - 2

    我在我的项目目录中完成了compasscreate.和compassinitrails。几个问题:我已将我的.sass文件放在public/stylesheets中。这是放置它们的正确位置吗?当我运行compasswatch时,它不会自动编译这些.sass文件。我必须手动指定文件:compasswatchpublic/stylesheets/myfile.sass等。如何让它自动运行?文件ie.css、print.css和screen.css已放在stylesheets/compiled。如何在编译后不让它们重新出现的情况下删除它们?我自己编译的.sass文件编译成compiled/t

  9. ruby - 使用 ruby​​ 将 HTML 转换为纯文本并维护结构/格式 - 2

    我想将html转换为纯文本。不过,我不想只删除标签,我想智能地保留尽可能多的格式。为插入换行符标签,检测段落并格式化它们等。输入非常简单,通常是格式良好的html(不是整个文档,只是一堆内容,通常没有anchor或图像)。我可以将几个正则表达式放在一起,让我达到80%,但我认为可能有一些现有的解决方案更智能。 最佳答案 首先,不要尝试为此使用正则表达式。很有可能你会想出一个脆弱/脆弱的解决方案,它会随着HTML的变化而崩溃,或者很难管理和维护。您可以使用Nokogiri快速解析HTML并提取文本:require'nokogiri'h

  10. ruby - 在 64 位 Snow Leopard 上使用 rvm、postgres 9.0、ruby 1.9.2-p136 安装 pg gem 时出现问题 - 2

    我想为Heroku构建一个Rails3应用程序。他们使用Postgres作为他们的数据库,所以我通过MacPorts安装了postgres9.0。现在我需要一个postgresgem并且共识是出于性能原因你想要pggem。但是我对我得到的错误感到非常困惑当我尝试在rvm下通过geminstall安装pg时。我已经非常明确地指定了所有postgres目录的位置可以找到但仍然无法完成安装:$envARCHFLAGS='-archx86_64'geminstallpg--\--with-pg-config=/opt/local/var/db/postgresql90/defaultdb/po

随机推荐