問題描述
項目使用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)的輸出


這說明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錯誤輸出前,有一行這樣的日志
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,有點懶加載的意思)


看一下debugger的函數(shù)調(diào)用,發(fā)現(xiàn)一個
doCreateBean>>>createBeanInstance的調(diào)用,其中createBeanInstance執(zhí)行到如下地方
熟悉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方法,里面還有坑,還有一層緩存,以后細講