淺談Nacos獲取配置兩次調優經歷
1.Nacos簡介
Nacos(Dynamic Naming and Configuration Service)是一個開源的動態服務發現、配置管理和服務管理平臺,由阿里巴巴集團開發并維護。Nacos致力于幫助用戶發現、配置和管理微服務,通過它提供的簡單易用的特性集,能夠快速實現動態服務發現、服務配置、服務元數據及流量管理。例如Nacos支持配置的動態更新,服務無需重新部署上線即可獲取最新配置,提升了系統的靈活性和響應速度;在多環境配置方面,它提供的Namespace和Group機制,便于管理不同的生產環境(例如測試、預上線和生產環境,每一個環境可通過Namespace和Group機制對同一配置項進行不同的配置)的配置、簡化多環境配置管理;Nacos還支持多種配置格式(如Properties、YAML、JSON等)和流量控制,滿足不同的開發需求。Nacos憑借如上這些優勢,在配置管理領域是一種理想的選擇。
2.Nacos配置中心在實際項目中的應用
Nacos目前廣泛的應用在我們的項目中,并且多數是作為配置管理來應用的,例如管理黑白名單的變更、灰度上線、一些開關的設置等。例如,在2023年北顯機房下線、金山云替換北顯機房的過程中,Nacos在我們的服務中發揮了巨大的作用。因歷史和架構原因,我們的多個服務存在共用同一redis實例的情況,為最大程度避免redis數據不一致的情況,我們在共用redis的服務中引入同一配置項作為切換的開關(前提是北顯機房的redis數據已“實時地”向金山云機房同步),當開關關閉時,所有服務讀寫北顯機房的redis。當開關打開后,所有服務讀寫金山云機房的redis。這相當于做到了所有服務“同時”從讀寫北顯機房的redis切換到讀寫金山云機房的redis,觀察服務沒有問題后,移除開關,實現了機房平穩替換。
Nacos作為配置中心并且實際應用的過程中,我們共發現了兩次問題,這兩個問題都是在流量比較大的時候出現的,下面我們分別來講述兩個問題的表現以及我們是如何針對這兩個問題進行調優的。希望通過這兩次配置調優的經歷,為大家提供一些可選的配置調優方案,以期未來大家在遇到類似問題時,可以嘗試我們的解決方案。
NacosClient獲取Config的常規流程
優化前客戶端獲取配置的流程圖:
圖片
3.兩次調優經歷
為了避免因服務器遷移導致的一些文件路徑問題,我們未配置Nacos本地文件路徑(即我們沒有指定user.home屬性),故而在起初,我們獲取某個dataId配置的時候,都是實時從Server端拉取,也就是在上圖中我們沒有本地緩存的文件。
圖片
圖片
3.1 第一次調優
從流程圖可以看到,我們服務獲取配置都是實時從Nacos Server端拉取,當服務的請求量較大(請求量大,但未被Nacos攔截器攔截住)時,雖然可以獲取到配置,但是服務報了很多超時,因為獲取配置這一步的耗時就已經接近或者超過程序里設定的超時時間(400毫秒)。相關代碼如下所示:
public static String getConfig(String dataId) {
try {
return configService.getConfig(dataId, NACOS_GROUP, 400);
} catch (Throwable e) {
LOGGER.error("getConfig happens error, dataId = {}, group = {} ", dataId, NACOS_GROUP, e);
}
return null;
}我們的一個數字氣泡計算服務灰度上線后,發現了很多關于"getConfig happens error, "的超時錯誤日志。經排查和討論,我們認為,每一個服務的配置項并不是很多,并且配置項多數不會頻繁的變更,即便配置項發生改變,延遲幾秒(Nacos配置變化生效時間幾乎都是毫秒級)對服務也無影響,綜上,我們用機器內存緩存配置來解決這一超時問題。針對還可能出現的超時情況,我們采用了兜底的方式(兜底的代碼幾乎不會執行到,但為了安全起見,我們還是為每一個配置項設置了一個兜底的“值”)。
▲ 第一次優化:內存緩存+兜底配置
我們在服務中為各個配置項注冊監聽器,當配置項發生變更時,我們將已獲取到的新的配置configInfo放入內存Map中,如下圖所示:
圖片
通過這種方式,服務在實際獲取配置的時候,優先從內存緩存中獲取,如果獲取不到再從服務器拉取:
public static String getConfig(String dataId) {
try {
// 優先從map中獲取
String value = cacheMap.get(dataId);
if (StringUtils.isBlank(cacheMap.get(dataId))) {
// 如果map中沒有此配置項,則從服務端拉取,拉取后再放入map中
value = configService.getConfig(dataId, NACOS_GROUP, 100);
cacheMap.put(dataId, value);
}
return value;
} catch (Throwable e) {
LOGGER.error("getConfig happens error, dataId = {}, group = {} ", dataId, NACOS_GROUP, e);
}
return null;
}如果內存緩存里沒有此配置項、并且請求服務端獲取此配置項時超時的話,就用兜底的配置值(這里需要注意的是,兜底的配置值可能需要不斷的調整以備不時之需),相關代碼如下所示:
public static boolean getSwitch() {
try {
// 優先從內存緩存里或者服務端拉取配置
String config = getConfig(SWITCH_DATAID);
if (StringUtils.isNotBlank(config)) {
return"1".equalsIgnoreCase(config);
}
} catch (Exception e) {
LOGGER.error("Error", e);
}
// 兜底值
returnfalse;
}這樣通過內存緩存和兜底的方式,解決了請求Nacos服務端獲取配置超時這一問題。
第一次優化后客戶端獲取配置的流程圖總結如下:
圖片
3.2 第二次調優
經過第一次的調優,Nacos作為配置在我們服務中一直運行的很好。但有一次我們的一個服務在灰度上線重啟時報了一些關于Nacos的錯誤日志,等這臺灰度上線的機器“穩定”后,相關的Nacos報錯日志也就停止了。初步排查我們發現,這些報錯導致其對應請求在獲取Nacos配置時走了兜底,雖然并未造成請求錯誤,但依賴于兜底,在未來可預見的時間內可能“引發”一些未知的問題,所以我們決定繼續排查并解決此問題。
經過各種排查和分析,我們發現服務剛啟動后有并發的流量進來,導致部分請求流量在獲取配置的時候被Nacos攔截器攔截住(獲取配置的qps超過了server端的限流閾值),以至獲取不到最新的配置,報錯日志如下:
15:47:49.708 [nioEventLoopGroup-3-7] ERROR [traceId:0f9bb096-fb01-4251-85f0-e5eb9d747f55] (c.a.n.c.c.i.Limiter:79) - access_key_id:4396fea7a8e259f09e8022caf4105fbd limited
15:47:49.709 [nioEventLoopGroup-3-6] ERROR [traceId:d4d2e03d-0267-4a47-b9b0-7416aa1c0e5c] (c.a.n.c.c.i.Limiter:79) - access_key_id:4396fea7a8e259f09e8022caf4105fbd limited
15:47:49.710 [nioEventLoopGroup-3-7] ERROR [traceId:0f9bb096-fb01-4251-85f0-e5eb9d747f55] (c.a.n.c.c.i.ClientWorker:242) - [fixed-nacos01.recom.mrd.sohuno.com_8848-nacos02.recom.mrd.sohuno.com_8848-nacos03.recom.mrd.sohuno.com_8848-e9010bc6-8adc-41ee-a3ad-222d08892325] [sub-server-error] dataId=comment_aggregation_switch, group=online, tenant=e9010bc6-8adc-41ee-a3ad-222d08892325, code=-503
15:47:49.711 [nioEventLoopGroup-3-4] ERROR [traceId:085d14da-1b6e-4379-8297-3fb425795519] (c.a.n.c.c.i.Limiter:79) - access_key_id:4396fea7a8e259f09e8022caf4105fbd limited
15:47:49.711 [nioEventLoopGroup-3-4] ERROR [traceId:085d14da-1b6e-4379-8297-3fb425795519] (c.a.n.c.c.i.ClientWorker:242) - [fixed-nacos01.recom.mrd.sohuno.com_8848-nacos02.recom.mrd.sohuno.com_8848-nacos03.recom.mrd.sohuno.com_8848-e9010bc6-8adc-41ee-a3ad-222d08892325] [sub-server-error] dataId=comment_aggregation_switch, group=online, tenant=e9010bc6-8adc-41ee-a3ad-222d08892325, code=-503
15:47:49.711 [nioEventLoopGroup-3-6] ERROR [traceId:d4d2e03d-0267-4a47-b9b0-7416aa1c0e5c] (c.a.n.c.c.i.ClientWorker:242) - [fixed-nacos01.recom.mrd.sohuno.com_8848-nacos02.recom.mrd.sohuno.com_8848-nacos03.recom.mrd.sohuno.com_8848-e9010bc6-8adc-41ee-a3ad-222d08892325] [sub-server-error] dataId=comment_aggregation_switch, group=online, tenant=e9010bc6-8adc-41ee-a3ad-222d08892325, code=-503
原因分析:
根據報錯日志,初步判定是從遠端服務器拉取comment_aggregation_switch這個dataId配置的時候報錯,我們閱讀了Nacos獲取配置相關的源碼,從源碼一步一步地追蹤到了錯誤日志出現的地方,相關分析過程如下:
首先,我們通過getConfig方法獲取comment_aggregation_switch這個dataId的配置值:
public static boolean getCommentAggregationSwitch() {
try {
String config = getConfig(COMMENT_AGGREGATION_SWITCH);
if (StringUtils.isNotBlank(config)) {
return "1".equalsIgnoreCase(config);
}
} catch (Exception e) {
LOGGER.error("getCommentAggregationSwitch error", e);
}
return false;
}在獲取comment_aggregation_switch這個dataId配置時,因為機器內存中沒有此配置,所以通過configService對象的getConfig接口遠程拉取:
public static String getConfig(String dataId) {
try {
String value = cacheMap.get(dataId);
if (StringUtils.isBlank(cacheMap.get(dataId))) {
value = configService.getConfig(dataId, NACOS_GROUP, 100);
cacheMap.put(dataId, value);
}
return value;
} catch (Throwable e) {
LOGGER.error("getConfig happens error, dataId = {}, group = {} ", dataId, NACOS_GROUP, e);
}
return null;
}
@Override
public String getConfig(String dataId, String group, long timeoutMs) throws NacosException {
return getConfigInner(namespace, dataId, group, timeoutMs);
}而configService對象的getConfig接口會調用getConfigInner方法,在getConfigInner方法種,我們找到了出錯的地方。因為我們沒有為Nacos配置LOCAL_SNAPSHOT_PATH(即我們沒有指定user.home屬性),所以跳過本地檢查那一步,也就是不會優先使用本地配置。那么不使用本地緩存配置,或緩存已過期,這些都會向Nacos服務端發起請求來獲取配置:
private String getConfigInner(String tenant, String dataId, String group, long timeoutMs) throws NacosException {
group = blank2defaultGroup(group);
ParamUtils.checkKeyParam(dataId, group);
ConfigResponse cr = new ConfigResponse();
cr.setDataId(dataId);
cr.setTenant(tenant);
cr.setGroup(group);
// 優先使用本地配置
String content = LocalConfigInfoProcessor.getFailover(agent.getName(), dataId, group, tenant);
if (content != null) {
LOGGER.warn("[{}] [get-config] get failover ok, dataId={}, group={}, tenant={}, cnotallow={}", agent.getName(),
dataId, group, tenant, ContentUtils.truncateContent(content));
cr.setContent(content);
String encryptedDataKey = LocalEncryptedDataKeyProcessor
.getEncryptDataKeyFailover(agent.getName(), dataId, group, tenant);
cr.setEncryptedDataKey(encryptedDataKey);
configFilterChainManager.doFilter(null, cr);
content = cr.getContent();
return content;
}
try {
ConfigResponse response = worker.getServerConfig(dataId, group, tenant, timeoutMs);
cr.setContent(response.getContent());
cr.setEncryptedDataKey(response.getEncryptedDataKey());
configFilterChainManager.doFilter(null, cr);
content = cr.getContent();
return content;
} catch (NacosException ioe) {
if (NacosException.NO_RIGHT == ioe.getErrCode()) {
throw ioe;
}
LOGGER.warn("[{}] [get-config] get from server error, dataId={}, group={}, tenant={}, msg={}",
agent.getName(), dataId, group, tenant, ioe.toString());
}
LOGGER.warn("[{}] [get-config] get snapshot ok, dataId={}, group={}, tenant={}, cnotallow={}", agent.getName(),
dataId, group, tenant, ContentUtils.truncateContent(content));
content = LocalConfigInfoProcessor.getSnapshot(agent.getName(), dataId, group, tenant);
cr.setContent(content);
String encryptedDataKey = LocalEncryptedDataKeyProcessor
.getEncryptDataKeyFailover(agent.getName(), dataId, group, tenant);
cr.setEncryptedDataKey(encryptedDataKey);
configFilterChainManager.doFilter(null, cr);
content = cr.getContent();
return content;
}在ConfigResponse response = worker.getServerConfig(dataId, group, tenant, timeoutMs);這一步,執行getServerConfig方法、http請求Nacos服務端獲取具體的配置值,相關截圖如下:
圖片
在getServerConfig方法中,會調用agent(ServerHttpAgent)的httpGet方法去獲取配置結果:
圖片
在agent(ServerHttpAgent)的httpGet方法中,會繼續調用NACOS_RESTTEMPLATE的get方法:
圖片
在NACOS_RESTTEMPLATE的get方法中,執行execute方法,這里即將和Nacos的攔截器打交道:
圖片
執行this.requestClient()的execute方法、在經過Nacos服務端攔截器的時候,判斷是否被攔截器攔截住時返回了:
圖片
在isIntercept方法中,Nacos攔截器里判斷qps是否超過了限流器閾值,即Limiter.isLimit方法返回true:
圖片
我們看到,Nacos服務端設置某個accessKeyId默認訪問的qps最大不能超過5(limit=5):
圖片
因為limit=5這個閾值限制,加上我們服務在啟動后就有大量的流量進來,存在qps>5的情況,所以在此處qps>5的并發訪問流量被限制住,isLimit方法返回true:
圖片
圖片
圖片
因isLimit方法返回true,以及LimitResponse的statusCode是-503,所以agent(ServerHttpAgent)的httpGet在如下這一步返回(沒有走進isFail方法里):
圖片
從而getServerConfig實現類里,在判斷請求結果時,因result的getCode()返回是-503,所以default處打印了錯誤日志(我們服務報錯的日志),并拋了異常:
圖片
在經過如上的分析后,針對被Nacos攔截器攔截、超過server端限流器閾值這一問題,我們初步制定出了兩種解決方案,如下所示:
▲ 解決方案1:增大server端限流器閾值
我們第一時間能想到的解決方案就是增大limit的值,也就是要增加limitTime這一配置或者更改這一配置的值,然后Nacos在讀取該值的時候就會用新的限流閾值(Nacos獲取閾值是通過System.getProperty("limitTime", String.valueOf(limit))來獲取的)。但是我們想到了一些潛在的問題,首先問題一:限流閾值增加到多少合適;問題二:Nacos服務器是否可以抗住;問題三:增大這個閾值是否會帶來額外的問題?
我們初步想將limitTime的值設置為10,也就是翻一倍。但是如果并發流量要是大于10怎么辦,這個值需要再增加到多少合適?再增加后Nacos服務器是否可以抗的住,即便抗的住是否會有一些未知的其它問題。最后,經過組內的評測,我們覺得此種更改可能風險比較大,我們需要一種更適合的解決方案。
▲ 解決方案2:服務啟動后、流量進來前先獲取配置到內存以及增加監聽器
在經過第一次調優后,我們的服務是在服務啟動后、流量進來前只監聽,并沒有先獲取配置,代碼如下:
public static void addListener(String dataId, String group) {
// 監聽配置
try {
configService.addListener(dataId, group, new PropertiesListener() {
@Override
public void innerReceive(Properties properties) {
}
@Override
public void receiveConfigInfo(String configInfo) {
}
});
} catch (Throwable e) {
LOGGER.error("addListener happens error, dataId = {}, group = {} ", dataId, group, e);
}
}我們發現Nacos在作為配置管理時,有自帶的getConfigAndSignListener方法,此方法是可以先獲取配置再去注冊監聽的,我們嘗試使用這一方法,修改后代碼如下:
String config = configService.getConfigAndSignListener(dataId, group, 1000,
new PropertiesListener() {
@Override
public void innerReceive(Properties properties) {
}
@Override
public void receiveConfigInfo(String configInfo) {
cacheMap.put(dataId, configInfo);
}
});
if (StringUtils.isNotEmpty(config)) {
cacheMap.put(dataId, config);
}這樣在服務啟動時首先會拉取一次配置并放到內存,之后如果有并發的流量進來,都可以從內存中獲取。為了驗證這一改動是否有效,我們在UAT環境上模擬報問題那臺機器上的請求,也就是使并發請求Nacos獲取配置的qps>5,經過了多次驗證,我們沒有再發現報錯。此種解法相較于第一種解決方案,對于我們來講算是近似最優解。變更代碼、測試驗證、灰度、上線后,再未發現此問題。
第二次優化后獲取配置相當于拆分為兩個“步驟”,步驟一是在應用程序初始化時:
圖片
步驟二是在程序運行時需要獲取配置:
圖片
4.總結
Nacos作為配置中心在我們項目中發揮了重要作用,在實際使用的過程中,我們共遇到了兩個問題,本篇文章介紹了兩個問題的表現并簡單從源碼層面分析了第二個問題出現的原因,之后給出了對應的解決方案。我們希望通過這兩次配置調優的經歷,為大家提供一些可選的配置調優方案,以期未來大家在遇到類似問題時,可以嘗試應用這兩種解決方法來解決實際的問題。
在SpringBoot的項目中,可以通過以下方式配置Nacos:
圖片
也可以不通過配置方式直接在項目中初始化:
圖片
綜上,目前我們認為引入Nacos組件最佳的實踐方式是:
1.服務啟動時首先獲取配置并且監聽配置的改變;
2.將獲取到的配置緩存到本地內存中;
3.可根據需要適當增大限流器閾值limitTime,但不建議更改此參數;
4.可根據需要適當增大從服務器拉取配置的超時時間,目前內網環境下我們設置的是100毫秒,可以重試;
5.做好相關錯誤日志的打印及報警工作;
6.為每一個配置項增加一個兜底值。






















