大家好,我是小富~
對於Nacos
大家應該都不太陌生,出身阿里名聲在外,能做動態服務發現、配置管理,非常好用的一個工具。然而這樣的技術用的人越多面試被問的概率也就越大,如果只停留在使用層面,那面試可能要喫大虧。
比如我們今天要討論的話題,Nacos
在做配置中心的時候,配置數據的交互模式是服務端推過來還是客戶端主動拉的?
這裏我先拋出答案:客戶端主動拉的!
接下來咱們扒一扒Nacos
的源碼,來看看它具體是如何實現的?
配置中心
聊Nacos
之前簡單回顧下配置中心的由來。
簡單理解配置中心的作用就是對配置統一管理,修改配置後應用可以動態感知,而無需重啓。
因爲在傳統項目中,大多都採用靜態配置的方式,也就是把配置信息都寫在應用內的yml
或properties
這類文件中,如果要想修改某個配置,通常要重啓應用纔可以生效。
但有些場景下,比如我們想要在應用運行時,通過修改某個配置項,實時的控制某一個功能的開閉,頻繁的重啓應用肯定是不能接受的。
尤其是在微服務架構下,我們的應用服務拆分的粒度很細,少則幾十多則上百個服務,每個服務都會有一些自己特有或通用的配置。假如此時要改變通用配置,難道要我挨個改幾百個服務配置?很顯然這不可能。所以爲了解決此類問題配置中心應運而生。
推與拉模型
客戶端與配置中心的數據交互方式其實無非就兩種,要麼推push
,要麼拉pull
。
推模型
客戶端與服務端建立TCP
長連接,當服務端配置數據有變動,立刻通過建立的長連接將數據推送給客戶端。
優勢:長鏈接的優點是實時性,一旦數據變動,立即推送變更數據給客戶端,而且對於客戶端而言,這種方式更爲簡單,只建立連接接收數據,並不需要關心是否有數據變更這類邏輯的處理。
弊端:長連接可能會因爲網絡問題,導致不可用,也就是俗稱的假死
。連接狀態正常,但實際上已無法通信,所以要有的心跳機制KeepAlive
來保證連接的可用性,纔可以保證配置數據的成功推送。
拉模型
客戶端主動的向服務端發請求拉配置數據,常見的方式就是輪詢,比如每3s向服務端請求一次配置數據。
輪詢的優點是實現比較簡單。但弊端也顯而易見,輪詢無法保證數據的實時性,什麼時候請求?間隔多長時間請求一次?都是不得不考慮的問題,而且輪詢方式對服務端還會產生不小的壓力。
長輪詢
開篇我們就給出了答案,nacos
採用的是客戶端主動拉pull
模型,應用長輪詢(Long Polling
)的方式來獲取配置數據。
額?以前只聽過輪詢,長輪詢又是什麼鬼?它和傳統意義上的輪詢(暫且叫短輪詢吧,方便比較)有什麼不同呢?
短輪詢
不管服務端配置數據是否有變化,不停的發起請求獲取配置,比如支付場景中前段JS輪詢訂單支付狀態。
這樣的壞處顯而易見,由於配置數據並不會頻繁變更,若是一直髮請求,勢必會對服務端造成很大壓力。還會造成推送數據的延遲,比如:每10s請求一次配置,如果在第11s時配置更新了,那麼推送將會延遲9s,等待下一次請求。
爲了解決短輪詢的問題,有了長輪詢方案。
長輪詢
長輪詢可不是什麼新技術,它不過是由服務端控制響應客戶端請求的返回時間,來減少客戶端無效請求的一種優化手段,其實對於客戶端來說與短輪詢的使用並沒有本質上的區別。
客戶端發起請求後,服務端不會立即返回請求結果,而是將請求掛起等待一段時間,如果此段時間內服務端數據變更,立即響應客戶端請求,若是一直無變化則等到指定的超時時間後響應請求,客戶端重新發起長鏈接。
Nacos初識
爲了後續演示操作方便我在本地搭了個Nacos
。注意: 運行時遇到個小坑,由於Nacos
默認是以cluster
集羣的方式啓動,而本地搭建通常是單機模式standalone
,這裏需手動改一下啓動腳本startup.X
中的啓動模式。
直接執行/bin/startup.X
就可以了,默認用戶密碼均是nacos
。
幾個概念
Nacos
配置中心的幾個核心概念:dataId
、group
、namespace
,它們的層級關係如下圖:
dataId
:是配置中心裏最基礎的單元,它是一種key-value
結構,key
通常是我們的配置文件名稱,比如:application.yml
、mybatis.xml
,而value
是整個文件下的內容。
目前支持JSON
、XML
、YAML
等多種配置格式。
group
:dataId配置的分組管理,比如同在dev環境下開發,但同環境不同分支需要不同的配置數據,這時就可以用分組隔離,默認分組DEFAULT_GROUP
。
namespace
:項目開發過程中肯定會有dev
、test
、pro
等多個不同環境,namespace
則是對不同環境進行隔離,默認所有配置都在public
裏。
架構設計
下圖簡要描述了nacos
配置中心的架構流程。
客戶端、控制檯通過發送Http請求將配置數據註冊到服務端,服務端持久化數據到Mysql。
客戶端拉取配置數據,並批量設置對dataId
的監聽發起長輪詢請求,如服務端配置項變更立即響應請求,如無數據變更則將請求掛起一段時間,直到達到超時時間。爲減少對服務端壓力以及保證配置中心可用性,拉取到配置數據客戶端會保存一份快照在本地文件中,優先讀取。
這裏我省略了比較多的細節,如鑑權、負載均衡、高可用方面的設計(其實這部分纔是真正值得學的,後邊另出文講吧),主要弄清客戶端與服務端的數據交互模式。
下邊我們以Nacos 2.0.1版本源碼分析,2.0以後的版本改動較多,和網上的很多資料略有些不同
地址:https://github.com/alibaba/nacos/releases/tag/2.0.1
客戶端源碼分析
Nacos
配置中心的客戶端源碼在nacos-client
項目,其中NacosConfigService
實現類是所有操作的核心入口。
說之前先了解個客戶端數據結構cacheMap
,這裏大家重點記住它,因爲它幾乎貫穿了Nacos客戶端的所有操作,由於存在多線程場景爲保證數據一致性,cacheMap
採用了AtomicReference
原子變量實現。
/**
* groupKey -> cacheData.
*/
private final AtomicReference<Map<String, CacheData>> cacheMap = new AtomicReference<Map<String, CacheData>>(new HashMap<>());
cacheMap
是個Map結構,key爲groupKey
,是由dataId, group, tenant(租戶)拼接的字符串;value爲CacheData
對象,每個dataId都會持有一個CacheData對象。
獲取配置
Nacos
獲取配置數據的邏輯比較簡單,先取本地快照文件中的配置,如果本地文件不存在或者內容爲空,則再通過HTTP請求從遠端拉取對應dataId配置數據,並保存到本地快照中,請求默認重試3次,超時時間3s。
獲取配置有getConfig()
和getConfigAndSignListener()
這兩個接口,但getConfig()
只是發送普通的HTTP請求,而getConfigAndSignListener()
則多了發起長輪詢和對dataId數據變更註冊監聽的操作addTenantListenersWithContent()
。
@Override
public String getConfig(String dataId, String group, long timeoutMs) throws NacosException {
return getConfigInner(namespace, dataId, group, timeoutMs);
}
@Override
public String getConfigAndSignListener(String dataId, String group, long timeoutMs, Listener listener)
throws NacosException {
String content = getConfig(dataId, group, timeoutMs);
worker.addTenantListenersWithContent(dataId, group, content, Arrays.asList(listener));
return content;
}
註冊監聽
客戶端註冊監聽,先從cacheMap
中拿到dataId
對應的CacheData
對象。
public void addTenantListenersWithContent(String dataId, String group, String content,
List<? extends Listener> listeners) throws NacosException {
group = blank2defaultGroup(group);
String tenant = agent.getTenant();
// 1、獲取dataId對應的CacheData,如沒有則向服務端發起長輪詢請求獲取配置
CacheData cache = addCacheDataIfAbsent(dataId, group, tenant);
synchronized (cache) {
// 2、註冊對dataId的數據變更監聽
cache.setContent(content);
for (Listener listener : listeners) {
cache.addListener(listener);
}
cache.setSyncWithServer(false);
agent.notifyListenConfig();
}
}
如沒有則向服務端發起長輪詢請求獲取配置,默認的Timeout
時間爲30s,並把返回的配置數據回填至CacheData
對象的content字段,同時用content生成MD5值;再通過addListener()
註冊監聽器。
CacheData
也是個出場頻率非常高的一個類,我們看到除了dataId、group、tenant、content這些相關的基礎屬性,還有幾個比較重要的屬性如:listeners
、md5
(content真實配置數據計算出來的md5值),以及註冊監聽、數據比對、服務端數據變更通知操作都在這裏。
其中listeners
是對dataId所註冊的所有監聽器集合,其中的ManagerListenerWrap
對象除了持有Listener
監聽類,還有一個lastCallMd5
字段,這個屬性很關鍵,它是判斷服務端數據是否更變的重要條件。
在添加監聽的同時會將CacheData
對象當前最新的md5值賦值給ManagerListenerWrap
對象的lastCallMd5
屬性。
public void addListener(Listener listener) {
ManagerListenerWrap wrap =
(listener instanceof AbstractConfigChangeListener) ? new ManagerListenerWrap(listener, md5, content)
: new ManagerListenerWrap(listener, md5);
}
看到這對dataId監聽設置就完事了?我們發現所有操作都圍着cacheMap
結構中的CacheData
對象,那麼大膽猜測下一定會有專門的任務來處理這個數據結構。
變更通知
客戶端又是如何感知服務端數據已變更呢?
我們還是從頭看,NacosConfigService
類的構造器中初始化了一個ClientWorker
,而在ClientWorker
類的構造器中又啓動了一個線程池來輪詢cacheMap
。
而在executeConfigListen()
方法中有這麼一段邏輯,檢查cacheMap
中dataId的CacheData
對象內,MD5字段與註冊的監聽listener
內的lastCallMd5值
,不相同表示配置數據變更則觸發safeNotifyListener
方法,發送數據變更通知。
void checkListenerMd5() {
for (ManagerListenerWrap wrap : listeners) {
if (!md5.equals(wrap.lastCallMd5)) {
safeNotifyListener(dataId, group, content, type, md5, encryptedDataKey, wrap);
}
}
}
safeNotifyListener()
方法單獨起線程,向所有對dataId
註冊過監聽的客戶端推送變更後的數據內容。
客戶端接收通知,直接實現receiveConfigInfo()
方法接收回調數據,處理自身業務就可以了。
configService.addListener(dataId, group, new Listener() {
@Override
public void receiveConfigInfo(String configInfo) {
System.out.println("receive:" + configInfo);
}
@Override
public Executor getExecutor() {
return null;
}
});
爲了理解更直觀我用測試demo演示下,獲取服務端配置並設置監聽,每當服務端配置數據變化,客戶端監聽都會收到通知,一起看下效果。
public static void main(String[] args) throws NacosException, InterruptedException {
String serverAddr = "localhost";
String dataId = "test";
String group = "DEFAULT_GROUP";
Properties properties = new Properties();
properties.put("serverAddr", serverAddr);
ConfigService configService = NacosFactory.createConfigService(properties);
String content = configService.getConfig(dataId, group, 5000);
System.out.println(content);
configService.addListener(dataId, group, new Listener() {
@Override
public void receiveConfigInfo(String configInfo) {
System.out.println("數據變更 receive:" + configInfo);
}
@Override
public Executor getExecutor() {
return null;
}
});
boolean isPublishOk = configService.publishConfig(dataId, group, "我是新配置內容~");
System.out.println(isPublishOk);
Thread.sleep(3000);
content = configService.getConfig(dataId, group, 5000);
System.out.println(content);
}
結果和預想的一樣,當向服務端publishConfig
數據變化後,客戶端可以立即感知,愣是用主動拉pull
模式做出了服務端實時推送的效果。
數據變更 receive:我是新配置內容~
true
我是新配置內容~
服務端源碼分析
Nacos
配置中心的服務端源碼主要在nacos-config
項目的ConfigController
類,服務端的邏輯要比客戶端稍複雜一些,這裏我們重點看下。
處理長輪詢
服務端對外提供的監聽接口地址/v1/cs/configs/listener
,這個方法內容不多,順着doPollingConfig
往下看。
服務端根據請求header
中的Long-Pulling-Timeout
屬性來區分請求是長輪詢還是短輪詢,這裏咱們只關注長輪詢部分,接着看LongPollingService
(記住這個service很關鍵)類中的addLongPollingClient()
方法是如何處理客戶端的長輪詢請求的。
正常客戶端默認設置的請求超時時間是30s
,但這裏我們發現服務端“偷偷”的給減掉了500ms
,現在超時時間只剩下了29.5s
,那爲什麼要這樣做呢?
用官方的解釋之所以要提前500ms響應請求,爲了最大程度上保證客戶端不會因爲網絡延時造成超時,考慮到請求可能在負載均衡時會耗費一些時間,畢竟Nacos
最初就是按照阿里自身業務體量設計的嘛!
此時對客戶端提交上來的groupkey
的MD5與服務端當前的MD5比對,如md5
值不同,則說明服務端的配置項發生過變更,直接將該groupkey
放入changedGroupKeys
集合並返回給客戶端。
MD5Util.compareMd5(req, rsp, clientMd5Map)
如未發生變更,則將客戶端請求掛起,這個過程先創建一個名爲ClientLongPolling
的調度任務Runnable
,並提交給scheduler
定時線程池延後29.5s
執行。
ConfigExecutor.executeLongPolling(
new ClientLongPolling(asyncContext, clientMd5Map, ip, probeRequestSize, timeout, appName, tag));
這裏每個長輪詢任務攜帶了一個asyncContext
對象,使得每個請求可以延遲響應,等延時到達或者配置有變更之後,調用asyncContext.complete()
響應完成。
asyncContext 爲 Servlet 3.0新增的特性,異步處理,使Servlet線程不再需要一直阻塞,等待業務處理完畢才輸出響應;可以先釋放容器分配給請求的線程與相關資源,減輕系統負擔,其響應將被延後,在處理完業務或者運算後再對客戶端進行響應。
ClientLongPolling
任務被提交進入延遲線程池執行的同時,服務端會通過一個allSubs
隊列保存所有正在被掛起的客戶端長輪詢請求任務,這個是客戶端註冊監聽的過程。
如延時期間客戶端據數一直未變化,延時時間到達後將本次長輪詢任務從allSubs
隊列剔除,並響應請求response
,這是取消監聽
。收到響應後客戶端再次發起長輪詢,循環往復。
到這我們知道服務端是如何掛起客戶端長輪詢請求的,一旦請求在掛起期間,用戶通過管理平臺操作了配置項,或者服務端收到了來自其他客戶端節點修改配置的請求。
怎麼能讓對應已掛起的任務立即取消,並且及時通知客戶端數據發生了變更呢?
數據變更
管理平臺或者客戶端更改配置項接位置ConfigController
中的publishConfig
方法。
值得注意得是,在publishConfig
接口中有這麼一段邏輯,某個dataId
配置數據被修改時會觸發一個數據變更事件Event
。
ConfigChangePublisher.notifyConfigChange(new ConfigDataChangeEvent(false, dataId, group, tenant, time.getTime()));
仔細看LongPollingService
會發現在它的構造方法中,正好訂閱了數據變更事件,並在事件觸發時執行一個數據變更調度任務DataChangeTask
。
DataChangeTask
內的主要邏輯就是遍歷allSubs
隊列,上邊我們知道,這個隊列中維護的是所有客戶端的長輪詢請求任務,從這些任務中找到包含當前發生變更的groupkey
的ClientLongPolling
任務,以此實現數據更變推送給客戶端,並從allSubs
隊列中剔除此長輪詢任務。
而我們在看給客戶端響應response
時,調用asyncContext.complete()
結束了異步請求。
結束語
上邊只揭開了nacos
配置中心的冰山一角,實際上還有非常多重要的技術細節都沒提及到,建議大家沒事看看源碼,源碼不需要通篇的看,只要抓住核心部分就夠了。就比如今天這個題目以前我真沒太在意,突然被問一下子喫不準了,果斷看下源碼,而且這樣記憶比較深刻(別人嚼碎了餵你的知識總是比自己咀嚼的差那麼點意思)。
nacos
的源碼我個人覺得還是比較樸素的,代碼並沒有過多炫技,看起來相對輕鬆。大家不要對看源碼有什麼牴觸,它也不過是別人寫的業務代碼而已,just so so!
我是小富~,如果對你有用在看、關注支持下,咱們下期見~
整理了幾百本各類技術電子書,有需要的同學自取。技術羣快滿了,想進的同學可以加我好友,和大佬們一起吹吹技術。
個人公衆號: 程序員內點事,歡迎交流