十一張圖講透原理,最細(xì)的增量拉取
一、前言
上一篇我們講解了客戶端首次獲取注冊(cè)表時(shí),需要從注冊(cè)中心全量拉取注冊(cè)表到本地存著。那后續(xù)如果有客戶端注冊(cè)、下線的話,注冊(cè)表肯定就發(fā)生變化了,這個(gè)時(shí)候客戶端就得更新本地注冊(cè)表了,怎么更新呢?下面我會(huì)帶著大家一起來(lái)看下客戶端第二次(這里代表全量獲取后的下一次)獲取注冊(cè)表的方式。
題外話:之前寫過一篇 Redis 主從同步的架構(gòu)原理,里面也涉及到首次同步和第二次同步,其實(shí)原理也類似,但是 Redis 的主從同步原理要復(fù)雜些。強(qiáng)烈推薦配合著看一波:
鏡 | 5 個(gè)維度深度剖析「主從架構(gòu)」原理
二、增量獲取引發(fā)的問題
上面我們說(shuō)到,當(dāng)?shù)谝淮潍@取全量信息后,本地就有注冊(cè)信息了。那如果 Server 的注冊(cè)表有更新,比如有服務(wù)注冊(cè)、下線,Client 必須要重新獲取一次注冊(cè)表信息才行。
那是否可以重新全量拉取一次呢?
可以是可以,但是,如果注冊(cè)表信息很大呢?比如有幾百個(gè)微服務(wù)都注冊(cè)上去了,那一次拉取是非常耗時(shí)的,而且占用網(wǎng)絡(luò)帶寬,性能較差,這種方案是不靠譜的。
所以我們就需要用增量拉取注冊(cè)信息表的方式,也就是說(shuō)只拉取變化的數(shù)據(jù),這樣數(shù)據(jù)量就比較小了。如下圖所示:
增量獲取注冊(cè)表
從源碼里面我們可以看到,Eureka Client 通過調(diào)用 getAndUpdateDelta 方法獲取增量的變化的注冊(cè)表數(shù)據(jù),Eureka Server 將變化的數(shù)據(jù)返回給 Client。
這里就有幾個(gè)問題:
(1)Client 隔多久進(jìn)行一次增量獲取?
(2)Server 將變化的數(shù)據(jù)存放在哪里?
(3)Client 如何將變化的數(shù)據(jù)合并到本地注冊(cè)表里面?
下面分別針對(duì)上面的幾個(gè)問題進(jìn)行解答。
三、間隔多久同步一次?
3.1 默認(rèn)間隔時(shí)間
默認(rèn)每隔 30 s 執(zhí)行一次同步,如下圖所示:
默認(rèn) 30s 同步一次
這個(gè) 30 s 就是由變量 client.refresh.interval 定義的。
Eureka 每 30 s 會(huì)調(diào)用一個(gè)后臺(tái)線程去拉取增量注冊(cè)表,這個(gè)后臺(tái)線程的名字叫做:cacheRefresh。如下所示:
間隔時(shí)間的源碼
3.2 Client 發(fā)送拉取注冊(cè)表的請(qǐng)求
就是調(diào)用 getDelta 方法,發(fā)送 HTTP請(qǐng)求調(diào)用 jersey 的 restful 接口,然后 Server 端的 Jersey 框架就會(huì)去處理這個(gè)請(qǐng)求了。發(fā)送請(qǐng)求的方法 getDelta 如下所示:
- eurekaTransport.queryClient.getDelta(remoteRegionsRef.get());
- restful 接口的地址就長(zhǎng)這樣:
- http://localhost:8080/v2/apps/delta
那么 Server 端如何過濾出增量的注冊(cè)表信息呢?我們可以找到這個(gè)方法:getContainerDifferential。如下圖所示:
這個(gè)方法主要干的活就是去獲取最近改變的數(shù)據(jù)。接下來(lái)我們看下最近改變的數(shù)據(jù)存放在哪。
四、變化的數(shù)據(jù)存放在哪?
4.1 數(shù)據(jù)結(jié)構(gòu)
其實(shí)就是放在這個(gè)隊(duì)列里面:recentlyChangedQueue。
它的數(shù)據(jù)結(jié)構(gòu)是一個(gè)并發(fā)安全的鏈表隊(duì)列 ConcurrentLinkedQueue。
鏈表里面存放的元素就是最近變化的注冊(cè)信息 RecentlyChangedItem。
- ConcurrentLinkedQueue<RecentlyChangedItem>
當(dāng)有客戶端注冊(cè)的時(shí)候,這個(gè)鏈表里面的尾部就會(huì)追加一個(gè)對(duì)象。
關(guān)于 ConcurrentLinkedQueue,還記得我之前寫過的 18 種隊(duì)列嗎?不記得話看下這篇:
45張圖庖丁解牛18種Queue,你知道幾種?
ConcurrentLinkedQueue 是由鏈表結(jié)構(gòu)組成的線程安全的先進(jìn)先出無(wú)界隊(duì)列。如下圖所示:
ConcurrentLinkedQueue原理
4.2 內(nèi)部構(gòu)造
我覺得這個(gè)隊(duì)列的構(gòu)造還是非常值得我們學(xué)習(xí)的,我們來(lái)看下這個(gè)隊(duì)列的構(gòu)造,如下圖所示:
增量數(shù)據(jù)內(nèi)部構(gòu)造
- 這個(gè)隊(duì)列里面存放的對(duì)象是最近改變的對(duì)象 RecentlyChangedItem。
- RecentlyChangedItem 存有三個(gè)元素:實(shí)例信息、操作類型和最后更新時(shí)間。
- 實(shí)例信息:使用 Lease保存一個(gè)客戶端的注冊(cè)表信息,這個(gè)在第四篇講解注冊(cè)表結(jié)構(gòu)已經(jīng)介紹過。
- 操作類型:當(dāng)有客戶端發(fā)起注冊(cè)、更新注冊(cè)表、下線時(shí),會(huì)設(shè)置 actionType,對(duì)應(yīng)三種枚舉值:新增、更新、刪除。
- 最后更新時(shí)間:客戶端注冊(cè)信息發(fā)生改變時(shí),需要同時(shí)更新最后更新時(shí)間。
4.3 最近的數(shù)據(jù)
既然上面說(shuō)到是最近改變的數(shù)據(jù)才會(huì)放進(jìn)去,那這個(gè)最近是多近呢?1 分鐘?2分鐘?
通過源碼我們找到了這個(gè)默認(rèn)配置,三分鐘刷新一次,也就是 180s 刷新一次。
那刷新了什么?刷新其實(shí)是會(huì)遍歷這個(gè)隊(duì)列:recentlyChangedQueue。
將隊(duì)列里面的所有元素都遍歷一遍,比對(duì)每個(gè)對(duì)象的最后更新時(shí)間是否超過了三分鐘,如果超過了,就移除這個(gè)元素。如下圖所示:
比較最后更新時(shí)間
當(dāng)元素的最后更新時(shí)間超過 3 分鐘未更新,則移除該元素。如下圖所示:
移除元素
4.4 檢查間隔
Server 端會(huì)將最近 3 分鐘有更新的注冊(cè)信息放入到隊(duì)列中,超過 3 分鐘未更新的數(shù)據(jù)將會(huì)被移除。那么多久會(huì)檢查一次呢?
通過源碼我們找到,每隔 30s 就會(huì)調(diào)用一次檢查任務(wù)。如下圖所示:
檢查間隔
4.5 小結(jié)
- Client 每隔 30 秒調(diào)用一次增量獲取注冊(cè)表的接口。
- Server 每隔 30 秒調(diào)用檢查一次隊(duì)列。
- 如果隊(duì)列中有元素在 3 分鐘以內(nèi)都沒有更新過,則從隊(duì)列中移除該元素。
五、客戶端注冊(cè)表合并
這里有個(gè)問題:客戶端首次拿到的全量注冊(cè)表,存放本地了。第二次拿到的是增量的注冊(cè)表,怎么將兩次的數(shù)據(jù)合并在一起呢?如下圖所示:
注冊(cè)表合并
下面我們來(lái)看看下客戶端注冊(cè)表合并的原理。
當(dāng)客戶端調(diào)用獲取增量注冊(cè)表的請(qǐng)求后,注冊(cè)表會(huì)返回增量信息,然后客戶端就會(huì)調(diào)用本地合并的方法:updateDelta。
合并注冊(cè)表的原理圖如下所示:
合并注冊(cè)表的原理
首先就會(huì)遍歷增量注冊(cè)表,檢查其中的每一項(xiàng),不論 actionType 是新增、刪除還是更新,如果本地本來(lái)就有,則執(zhí)行后續(xù)的類型判斷邏輯。
如果實(shí)例信息的名字在本地不存在則會(huì)先往本地注冊(cè)表新增一個(gè)注冊(cè)信息。然后本地肯定存在注冊(cè)信息了,執(zhí)行后續(xù)的判斷邏輯。
當(dāng)類型字段 actionType 等于新增或更新時(shí),先刪除后增加。
當(dāng)類型字段 actionType 等于刪除時(shí),直接進(jìn)行刪除。
經(jīng)過這一些列的邏輯之后,增量注冊(cè)表和本地注冊(cè)表就合并好了。
六、比對(duì)注冊(cè)表
經(jīng)過重重判斷 + 合并操作,客戶端終于完成了本地注冊(cè)表的刷新,理論上來(lái)說(shuō),這個(gè)時(shí)候客戶端的注冊(cè)表應(yīng)該和注冊(cè)中心的注冊(cè)表一致了。
但是如何確定是一致的呢?這里我們來(lái)考慮幾種方案:
- 再全量拉取一次注冊(cè)表,和本地注冊(cè)表進(jìn)行比對(duì)。但是既然又要做一次全量拉取,那之前的增量拉取就沒有必要了。
- 拉取增量注冊(cè)表,Server 返回全量注冊(cè)表的實(shí)例 id,客戶端比對(duì)每個(gè)實(shí)例 id 是否存在,以及檢查本地是否有多余的,如果能匹配上,則認(rèn)為是一致的。但是這里也有一個(gè)問題,對(duì)于新增和更新的注冊(cè)實(shí)例,得把更新的實(shí)例信息的字段一一比對(duì)才能確定是否一致,這就太麻煩了。另外還有一個(gè)致命的問題:如果客戶端因?yàn)榫W(wǎng)絡(luò)故障下線了,上一次最近 3 分鐘的增量數(shù)據(jù)沒有拉取到,那么相當(dāng)于丟失了一次增量數(shù)據(jù),這個(gè)時(shí)候,就不是完整的注冊(cè)表信息了。
有沒有既方便又準(zhǔn)確的比對(duì)方式呢?
有的,那就是哈希比對(duì)。哈希比對(duì)的意思就是將兩個(gè)對(duì)象經(jīng)過哈希算法計(jì)算出兩個(gè) hash 值,如果兩個(gè) hash 值相等,則認(rèn)為這兩個(gè)對(duì)象相等。這種方式在代碼中也非常常見,比如類的 hashcode() 方法。
從源碼中,我們看到 Eureka Server 返回注冊(cè)表時(shí),會(huì)返回一個(gè) hash 值,是將全量注冊(cè)表 hash 之后的值。調(diào)用的是這個(gè)方法:getReconcileHashCode()。
如下圖所示,獲取增量注冊(cè)表的接口,會(huì)返回增量注冊(cè)表和 hashcode。
然后本地注冊(cè)表合并后,再計(jì)算出一個(gè) hashcode,和 Server 返回的 hashcode 進(jìn)行比對(duì),如果一致,說(shuō)明本地注冊(cè)表和 Server 端一致。如果不一致,則會(huì)進(jìn)行一次全量拉取。
上面說(shuō)的原理我們畫一張?jiān)韴D看下就清楚了:
七、總結(jié)
本篇文章可以用一張圖來(lái)做總結(jié),直接上圖:
客戶端注冊(cè)表同步原理
- 客戶端每隔 30s 獲取一次增量數(shù)據(jù),注冊(cè)中心返回最近 3 分鐘變化的注冊(cè)信息,包含了新注冊(cè)的、更新的和下線的服務(wù)實(shí)例。然后將增量注冊(cè)表 + 全量注冊(cè)表的 hash 值返回。
- 客戶端將本地注冊(cè)表 + 增量注冊(cè)表進(jìn)行合并。合并完成后,計(jì)算一個(gè) hash 值,和 Server 返回的 hash 值進(jìn)行比對(duì),如果相等,則說(shuō)明客戶端的注冊(cè)表和注冊(cè)中心的注冊(cè)表一致,同步完成。如果不一致,則還需要全量拉取一次。
提個(gè)問題:為什么 hash 比對(duì)會(huì)不一致?答案在文中哦!
下篇,注冊(cè)中心的緩存架構(gòu)走起!