springcloud eureka原理和機制

LemonDus發表於2024-12-08

公司的註冊中心使用的是Eureka,之前使用過ZooKeeper,大致原理應該差不多,具體細節需要進一步學習,正好之前在騰訊雲開發者社群看到一篇講得很不錯的文章,轉載過來方便檢視。

簡介

在微服務架構下,服務端環境通常包含多個服務,同時每個服務也是一個無狀態的多例項叢集。這些服務和例項一般都是會動態變化的,可能會因為意外的故障或者人為的重啟發版等原因,這些服務和例項的資訊和數量隨時會發生改變。因此微服務環境下需要一個服務註冊中心來集中管理叢集中各個服務例項的狀態,這樣服務的呼叫方就可以動態地從服務註冊中心獲取到當前可用的服務例項來發起呼叫。

Eureka 就是服務發現中心的一種。Eureka 一開始是由 Netflix 開源的用於服務註冊的元件,之後 Spring Cloud 對其進行封裝和整合,新增到了 Spring Cloud 微服務生態。
eureka_cloud

架構

Eureka 由 Eureka Server 和 Eureka Client 兩部分組成。

  • Server 是服務註冊中心,負責維護叢集中的服務例項資訊和狀態,以及給 Client 返回服務列表。在分散式環境下一般會多例項部署來達到高可用,比如在多個可用區上均部署 Eureka Server。
  • Client 是一個嵌入到業務服務的模組,負責與 Server 互動,包括髮送註冊請求、維持心跳、拉取服務列表等。

eureka_jiagou

引入了服務發現中心後,需要為其他應用提供服務的應用在啟動時需要先透過 Eureka Client 向 Eureka Server 傳送註冊請求,把自己的服務資訊註冊到 Eureka Server 上,同時需要定期傳送心跳。

在應用下線時傳送取消註冊請求,把自身從 Eureka Server 的服務列表裡刪除。在多例項部署的情況下,Eureka Client 需要根據一定的策略選擇一個目標 Server 進行通訊,這個過程在後面會詳細介紹。

而服務的呼叫方在發起呼叫時需要先從 Eureka Server 獲取服務例項列表,然後可以根據客戶端的負載均衡策略選擇一個例項,然後再向該例項發起呼叫請求。

下面基於 spring-cloud-starter-eureka 版本 1.3.2.RELEASE 的程式碼,分別介紹一下 Eureka Server 和 Eureka Client 兩者的工作原理。

服務端原理

Eureka Server 負責管理整個叢集服務例項資訊,有新例項註冊時需要為其建立和管理對應的 Lease ,同時還負責把 Lease 的變更同步給叢集中其他的 Eureka Server,以保證叢集中所有的 Eureka Server 節點的服務列表最終一致。Eureka Server 會把這些 Lease 維護在一個 PeerAwareInstanceRegistry 裡,當有 Eureka Client 需要獲取服務列表時,需要從中獲取這些 Lease 資訊返回。

Eureka Server 幾個關鍵模組的關係如下圖。這裡面最核心的是 PeerAwareInstanceRegistry ,它記錄了當前註冊過的所有服務例項的資訊和狀態。

  • Resources :這部分對外暴露了一系列的 Restful 介面。Eureka Client 的註冊、心跳、獲取服務列表等操作都需要呼叫這些介面。另外,其他的 Server 在同步 Registry 時也需要呼叫這些介面。
  • Controller :這裡提供了幾個 web 介面,主要用在 Eureka Server 本身的 Dashboard 頁面, 從頁面上可以檢視到當前註冊了的服務,以及每個服務下各個例項的狀態。
  • PeerAwareInstanceRegistry :這裡面記錄了當前註冊了的服務例項。當這些註冊資訊發生變化時,PeerAwareInstanceRegistry 還要負責把這些變化同步到其他的 Server。
  • PeerEurekaNodes :這裡維護了叢集裡所有 Eureka Server 節點的資訊,PeerAwareInstanceRegistry 在同步時需要從這裡獲取其他 Server 的資訊。同時它還負責定時檢查配置來發現是否有 Eureka Server 節點新增或刪除。
  • HttpReplicationClient :這是 PeerAwareInstanceRegistry 向其他 Server 同步時傳送請求的 http client。

eureka_server

Lease

服務向 Eureka Server 註冊時,Eureka Server 會為其建立一個 Lease 。這些 Lease 是維護在上面說到的 PeerAwareInstanceRegistry 裡的,它維護了一個 Map 結構

private final ConcurrentHashMap<String, Map<String, Lease<InstanceInfo>>> registry;

這是一個雙重 Map,記錄每個服務下有哪些例項,以及每個例項對應的 LeaseLease 裡記錄了對應例項的註冊時間和上次更新時間。

public class Lease<T> { 
   

    // ...

    private long evictionTimestamp;
    private long registrationTimestamp;
    private long serviceUpTimestamp;
    private volatile long lastUpdateTimestamp;
    private long duration;

    // ...
}

一個例項註冊時會在 registry 裡新增一個 Lease ,傳送心跳時會更新 Lease 的時間,Lease 的有效期預設是 90 秒。有效期內未更新的 Lease 會被認為過期。

PeerAwareInstanceRegistry 會定時執行一個 EvictionTask ,將過期的 Lease 刪除。EvictionTask 的預設執行週期是 60 秒,可以透過配置項修改。

eureka.server.evictionIntervalTimerInMs=60 * 1000

服務註冊列表增量變更

PeerAwareInstanceRegistry 記錄了所有服務例項的狀態,當 Eureka Client 獲取服務列表時可以遍歷這個列表返回。但是一般情況下,叢集中短期內發生變化的例項數量不會太多,尤其是當叢集比較大的時候,每次重新整理服務列表時都全量返回其實並不必要。因此 Eureka Server 除了提供全量獲取服務的介面,還提供了獲取近期出現變更的服務例項的介面。

Eureka Server 實現增量的方式每次在更新服務列表後,都把有變更的例項 Lease 記錄在一個佇列裡(包括例項新增,例項刪除,例項的狀態變更的情況)

private ConcurrentLinkedQueue<RecentlyChangedItem> recentlyChangedQueue;

和這個佇列相關的關鍵配置是

eureka.server.retentionTimeInMSInDeltaQueue=3 * 60 * 1000
eureka.server.deltaRetentionTimerIntervalInMs=30 * 1000

retentionTimeInMSInDeltaQueue 表示佇列裡的元素保留時間,預設是 3 分鐘。 deltaRetentionTimerIntervalInMs 表示檢查刪除佇列裡過期元素的時間間隔。也就是說,我們可以近似認為最近 3 分鐘內(實際上最久可能是最近 3 分 30 秒內),新增的例項、刪除的例項以及狀態發生變化的例項對應的 Lease 都會保留在這個佇列裡。

當 Eureka Client 以增量的方式請求獲取服務列表時,Eureka Server 會把這個列表裡的元素對應的 Lease 返回給 Eureka Client。這裡有個問題是,Eureka Client 獲取的增量服務列表是有可能包含重複資訊,Eureka 要求由客戶端處理這種重複的情況。

具體的 Eureka Client 獲取服務列表的方式會在後面分析客戶端原理時詳細說明。

Response 快取

Eureka Server 的介面支援以 JSON 和 XML 的格式返回資料還支援對資料壓縮。Eureka Client 在獲取服務列表時,Eureka Server 會把服務例項資訊按請求的格式序列化和壓縮後返回。當叢集裡 Eureka Client 比較多時,如果每次返回響應時都去做序列化和壓縮,那麼就會浪費資源在重複的操作上。Eureka Server 對響應做了快取,這樣在處理 Eureka Client 請求時就可以直接從快取獲得已經序列化完成和壓縮完成的資料返回了。

Eureka Server 的快取分為兩層,它們之間的關係如下圖。

eureka_cache

ReadOnlyCache 顧名思義是隻讀的,它會定期從 ReadWriteCache 讀取資料來重新整理自己的資料。重新整理的週期可以透過配置控制,預設是 30 秒。

eureka.server.responseCacheUpdateIntervalMs=30 * 1000

ReadWriteCache 並不會定期重新整理自身的資料,只會在出現 cache miss 時再從 Registry 獲取對應的資料。ReadWriteCache 快取的資料失效的情況有兩種。 一是當 Registry 發生變更時會呼叫 invalidate 方法使 ReadWriteCache 對應的資料失效,二是快取的資料超時自動過期失效。過期時間預設是 180 秒,可以透過配置修改。

eureka.server.responseCacheAutoExpirationInSeconds=180

預設情況下 Eureka Client 獲取服務的請求會從 ReadOnlyCache 返回。因為 ReadOnlyCache 是定時重新整理的,所以有可能拿到的結果並不是最新的。ReadOnlyCache 可以透過配置關閉。

eureka.server.useReadOnlyResponseCache=false

不使用 ReadOnlyCache 時響應從 ReadWriteCache 返回。因為 ReadWriteCache 不會自動定時重新整理,所以出現 cache miss 的請求會需要相對更長的時間才能返回。

自我保護模式

自我保護模式的作用是防止當出現網路分隔,服務雖然正常執行但無法與 Eureka Server 保持心跳的情況下,Eureka Server 把這些服務例項當作過期例項而刪除。如下圖,服務本身是正常的,但服務傳送心跳的網路發生異常。如果沒有自我保護模式,那麼這些服務例項會被過期刪除,此時服務呼叫方將無法從 Eureka Server 獲取到這些服務。

前面的介紹有提到過,過期的 Lease 會被 EvictionTask 刪除。EvictionTask 執行時會先判斷 Eureka Server 當前是否處於自我保護模式。在自我保護模式下,EvictionTask 不會刪除過期的例項,但新的例項依舊可以正常註冊。
eureka_wlfg

自我保護模式的觸發條件是當 Eureka Server 最近一分鐘實際收到的心跳數低於最少心跳數閾值。

public boolean isLeaseExpirationEnabled() { 
   
		if (!isSelfPreservationModeEnabled()) { 
   
		    return true;
    }
    return numberOfRenewsPerMinThreshold > 0 && getNumOfRenewsInLastMin() > numberOfRenewsPerMinThreshold;
}

其中 numberOfRenewsPerMinThreshold 是透過當前已註冊的例項數目算出來的。

this.numberOfRenewsPerMinThreshold = (int) ((count * 2) * serverConfig.getRenewalPercentThreshold());

count 指的是當前註冊的例項數目, count * 2 即理想情況下每分鐘應收到的心跳數(心跳間隔 30 秒), renewalPercentThreshold 是最低心跳數閾值百分比,預設值是 0.85。也就是說,預設情況下,當最近一分鐘的收到的心跳次數低於應該收到的心跳次數的 85% 時,就會進入自我保護,此時過期的例項不會被刪除,直到心跳次數恢復到 85% 以上。

心跳次數閾值百分比可以透過配置設定。

eureka.server.renewalPercentThreshold=0.85

另外可以透過配置把自我保護模式關閉,關閉後無論收到多少次心跳,過期的例項都會被刪除。

eureka.server.enableSelfPreservation=false

一致性和可用性

Eureka Server 叢集的節點沒有主從之分,每個節點都可以同時處理讀寫請求。雖然節點收到的寫請求會同步到其他節點,但是沒有采用任何措施(比如一致性協議)保證寫請求同步到其節點。因此 Eureka Server 並不能保證資料的強一致,只能保證當叢集穩定時,各個節點的資料最終會達到一致,但在這之前,不同節點返回的資料可能不一樣。

犧牲資料一致性換來的是叢集更高的可用性。CP 系統一般要求叢集至少有過半數的節點存活,才能保證正常處理讀寫請求。而 Eureka Server 叢集只需要至少有一個節點存活,就能夠正常提供服務(zookeeper需要一半以上的節點存活),雖然此時叢集返回的資料不一定準確。

因此,Eureka Server 叢集是一個 AP 系統。作為一個服務註冊中心,這意味著當叢集發生極端異常時,與其為了保證服務列表的一致性而使服務註冊不可用,它選擇儘可能保證服務發現功能可用而犧牲服務註冊列表的準確性。

客戶端原理

Eureka Client 封裝了與 Eureka Server 進行各種互動的程式碼邏輯。叢集中的服務需要引入 Eureka Client,並透過 Eureka Client 與 Eureka Server 進行互動。Eureka Client 的主要職責包括

  • 服務啟動時註冊服務
  • 定時傳送心跳來更新 Lease
  • 服務下線時取消註冊
  • 獲取和定時更新已註冊的服務列表

如果一個服務只呼叫其他服務,但自身不提供服務,那麼可以透過配置控制不註冊自身例項

eureka.client.registerWithEureka=false

相反,如果一個服務只提供服務,但不需要呼叫其他服務,那麼可以配置不獲取服務列表

eureka.client.fetchRegistry=false

eureka_client

上圖是 Eureka Client 的內部結構。

  • Applications :儲存了從 Eureka Server 獲取到的服務資訊,相當於 Eureka Client 端的服務列表快取。
  • InstanceInfo :維護了自身服務例項的資訊,註冊和心跳時需要用到。
  • QueryClient :負責從 Eureka Server 獲取已註冊的服務,並且更新 Applications 。
  • RegistrationClient :負責在服務啟動時傳送註冊請求,然後定期傳送心跳,最後在服務下線之前取消註冊。
  • ClusterResolverQueryClientRegistrationClient 在傳送請求前需要先知道 Eureka Server 的地址,ClusterResolver 可以根據不同的策略和實現返回 Eureka Server 地址列表以供選擇。
  • JerseyApplicationClient :是真正傳送網路請求的 Http client,QueryClientRegistrationClient 獲取到 Eureka Server 地址後會建立一個 JerseyApplicationClient 和該 Eureka Server 通訊。

獲取 Server 地址

Eureka Client 在和 Eureka Server 通訊之前,需要先獲得 Eureka Server 的地址。如果 Eureka Server 是多例項部署的,那麼還需要對這些地址做優先順序排序,然後 Eureka Client 在發起呼叫時會按順序呼叫,失敗時再嘗試下一個 Eureka Server。

Eureka Server 的地址由 ClusterResolver 提供。它暴露了一個介面用來返回 Eureka Server 地址列表。

public interface ClusterResolver<T extends EurekaEndpoint> { 
   

		// ...

    List<T> getClusterEndpoints();
}

預設情況使用的是 ConfigClusterResolver ,從配置檔案裡獲取 Eureka Server 地址。

ConfigClusterResolver 會被 ZoneAffinityClusterResolver 代理,ZoneAffinityClusterResolver 會進一步根據是否和例項本身處於同一個可用區,把 Eureka Server 地址分成兩部分,然後在隨機排列後,按同區在前,不同區在後的順序返回 Eureka Server 地址列表。

後續 Eureka Client 在傳送請求時會以這個列表的順序作為優先順序選擇 Eureka Server。這樣做可以讓 Eureka Client 優先和同區的 Eureka Server 互動。隨機化能讓 Eureka Server 負載儘量平均。

構造 EurekaHttpClient

在知道如何獲取 Eureka Server 地址列表之後,Eureka Client 還需要建立 EurekaHttpClient 物件來發起 http 請求。

Eureka Client 在初始化時需要建立兩個 EurekaHttpClient, 分別是 QueryClientRegistrationClientQueryClient 主要負責傳送獲取服務列表請求,RegistrationClient 負責傳送註冊、心跳等請求。

從類圖來看,EurekaHttpClient 使用裝飾者模式。
eureka_zsqms

JerseyApplicationClient 是最終負責傳送請求的實現,在其之上做了裝飾。最後生成的 client 結構如下圖。QueryClientRegistrationClient 生成 client 的方式是一樣的,只是在使用時呼叫的介面不同。QueryClient 只使用了和獲取服務相關的介面,而 RegistrationClient 需要呼叫註冊、心跳等介面。
eureka_clientjg

JerseyApplicationClient 建立時需要一個 Eureka Server 的 Url,它只會向該 Eureka Server 傳送請求。在 JerseyApplicationClient 之外套了多個裝飾類。

MetricsCollectingEurekaHttpClient 用於對請求和響應做統計,比如請求用時,響應返回碼統計等。

RedirectingEurekaHttpClient 主要處理了重定向。當請求返回 302 時,RedirectingEurekaHttpClient 會根據返回的重定向地址建立新的 JerseyApplicationClient ,然後重試請求。

RetryableEurekaHttpClient 實現了重試的邏輯。同時維護了一個 quarantineSet ,執行請求返回失敗的 Eureka Server 會被加入其中,然後再尋找下一個可用的 Eureka Server 重試請求。

quarantineSet 有大小閾值,當超過閾值時,裡面的 Eureka Server 會被釋放出來,下次重試請求時會再次嘗試這些 Eureka Server。這個閾值可以透過配置設定。

eureka.client.transport.retryableClientQuarantineRefreshPercentage=0.66

預設情況下,當 quarantineSet 裡包括超過三分之二的 Eureka Server 時,quarantineSet 會被重置,之前在裡面的 Eureka Server 會被重新當作可用的。

RetryableEurekaHttpClient 整體的工作流程圖如下。其中的 currentHttpClient 指的是被 RetryableEurekaHttpClient 裝飾的物件。
eureka_retry

SessionedEurekaHttpClient 裝飾了 RetryableEurekaHttpClient 併為其建立一個 session。當 session 時間過後,RetryableEurekaHttpClient 會被重新建立。

這樣做的目的是為了使叢集中 Eureka Server 節點的負載儘量平均。假設現在叢集裡新增了一個新的 Eureka Server 節點,如果建立新 的 client,那麼除非發生異常切換,否則現有的 Eureka Client 還是會把請求發到老的 Eureka Server 節點,而新的節點不會收到請求。SessionedEurekaHttpClient 在當前 session 結束建立新 session 時給了 Eureka Client 重新選擇 Eureka Server 的機會,能讓叢集裡的 Eureka Client 儘量連線到不同的 Eureka Server。

session 的時長可以透過配置設定。

eureka.client.transport.sessionedClientReconnectIntervalSeconds=20 * 60

最終使用的 session 時長會在這個配置值的基礎上加上一個隨機值,這個隨機值的區間是

[-sessionDuration / 2, sessionDuration / 2]

也就是說預設情況下 session 的時長範圍是 10 到 30 分鐘。

獲取服務列表

服務呼叫方在呼叫其他服務時需要先從 Eureka Server 獲取服務列表,但這一過程不需要每次發起呼叫時都重複。Eureka Client 會在本地維護一份服務列表的快取,並負責和 Eureka Server 同步來更新快取。

Eureka Client 在啟動會先從 Eureka Server 獲取全量的服務列表,並儲存到本地。隨後 Eureka Client 還要定時獲取服務列表來更新本地快取。更新快取的時間間隔可以透過配置設定,預設是 30 秒

eureka.client.registryFetchIntervalSeconds=30

由於正常情況下叢集中大部分的服務例項資訊不會發生變化,所以沒有必要每次在更新時都全量拉取服務列表。Eureka Client 在更新服務列表快取時會優先使用增量更新的方式。

前面介紹服務端原理的時候有介紹過 Eureka Server 會維護一個 recentlyChangedQueue ,裡面儲存最近一段時間有發生變化的例項,這些資訊會在 recentlyChangedQueue 保留一段時間,過期後刪除。Eureka Server 返回增量變化資訊其實就是讀取的 recentlyChangedQueue 的內容。因此使用增量更新的方式需要處理兩個問題。

  • 如果 Eureka Client 因為某些原因(比如網路異常)長時間沒能獲取到增量變更,那麼 recentlyChangedQueue 裡的內容會被刪除,被刪除的資訊後續 Eureka Client 就再也不能從增量介面獲取到了, Eureka Client 本地的快取因此會丟失更新。此時 Eureka Client 需要重新全量獲取服務列表以保持和 Eureka Server 的資料一致。
  • Eureka Client 前後兩次獲取到的增量資訊內容是有可能重複的,Eureka Client 要能處理這種重複的響應。

先看一下 Eureka Client 獲取服務列表相關的流程。
eureka_cacheupdate

如果本地快取為空或者說增量拉取模式關閉,那麼會直接獲取全量的服務列表。透過配置可以控制是否使用增量拉取模式。

eureka.client.disableDelta=false

Eureka Server 在返回增量資訊時還會同時返回 Eureka Server 服務列表的 hashcode 。Eureka Client 更新完本地快取之後也會計算本地的 hashcode 並和 Eureka Server 返回的比較。如果兩者不同,那麼說明本地快取的資料和 Eureka Server 出現差異了,此時 Eureka Client 會再發起全量獲取服務列表的請求,以保證本地快取和 Eureka Server 的一致。這樣就解決了第一個問題。另外,每次更新完本地快取後還會對服務例項列表做隨機重排,這樣做是為了避免不同的 Eureka Client 都優先使用相同的例項。

Eureka Server 的 recentlyChangedQueue 記錄的 Lease 裡除了記錄例項資訊,還標記了增量型別。增量型別有三種: ADDEDMODIFIEDDELETED。分別表示例項的新增、狀態變更和刪除。Eureka Client 在更新本地快取時需要根據不同的增量型別做不同的操作。

  • ADDED 有例項新增時,Eureka Client 需要先根據例項 ID 判斷本地快取是否有該例項。如果沒有那麼直接新增,如果已經有了那就用新返回的例項資訊更新快取。
  • MODIFIED 有例項更新時的操作和例項新增類似,即根據例項 ID 查詢本地快取,無則新增,有則更新。
  • DELETED 有例項被刪除時,Eureka Client 只需要從本地快取裡把 ID 相同的例項刪除即可。

因為每個服務例項都有唯一的例項 ID 標識,Eureka Client 的這些操作可以做到冪等的。因此就算增量介面返回相同的資料,Eureka Client 也能夠正確處理。

在叢集穩定的情況下,Eureka Client 使用增量的方式更新快取可以節省頻寬和加快更新效率,一般情況下都建議使用增量更新。

最後 Eureka 整體核心模組的互動過程如下圖。
eureka_hxjh

其他方案

除了 Eureka 之外,還有一些比較常見的可以用於服務發現的方案。

  • Zookeeper/etcd:這兩者本身都是一個分散式 K/V 儲存系統,但是可以用來作為服務註冊中心。兩者在寫入資料之前都會由分散式一致性演算法(Zab/Raft)來保證資料一致性,是 CP 系統。
  • Consul/Nacos:這兩者都是專門用來做服務發現的,並且除了服務發現之外還提供其他功能,比如配置管理等。其中 Consul 也是一個 CP 系統,而 Nacos 可由使用者選擇 AP 或 CP 模式。

與這些方案相比,Eureka 首先是一個專門為了做服務註冊中心而開發的系統,Eureka 沒有其他如配置管理等功能。

其次,Eureka 是一個 AP 系統,它不保證資料的強一致,只透過簡單的資料同步來保證最終一致性。從可用性角度來看,Eureka 的可用性比其他 CP 系統的可用性更強。我們認為在服務發現的場景下,Eureka 犧牲資料一致性來保證更高的可用性的決定是合理的。

總結

本文介紹了服務註冊中心 Eureka 的工作原理,分別從 Eureka Server 和 Eureka Client 兩方面詳細分析了兩者的主要模組和功能。作為一個 AP 系統,Eureka 在 server 和 client 端均採用了快取,server 端的資料同步也不保證一致性,因此和其他 CP 系統方案相比,Eureka 在發生異常的情況下犧牲了資料一致性,但提高了可用性。

相關文章