問(wèn)題描述
項(xiàng)目使用spring cloud gateway作為網(wǎng)關(guān),nacos作為微服務(wù)注冊(cè)中心侨艾,項(xiàng)目搭建好后正常訪問(wèn)都沒(méi)問(wèn)題,但是有個(gè)很煩人的小瑕疵:
- 當(dāng)某個(gè)微服務(wù)重啟后,通過(guò)網(wǎng)關(guān)調(diào)用這個(gè)服務(wù)時(shí)有時(shí)會(huì)出現(xiàn)
503 Service Unavailable(服務(wù)不可用)
的錯(cuò)誤瞳腌,但過(guò)了一會(huì)兒又可以訪問(wèn)了,這個(gè)等待時(shí)間有時(shí)很長(zhǎng)有時(shí)很短镜雨,甚至有時(shí)候還不會(huì)出現(xiàn) - 導(dǎo)致每次重啟某個(gè)項(xiàng)目都要順便啟動(dòng)gateway項(xiàng)目才能保證立即可以訪問(wèn)嫂侍,時(shí)間長(zhǎng)了感覺(jué)好累,想徹底研究下為什么,并徹底解決
接下來(lái)介紹我在解決整個(gè)過(guò)程的思路挑宠,如果沒(méi)興趣菲盾,可以直接跳到最后的最終解決方案
gateway感知其它服務(wù)上下線
首先在某個(gè)微服務(wù)上下線時(shí),gateway的控制臺(tái)可以立即看到有對(duì)應(yīng)的輸出
這說(shuō)明nacos提供了這種監(jiān)聽功能各淀,在注冊(cè)中心服務(wù)列表發(fā)生時(shí)可以第一時(shí)間通知客戶端懒鉴,而在我們的依賴spring-cloud-starter-alibaba-nacos-discovery
中顯然已經(jīng)幫我們實(shí)現(xiàn)了這個(gè)監(jiān)聽
所以也就說(shuō)明gateway是可以立即感知其它服務(wù)的上下線事件,但問(wèn)題是明明感知到某個(gè)服務(wù)的上線碎浇,那為什么會(huì)出現(xiàn)503 Service Unavailable
的錯(cuò)誤临谱,而且上面的輸出有時(shí)出現(xiàn)了很久,但調(diào)用依然是503 Service Unavailable
奴璃,對(duì)應(yīng)的某服務(wù)明明下線悉默,這是應(yīng)該是503 Service Unavailable
狀態(tài),可有時(shí)確會(huì)有一定時(shí)間的500
錯(cuò)誤
ribbon
為了調(diào)查事情的真相苟穆,我打開了gateway的debug日志模式抄课,找到了503的罪魁禍?zhǔn)?br>
在503錯(cuò)誤輸出前,有一行這樣的日志
Zone aware logic disabled or there is only one zone
雳旅,而報(bào)這個(gè)信息的包就是ribbon-loadbalancer跟磨,也就是gateway默認(rèn)所使用的負(fù)載均衡器
我的gateway配置文件路由方面設(shè)置如下
routes:
- id: auth
uri: lb://demo-auth
predicates:
- Path=/auth/**
filters:
- StripPrefix=1
其中在uri這一行,使用了lb:// ,代表使用了gateway的ribbon負(fù)載均衡功能岭辣,官方文檔說(shuō)明如下
Note that this example also demonstrates (optional) Spring Cloud Netflix Ribbon load-balancing (defined the lb prefix on the destination URI)
ribbon再調(diào)用時(shí)首先會(huì)獲取所有服務(wù)列表(ip和端口信息)吱晒,然后根據(jù)負(fù)載均衡策略調(diào)用其中一個(gè)服務(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ù)是空列表仑濒,說(shuō)明這里沒(méi)有存儲(chǔ)任何服務(wù)信息,所以才導(dǎo)致最終的503 Service Unavailable
繼續(xù)跟進(jìn)去看getAvailableZones
的代碼偷遗,如下
public class LoadBalancerStats implements IClientConfigAware {
// 一個(gè)緩存所有服務(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中維護(hù)了一個(gè)map來(lái)緩存所有可用服務(wù)墩瞳,而問(wèn)題的原因也大概明了了:gateway獲取到了服務(wù)變更事件,但并沒(méi)有及時(shí)更新ribbon的服務(wù)列表緩存
ribbon的刷新緩存機(jī)制
現(xiàn)在的實(shí)際情況是:gateway獲取到了服務(wù)變更事件氏豌,但并沒(méi)有馬上更新ribbon的服務(wù)列表緩存喉酌,但過(guò)一段時(shí)間可以訪問(wèn)說(shuō)明緩存又刷新了,那么接下來(lái)就要找到ribbon的緩存怎么刷新的泵喘,進(jìn)而進(jìn)一步分析為什么沒(méi)有及時(shí)刷新
在LoadBalancerStats查找到更新緩存的方法是updateZoneServerMapping
public class LoadBalancerStats implements IClientConfigAware {
// 一個(gè)緩存所有服務(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);
}
}
那么接下來(lái)看看這個(gè)方法的調(diào)用鏈泪电,調(diào)用鏈有點(diǎn)長(zhǎng),最終找到了DynamicServerListLoadBalancer
下的updateListOfServers
方法纪铺,首先看DynamicServerListLoadBalancer
翻譯過(guò)來(lái)"動(dòng)態(tài)服務(wù)列表負(fù)載均衡器"相速,說(shuō)明它有動(dòng)態(tài)獲取服務(wù)列表的功能,那我們的bug它肯定難辭其咎鲜锚,而updateListOfServers
就是它刷新緩存的手段突诬,那么就看看這個(gè)所謂的"動(dòng)態(tài)服務(wù)列表負(fù)載均衡器"是如何使用updateListOfServers
動(dòng)態(tài)刷新緩存的
public class DynamicServerListLoadBalancer<T extends Server> extends BaseLoadBalancer {
// 封裝成一個(gè)回調(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賦值
...
// 初始化時(shí)刷新服務(wù)
restOfInit(clientConfig);
}
void restOfInit(IClientConfig clientConfig) {
...
// 開啟動(dòng)態(tài)刷新緩存
enableAndInitLearnNewServersFeature();
// 首先刷新一遍緩存
updateListOfServers();
...
}
// 開啟動(dòng)態(tài)刷新緩存
public void enableAndInitLearnNewServersFeature() {
// 把更新的方法傳遞給serverListUpdater
serverListUpdater.start(updateAction);
}
可以看到初始化DynamicServerListLoadBalancer時(shí)苫拍,首先updateListOfServers獲取了一次服務(wù)列表并緩存,這只能保證項(xiàng)目啟動(dòng)獲取一次服務(wù)列表旺隙,而真正的動(dòng)態(tài)更新實(shí)現(xiàn)是把updateListOfServers方法傳遞給內(nèi)部serverListUpdater.start
方法绒极,serverListUpdater翻譯過(guò)來(lái)就是“服務(wù)列表更新器”,所以再理一下思路:
DynamicServerListLoadBalancer只所以敢自稱“動(dòng)態(tài)服務(wù)列表負(fù)載均衡器”蔬捷,是因?yàn)樗鼉?nèi)部有個(gè)serverListUpdater(“服務(wù)列表更新器”)垄提,也就是serverListUpdater.start
才是真正為ribbon提供動(dòng)態(tài)更新服務(wù)列表的方法,也就是罪魁禍?zhǔn)?/p>
那么就看看ServerListUpdater
到底是怎么實(shí)現(xiàn)的動(dòng)態(tài)更新周拐,首先ServerListUpdater
是一個(gè)接口塔淤,它的實(shí)現(xiàn)也只有一個(gè)PollingServerListUpdater,那么肯定是它了速妖,看一下它的start
方法實(shí)現(xiàn)
public class PollingServerListUpdater implements ServerListUpdater {
@Override
public synchronized void start(final UpdateAction updateAction) {
if (isActive.compareAndSet(false, true)) {
// 定義一個(gè)runable高蜂,運(yùn)行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);
}
}
};
// 定時(shí)執(zhí)行
scheduledFuture = getRefreshExecutor().scheduleWithFixedDelay(
wrapperRunnable,
initialDelayMs,
refreshIntervalMs, // 默認(rèn)30 * 1000
TimeUnit.MILLISECONDS
);
} else {
logger.info("Already active, no-op");
}
}
至此真相大白了,原來(lái)ribbon默認(rèn)更新服務(wù)列表依靠的是定時(shí)任務(wù)罕容,而且默認(rèn)30秒一次备恤,也就是說(shuō)假如某個(gè)服務(wù)重啟了,gateway的nacos客戶端也感知到了锦秒,但是ribbon內(nèi)部極端情況需要30秒才會(huì)重新獲取服務(wù)列表露泊,這也就解釋了為什么會(huì)有那么長(zhǎng)時(shí)間的503 Service Unavailable
問(wèn)題
而且因?yàn)槎〞r(shí)任務(wù),所以等待時(shí)間是0-30秒不等旅择,有可能你剛重啟完就獲取了正常調(diào)用沒(méi)問(wèn)題惭笑,也有可能剛重啟完時(shí)剛獲取完一次,結(jié)果就得等30秒才能訪問(wèn)到新的節(jié)點(diǎn)
解決思路
問(wèn)題的原因找到了生真,接下來(lái)就是解決了沉噩,最簡(jiǎn)單暴力的方式莫過(guò)于修改定時(shí)任務(wù)的間隔時(shí)間,默認(rèn)30秒柱蟀,可以改成10秒川蒙,5秒,1秒长已,只要你機(jī)器配置夠牛逼
但是有沒(méi)有更優(yōu)雅的解決方案畜眨,我們的gateway明明已經(jīng)感知到服務(wù)的變化,如果通知ribbon直接更新术瓮,問(wèn)題不就完美解決了嗎康聂,這種思路定時(shí)任務(wù)都可以去掉了,性能還優(yōu)化了
具體解決步驟如下
- 寫一個(gè)新的更新器胞四,替換掉默認(rèn)的PollingServerListUpdater更新器
- 更新器可以監(jiān)聽nacos的服務(wù)更新
- 收到服務(wù)更新事件時(shí)恬汁,調(diào)用doUpdate方法更新ribbon緩存
接下來(lái)一步步解決
首先看上面DynamicServerListLoadBalancer的代碼,發(fā)現(xiàn)更新器是構(gòu)造方法傳入的撬讽,所以要找到構(gòu)造方法的調(diào)用并替換成自己信息的更新器
在DynamicServerListLoadBalancer構(gòu)造方法上打了個(gè)斷點(diǎn)蕊连,看看它是如何被初始化的(并不是gateway啟動(dòng)就會(huì)初始化,而是首次調(diào)用某個(gè)服務(wù)游昼,給對(duì)應(yīng)的服務(wù)創(chuàng)建一個(gè)LoadBalancer甘苍,有點(diǎn)懶加載的意思)
看一下debugger的函數(shù)調(diào)用,發(fā)現(xiàn)一個(gè)
doCreateBean>>>createBeanInstance
的調(diào)用烘豌,其中createBeanInstance執(zhí)行到如下地方熟悉spring源碼的朋友應(yīng)該看得出來(lái)DynamicServerListLoadBalancer是spring容器負(fù)責(zé)創(chuàng)建的载庭,而且是FactoryBean模式。
這個(gè)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);
}
...
}
也就是通過(guò)我們熟知的@Configuration+@Bean模式創(chuàng)建的PollingServerListUpdater更新器廊佩,而且加了個(gè)注解@ConditionalOnMissingBean
也就是說(shuō)我們自己實(shí)現(xiàn)一個(gè)ServerListUpdater更新器囚聚,并加入spring容器,就可以代替PollingServerListUpdater成為ribbon的更新器
最終解決方案
我們的更新器是要訂閱nacos的标锄,收到事件做update處理顽铸,為了避免ribbon和nacos耦合抽象一個(gè)監(jiān)聽器再用nacos實(shí)現(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;
}
/**
* 開始運(yùn)行
* @param updateAction
*/
@Override
public void start(UpdateAction updateAction) {
// 創(chuàng)建監(jiān)聽
String clientName = getClientName(updateAction);
listener.listen(clientName, ()-> {
logger.info("{} 服務(wù)變化, 主動(dòng)刷新服務(wù)列表緩存", clientName);
// 回調(diào)直接更新
updateAction.doUpdate();
});
}
/**
* 通過(guò)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);
}
}
實(shí)現(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("實(shí)例:" + namingEvent.getInstances());
// 實(shí)際更新
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èn)的某微服務(wù)停止后,調(diào)用馬上503践剂,啟動(dòng)后鬼譬,馬上可以調(diào)用
總結(jié)
本來(lái)想解決這個(gè)問(wèn)題首先想到的是nacos或ribbon肯定留了擴(kuò)展,比如說(shuō)改了配置就可以平滑感知服務(wù)下線逊脯,但結(jié)果看了文檔和源碼优质,并沒(méi)有發(fā)現(xiàn)對(duì)應(yīng)的擴(kuò)展點(diǎn),所以只能大動(dòng)干戈來(lái)解決問(wèn)題军洼,其實(shí)很多地方都覺(jué)得很粗暴巩螃,比如獲取clientName,但也實(shí)在找不到更好的方案匕争,如果誰(shuí)知道牺六,麻煩評(píng)論告訴我一下
實(shí)際上我的項(xiàng)目更新器還保留了定時(shí)任務(wù)刷新的邏輯,一來(lái)剛接觸cloud對(duì)自己的修改自信不足汗捡,二來(lái)發(fā)現(xiàn)nacos的通知都是udp的通知方式淑际,可能不可靠,不知道是否多余
nacos的監(jiān)聽主要使用namingService的subscribe方法扇住,里面還有坑春缕,還有一層緩存,以后細(xì)講