解決gateway使用nacos重啟報503 Service Unavailable問題

問題描述

項目使用spring cloud gateway作為網(wǎng)關(guān),nacos作為微服務(wù)注冊中心,項目搭建好后正常訪問都沒問題,但是有個很煩人的小瑕疵:

  • 當(dāng)某個微服務(wù)重啟后,通過網(wǎng)關(guān)調(diào)用這個服務(wù)時有時會出現(xiàn)503 Service Unavailable(服務(wù)不可用)的錯誤,但過了一會兒又可以訪問了,這個等待時間有時很長有時很短,甚至有時候還不會出現(xiàn)
  • 導(dǎo)致每次重啟某個項目都要順便啟動gateway項目才能保證立即可以訪問,時間長了感覺好累,想徹底研究下為什么,并徹底解決

接下來介紹我在解決整個過程的思路,如果沒興趣,可以直接跳到最后的最終解決方案

gateway感知其它服務(wù)上下線

首先在某個微服務(wù)上下線時,gateway的控制臺可以立即看到有對應(yīng)的輸出


某服務(wù)下線gateway輸出
某服務(wù)上線gateway輸出

這說明nacos提供了這種監(jiān)聽功能,在注冊中心服務(wù)列表發(fā)生時可以第一時間通知客戶端,而在我們的依賴spring-cloud-starter-alibaba-nacos-discovery中顯然已經(jīng)幫我們實現(xiàn)了這個監(jiān)聽

所以也就說明gateway是可以立即感知其它服務(wù)的上下線事件,但問題是明明感知到某個服務(wù)的上線,那為什么會出現(xiàn)503 Service Unavailable的錯誤,而且上面的輸出有時出現(xiàn)了很久,但調(diào)用依然是503 Service Unavailable,對應(yīng)的某服務(wù)明明下線,這是應(yīng)該是503 Service Unavailable狀態(tài),可有時確會有一定時間的500錯誤

ribbon

為了調(diào)查事情的真相,我打開了gateway的debug日志模式,找到了503的罪魁禍?zhǔn)?br>

503的控制臺輸出

在503錯誤輸出前,有一行這樣的日志Zone aware logic disabled or there is only one zone,而報這個信息的包就是ribbon-loadbalancer,也就是gateway默認所使用的負載均衡器

我的gateway配置文件路由方面設(shè)置如下

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再調(diào)用時首先會獲取所有服務(wù)列表(ip和端口信息),然后根據(jù)負載均衡策略調(diào)用其中一個服務(wù),選擇服務(wù)的代碼如下

package com.netflix.loadbalancer;
public class ZoneAwareLoadBalancer<T extends Server> extends DynamicServerListLoadBalancer<T> {
    // 選擇服務(wù)的方法
    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..這行日志的出處,經(jīng)調(diào)試發(fā)現(xiàn)在getLoadBalancerStats().getAvailableZones()這一步返回的服務(wù)是空列表,說明這里沒有存儲任何服務(wù)信息,所以才導(dǎo)致最終的503 Service Unavailable
繼續(xù)跟進去看getAvailableZones的代碼,如下

public class LoadBalancerStats implements IClientConfigAware {
    // 一個緩存所有服務(wù)的map
    volatile Map<String, List<? extends Server>> upServerListZoneMap = new ConcurrentHashMap<String, List<? extends Server>>();
    // 獲取可用服務(wù)keys
    public Set<String> getAvailableZones() {
        return upServerListZoneMap.keySet();
    }

可以看到ribbon是在LoadBalancerStats中維護了一個map來緩存所有可用服務(wù),而問題的原因也大概明了了:gateway獲取到了服務(wù)變更事件,但并沒有及時更新ribbon的服務(wù)列表緩存

ribbon的刷新緩存機制

現(xiàn)在的實際情況是:gateway獲取到了服務(wù)變更事件,但并沒有馬上更新ribbon的服務(wù)列表緩存,但過一段時間可以訪問說明緩存又刷新了,那么接下來就要找到ribbon的緩存怎么刷新的,進而進一步分析為什么沒有及時刷新

在LoadBalancerStats查找到更新緩存的方法是updateZoneServerMapping

public class LoadBalancerStats implements IClientConfigAware {
    // 一個緩存所有服務(wù)的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);
        }
    }

那么接下來看看這個方法的調(diào)用鏈,調(diào)用鏈有點長,最終找到了DynamicServerListLoadBalancer下的updateListOfServers方法,首先看DynamicServerListLoadBalancer翻譯過來"動態(tài)服務(wù)列表負載均衡器",說明它有動態(tài)獲取服務(wù)列表的功能,那我們的bug它肯定難辭其咎,而updateListOfServers就是它刷新緩存的手段,那么就看看這個所謂的"動態(tài)服務(wù)列表負載均衡器"是如何使用updateListOfServers動態(tài)刷新緩存的

public class DynamicServerListLoadBalancer<T extends Server> extends BaseLoadBalancer {
    // 封裝成一個回調(diào)
    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賦值
        ...
        // 初始化時刷新服務(wù)
        restOfInit(clientConfig);
    }
    
    void restOfInit(IClientConfig clientConfig) {
        ...
        // 開啟動態(tài)刷新緩存
        enableAndInitLearnNewServersFeature();
        // 首先刷新一遍緩存
        updateListOfServers();
        ...
    }
    
    // 開啟動態(tài)刷新緩存
    public void enableAndInitLearnNewServersFeature() {
        // 把更新的方法傳遞給serverListUpdater
        serverListUpdater.start(updateAction);
    }

可以看到初始化DynamicServerListLoadBalancer時,首先updateListOfServers獲取了一次服務(wù)列表并緩存,這只能保證項目啟動獲取一次服務(wù)列表,而真正的動態(tài)更新實現(xiàn)是把updateListOfServers方法傳遞給內(nèi)部serverListUpdater.start方法,serverListUpdater翻譯過來就是“服務(wù)列表更新器”,所以再理一下思路:

DynamicServerListLoadBalancer只所以敢自稱“動態(tài)服務(wù)列表負載均衡器”,是因為它內(nèi)部有個serverListUpdater(“服務(wù)列表更新器”),也就是serverListUpdater.start才是真正為ribbon提供動態(tài)更新服務(wù)列表的方法,也就是罪魁禍?zhǔn)?/p>

那么就看看ServerListUpdater到底是怎么實現(xiàn)的動態(tài)更新,首先ServerListUpdater是一個接口,它的實現(xiàn)也只有一個PollingServerListUpdater,那么肯定是它了,看一下它的start方法實現(xiàn)

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(); // 執(zhí)行更新服務(wù)列表方法
                        lastUpdated = System.currentTimeMillis();
                    } catch (Exception e) {
                        logger.warn("Failed one update cycle", e);
                    }
                }
            };

            // 定時執(zhí)行
            scheduledFuture = getRefreshExecutor().scheduleWithFixedDelay(
                    wrapperRunnable,
                    initialDelayMs,
                    refreshIntervalMs, // 默認30 * 1000
                    TimeUnit.MILLISECONDS
            );
        } else {
            logger.info("Already active, no-op");
        }
    }

至此真相大白了,原來ribbon默認更新服務(wù)列表依靠的是定時任務(wù),而且默認30秒一次,也就是說假如某個服務(wù)重啟了,gateway的nacos客戶端也感知到了,但是ribbon內(nèi)部極端情況需要30秒才會重新獲取服務(wù)列表,這也就解釋了為什么會有那么長時間的503 Service Unavailable問題

而且因為定時任務(wù),所以等待時間是0-30秒不等,有可能你剛重啟完就獲取了正常調(diào)用沒問題,也有可能剛重啟完時剛獲取完一次,結(jié)果就得等30秒才能訪問到新的節(jié)點

解決思路

問題的原因找到了,接下來就是解決了,最簡單暴力的方式莫過于修改定時任務(wù)的間隔時間,默認30秒,可以改成10秒,5秒,1秒,只要你機器配置夠牛逼

但是有沒有更優(yōu)雅的解決方案,我們的gateway明明已經(jīng)感知到服務(wù)的變化,如果通知ribbon直接更新,問題不就完美解決了嗎,這種思路定時任務(wù)都可以去掉了,性能還優(yōu)化了

具體解決步驟如下

  • 寫一個新的更新器,替換掉默認的PollingServerListUpdater更新器
  • 更新器可以監(jiān)聽nacos的服務(wù)更新
  • 收到服務(wù)更新事件時,調(diào)用doUpdate方法更新ribbon緩存

接下來一步步解決

首先看上面DynamicServerListLoadBalancer的代碼,發(fā)現(xiàn)更新器是構(gòu)造方法傳入的,所以要找到構(gòu)造方法的調(diào)用并替換成自己信息的更新器

在DynamicServerListLoadBalancer構(gòu)造方法上打了個斷點,看看它是如何被初始化的(并不是gateway啟動就會初始化,而是首次調(diào)用某個服務(wù),給對應(yīng)的服務(wù)創(chuàng)建一個LoadBalancer,有點懶加載的意思)

構(gòu)造方法斷點

debugger

看一下debugger的函數(shù)調(diào)用,發(fā)現(xiàn)一個doCreateBean>>>createBeanInstance的調(diào)用,其中createBeanInstance執(zhí)行到如下地方
createBeanInstance

熟悉spring源碼的朋友應(yīng)該看得出來DynamicServerListLoadBalancer是spring容器負責(zé)創(chuàng)建的,而且是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模式創(chuàng)建的PollingServerListUpdater更新器,而且加了個注解@ConditionalOnMissingBean

也就是說我們自己實現(xiàn)一個ServerListUpdater更新器,并加入spring容器,就可以代替PollingServerListUpdater成為ribbon的更新器

最終解決方案

我們的更新器是要訂閱nacos的,收到事件做update處理,為了避免ribbon和nacos耦合抽象一個監(jiān)聽器再用nacos實現(xiàn)

1.抽象監(jiān)聽器
/**
 * @Author pq
 * @Date 2022/4/26 17:19
 * @Description 抽象監(jiān)聽器
 */
public interface ServerListListener {
    /**
     * 監(jiān)聽
     * @param serviceId 服務(wù)名
     * @param eventHandler 回調(diào)
     */
    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) {
        // 創(chuàng)建監(jiān)聽
        String clientName = getClientName(updateAction);
        listener.listen(clientName, ()-> {
            logger.info("{} 服務(wù)變化, 主動刷新服務(wù)列表緩存", clientName);
            // 回調(diào)直接更新
            updateAction.doUpdate();
        });
    }

    /**
     * 通過updateAction獲取服務(wù)名,這種方法比較粗暴
     * @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);
        }
    }
實現(xiàn)ServerListListener監(jiān)控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());
    }

    /**
     * 創(chuàng)建監(jiān)聽器
     */
    @Override
    public void listen(String serviceId, ServerEventHandler eventHandler) {
        try {
            namingService.subscribe(serviceId, event -> {
                if (event instanceof NamingEvent) {
                    NamingEvent namingEvent = (NamingEvent) event;
//                    log.info("服務(wù)名:" + 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訪問的某微服務(wù)停止后,調(diào)用馬上503,啟動后,馬上可以調(diào)用

總結(jié)

本來想解決這個問題首先想到的是nacos或ribbon肯定留了擴展,比如說改了配置就可以平滑感知服務(wù)下線,但結(jié)果看了文檔和源碼,并沒有發(fā)現(xiàn)對應(yīng)的擴展點,所以只能大動干戈來解決問題,其實很多地方都覺得很粗暴,比如獲取clientName,但也實在找不到更好的方案,如果誰知道,麻煩評論告訴我一下

實際上我的項目更新器還保留了定時任務(wù)刷新的邏輯,一來剛接觸cloud對自己的修改自信不足,二來發(fā)現(xiàn)nacos的通知都是udp的通知方式,可能不可靠,不知道是否多余

nacos的監(jiān)聽主要使用namingService的subscribe方法,里面還有坑,還有一層緩存,以后細講

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容