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