服務發現-從原理到實現

高效能架構探索發表於2021-10-18

服務發現,作為網際網路從業人員,大家應該都不陌生,一個完善的服務叢集,微服務是必不可少的功能之一。

最近一直想寫這個話題,也一直在構思,但不知道從何入手,或者說不知道寫哪方面。如果單純寫如何實現,這個未免太乏味枯燥了;而如果只是介紹現有成熟方案呢,卻達不到我的目的。想了很久,準備先從微服務的架構入手,切入 服務發現 要解決什麼問題,搭配常見的處理模式,最後介紹下現有的處理方案。

微服務服務於分散式系統,是個分散式系統。服務部署跨主機、網段、機房乃至大區。各個服務之間通過RPC(remote procedure call)進行呼叫。然後,在架構上最重要的一環,就是服務發現。如果說服務發現是微服務架構的靈魂也當之無愧,試想一下,當一個系統被拆分成多個服務,且被大量部署的時候,有什麼能比"找到"想呼叫的服務在哪裡,以及能否正常提供服務重要呢?同樣的,有新服務啟動時,如何讓其他服務知道該服務在哪裡?

微服務考研的是治理大量服務的能力,包含多種服務,同樣也包含多個例項。

1概念

服務發現之所以重要,是因為它解決了微服務架構最關鍵的問題:如何精準的定位需要呼叫的服務ip以及埠。無論使用哪種方式來提供服務發現功能,大致上都包含以下三點:

  • Register, 服務啟動時候進行註冊
  • Query, 查詢已註冊服務資訊
  • Healthy Check,確認服務狀態是否健康

整個過程很簡單。大致就是在服務啟動的時候,先去進行註冊,並且定時反饋本身功能是否正常。由服務發現機制統一負責維護一份正確或者可用的服務清單。因此,服務本身需要能隨時接受查下,反饋呼叫方服務所要的資訊。

2註冊模式

一整套服務發現機制順利執行,首先就得維護一份可用的服務列表。包含服務註冊與移除功能,以及健康檢查。服務是如何向註冊中心"宣告"自身的存在?健康檢查,是如何確認這些服務是可用的呢?

做法大致分為兩類:

  • 自注冊模式 自注冊,顧名思義,就是上述這些動作,有服務(client)本身來維護。每個服務啟動後,需要到統一的服務註冊中心進行註冊登記,服務正常終止後,也可以到註冊中心移除自身的註冊記錄。在服務執行過程中,通過不斷的傳送心跳資訊,來通知註冊中心,本服務執行正常。註冊中心只要超過一定的時間沒有收到心跳訊息,就可以將這個服務狀態判斷為異常,進而移除該服務的註冊記錄。

  • 三方註冊模式 這個模式與自注冊模式相比,區別就是健康檢查的動作不是有服務本身(client)來負責,而是由其它第三方服務來確認。有時候服務自身傳送心跳資訊的方式並不精確,因為可能服務本身已經存在故障,某些介面功能不可用,但仍然可以不斷的傳送心跳資訊,導致註冊中心沒有發覺該服務已經異常,從而源源不斷的將流量打到已經異常的服務上來。

這時候,要確認服務是否正常運轉的健康檢查機制,就不能只依靠心跳,必須通過其它第三方的驗證(ping),不斷的從外部來確認服務本身的健康狀態。

這些都是有助於協助註冊中心提高服務列表精確到的方法。能越精確的提高服務清單狀態的可靠性,整套微服務架構的可靠度就會更高。這些方法不是互斥的,在必要的時候,可以搭配使用。

3發現模式

服務發現的發現機制主要包括三種:

  • 服務提供者:服務啟動時將服務資訊註冊到註冊中心,服務退出時將註冊中心的服務資訊刪除掉。

  • 服務消費者:從服務登錄檔獲取服務提供者的最新網路位置等服務資訊,維護與服務提供者之間的通訊。

  • 註冊中心:服務提供者和服務消費者之間的一個橋樑

服務發現機制的關鍵部分是註冊中心。註冊中心提供管理和查詢服務註冊資訊的API。當服務提供者的例項發生變更時(新增/刪除服務),服務登錄檔更新最新的狀態列表,並將其最新列表以適當的方式通知給服務消費者。目前大多數的微服務框架使用Netflix Eureka、Etcd、Consul或Apache Zookeeper等作為註冊中心。

為了說明服務發現模式是如何解決微服務例項地址動態變化的問題,下面介紹兩種主要的服務發現模式:

  • 客戶端發現模式
  • 服務端發現模式。

客戶端模式與服務端模式,兩者的本質區別在於,客戶端是否儲存服務列表資訊。

客戶端發現模式

在客戶端模式下,如果要進行微服務呼叫,首先要進行的是到服務註冊中心獲取服務列表,然後再根據呼叫端本地的負載均衡策略,進行服務呼叫。

在上圖中,client端提供了負載均衡的功能,其首先從註冊中心獲取服務提供者的列表,然後通過自身負載均衡演算法,選擇一個最合理的服務提供者進行呼叫:

1、 服務提供者向註冊中心進行註冊,提交自己的相關資訊

2、 服務消費者定期從註冊中心獲取服務提供者列表

3、 服務消費者通過自身的負載均衡演算法,在服務提供者列表裡面選擇一個合適的服務提供者,進行訪問

客戶端發現模式的優缺點如下:

  • 優點:

    • 負載均衡作為client中一個功能,用自身的演算法,從服務提供者列表中選擇一個合適服務提供者進行訪問,因此client端可以定製化負載均衡演算法。優點是服務客戶端可以靈活、智慧地制定負載均衡策略,包括輪詢、加權輪詢、一致性雜湊等策略。
    • 可以實現點對點的網狀通訊,即去中心化的通訊。可以有效避開單點造成的效能瓶頸和可靠性下降等問題。
    • 服務客戶端通常以SDK的方式直接引入到專案,這種方式語言的整合程度最佳,程式執行效能最佳,程式錯誤排查更加容易。
  • 缺點:

    • 當負載均衡演算法需要更新時候,很難做到同一時間全部更新,所以就造成新舊演算法同時執行
    • 與註冊中心緊密耦合,如果要換註冊中心,需要去修改程式碼,重新上線。微服務的規模越大,服務更新越困難,這在一定程度上違背了微服務架構提倡的技術獨立性。

目前來說,大部分服務發現的實現都採取了客戶端模式。

服務端發現模式

在服務端模式下,呼叫方直接向服務註冊中心進行請求,服務註冊中心再通過自身負載均衡策略,對微服務進行呼叫。這個模式下,呼叫方不需要在自身節點維護服務發現邏輯以及服務註冊資訊。

在服務端模式下: 1、 服務提供者向註冊中心進行服務註冊 2、 註冊中心提供負載均衡功能, 3、 服務消費者去請求註冊中心,由註冊中心根據服務提供列表的健康情況,選擇合適的服務提供者供服務消費者呼叫

現代容器化部署平臺(如Docker和Kubernetes)就是服務端服務發現模式的一個例子,這些部署平臺都具有內建的服務登錄檔和服務發現機制。容器化部署平臺為每個服務提供路由請求的能力。服務客戶端向路由器(或者負載均衡器)發出請求,容器化部署平臺自動將請求路由到目標服務一個可用的服務例項。因此,服務註冊,服務發現和請求路由完全由容器化部署平臺處理。

服務端發現模式的特點如下:

  • 優點:
    • 服務消費者不需要關心服務提供者的列表,以及其採取何種負載均衡策略
    • 負載均衡策略的改變,只需要註冊中心修改就行,不會出現新老演算法同時存在的現象
    • 服務提供者上下線,對於服務消費者來說無感知
  • 缺點:
    • rt增加,因為每次請求都要請求註冊中心,尤其返回一個服務提供者
    • 註冊中心成為瓶頸,所有的請求都要經過註冊中心,如果註冊服務過多,服務消費者流量過大,可能會導致註冊中心不可用
    • 微服務的一個目標是故障隔離,將整個系統切割為多個服務共同執行,如果某服務無法正常執行,只會影響到整個系統的相關部分功能,其它功能能夠正常執行,即去中心化。然而,服務端發現模式實際上是集中式的做法,如果路由器或者負載均衡器無法提供服務,那麼將導致整個系統癱瘓。

4實現方案

file

以檔案的形式實現服務發現,這是一個比較簡單的方案。其基本原理就是將服務提供者的資訊(ip:port)寫入檔案中,服務消費者載入該檔案,獲取服務提供者的資訊,根據一定的策略,進行訪問。

需要注意的是,因為以檔案形式提供服務發現,服務消費者要定期的去訪問該檔案,以獲得最新的服務提供者列表,這裡有個小優化點,就是可以有個執行緒定時去做該任務,首先去用該檔案的最後一次修改時間跟服務上一次讀取檔案時候儲存的修改時間做對比,如果時間一致,表明檔案未做修改,那麼就不需要重新做載入了,反之,重新載入檔案。

檔案方式實現服務發現,其特點顯而易見:

  • 優點:實現簡單,去中心化
  • 缺點:需要服務消費者去定時操作,如果某一個檔案推送失敗,那麼就會造成異常現象

zookeeper

ZooKeeper 是一個集中式服務,用於維護配置資訊、命名、提供分散式同步和提供組服務。

zookeeper 樹形結構zookeeper 樹形結構

zookeeper是一個樹形結構,如上圖所示。

使用zookeeper實現服務發現的功能,簡單來講,就是使用zookeeper作為註冊中心。服務提供者在啟動的時候,向zookeeper註冊其資訊,這個註冊過程其實就是實際上在zookeeper中建立了一個znode節點,該節點儲存了ip以及埠等資訊,服務消費者向zookeeper獲取服務提供者的資訊。 服務註冊、發現過程簡述如下:

  • 服務提供者啟動時,會將其服務名稱,ip地址註冊到配置中心
  • 服務消費者在第一次呼叫服務時,會通過註冊中心找到相應的服務的IP地址列表,並快取到本地,以供後續使用。當消費者呼叫服務時,不會再去請求註冊中心,而是直接通過負載均衡演算法從IP列表中取一個服務提供者的伺服器呼叫服務
  • 當服務提供者的某臺伺服器當機或下線時,相應的ip會從服務提供者IP列表中移除。同時,註冊中心會將新的服務IP地址列表傳送給服務消費者機器,快取在消費者本機
  • 當某個服務的所有伺服器都下線了,那麼這個服務也就下線了
  • 同樣,當服務提供者的某臺伺服器上線時,註冊中心會將新的服務IP地址列表傳送給服務消費者機器,快取在消費者本機
  • 服務提供方可以根據服務消費者的數量來作為服務下線的依據
服務註冊

假設我們服務提供者的服務名稱為services,首先在zookeeper上建立一個path /services,在服務提供者啟動時候,向zookeeper進行註冊,其註冊的原理就是建立一個路徑,路徑為/services/$ip:port,其中ip:port為服務提供者例項的ip和埠。如下圖所示,我們現在services例項有三個,其ip:port分別為192.168.1.1:1234、192.168.1.2:1234、192.168.1.3:1234和192.168.1.4:1234,如下圖所示:

健康檢查

zookeeper實現了一種TTL的機制,就是如果客戶端在一定時間內沒有向註冊中心傳送心跳,則會將這個客戶端摘除。

獲取服務提供者的列表

前面有提過,zookeeper實際上是一個樹形結構,那麼服務消費者是如何獲取到服務提供者的資訊呢?最重要的也是必須的一點就是 知道服務提供者資訊的父節點路徑。以上圖為例,我們需要知道

/services

通過zookeeper client提供的介面 getchildren(path)來獲取所有的子節點。

感知服務上線與下線

zookeeper提供了“心跳檢測”功能,它會定時向各個服務提供者傳送一個請求(實際上建立的是一個 socket 長連線),如果長期沒有響應,服務中心就認為該服務提供者已經“掛了”,並將其剔除,比如192.168.1.2這臺機器如果當機了,那麼zookeeper上的路徑/services/下就會只剩下192.168.1.1:1234, 192.168.1.2:1234,192.168.1.4:1234。如下圖所示:

服務下線服務下線

假設此時,重新上線一個例項,其ip為192.168.1.5,那麼此時zookeeper樹形結構如下圖所示:

服務上線服務上線

服務消費者會去監聽相應路徑(/services),一旦路徑上的資料有任務變化(增加或減少),zookeeper都會通知服務消費方服務提供者地址列表已經發生改變,從而進行更新。

實現

下面是服務提供者在zookeeper註冊中心註冊時候的核心程式碼:

int ZKClient::Init(const std::string& host, int timeout,
                          int retry_times) {
  host_ = host;
  timeout_ = timeout;
  retry_times_ = retry_times;


  hthandle_ = zookeeper_init(host_.c_str(), GlobalWatcher, timeout_,
                             NULL, this, 0);
  return (hthandle_ != NULL) ? 0 : -1;
}

int ZKClient::CreateNode(const std::string& path,
                                const std::string& value,
                                int type) {
  int flags;
  if (type == Normal) {
    flags = 0;
  } else if (type == Ephemeral) {
    flags = ZOO_EPHEMERAL;
  } else {
    return -1;
  }

  int ret = zoo_exists(hthandle_, path.c_str(), 0, NULL);
  if (ret == ZOK) {
    return -1;
  }
  if (ret != ZNONODE) {
    return -1;
  }

  ret = zoo_create(hthandle_, path.c_str(), value.c_str(), value.length(),
                   &ZOO_OPEN_ACL_UNSAFE, flags, NULL, 0);
  return ret == ZOK ? 0 : -1;
}


int main() {
  std::string ip; // 當前服務ip
  int port; // 當前服務的埠
  std::string path = "/services/" + ip + ":" + std::to_string(port);
  
  ZKClient zk;
  zk.init(...);
  
  //初始化zk客戶端
  zk.CreateNode(path, "", Ephemeral);
  
  ...
  return 0
}

上面是服務提供者所做的一些操作,其核心功能就是:

在服務啟動的時候,使用zookeeper的客戶端,建立一個臨時(Ephemeral)節點

從程式碼中可以看出,建立znode的時候,指定了其node型別為Ephemeral,這塊非常重要,在zookeeper中,如果znode型別為Ephemeral,表明,在服務提供者跟註冊中心斷開連線的時候,這個節點會自動小時,進而註冊中心會通知服務消費者重新獲取服務提供者列表。


下面是服務消費者的核心程式碼:

int ZKClient::Init(const std::string& host, int timeout,
                          int retry_times) {
  host_ = host;
  timeout_ = timeout;
  retry_times_ = retry_times;


  hthandle_ = zookeeper_init(host_.c_str(), GlobalWatcher, timeout_,
                             NULL, this, 0);
  return (hthandle_ != NULL) ? 0 : -1;
}

int ZKClient::GetChildren(const std::string& path,
                                 const std::function<int(const std::vector<std::tuple<std::string, std::string>>)>& on_change,
                                 std::vector<std::tuple<std::string, std::string>>* children) {
  std::lock_guard<std::recursive_mutex> lk(mutex_);
  
  int ret = zoo_get_children(handle, path, 1, children); // 通過來獲取子節點
  if (ret == ZOK) {
   node2children_[path] = std::make_tuple(on_change, *children); // 註冊事件
  }
  return ret;

}

int main() {
  ZKClient zk;
  zk.Init(...);
  
  std::vector children
  // 設定回撥通知,即在某個path下子節點發生變化時,進行通知
  zk.GetChildren("/services", callback, children);
  
  ...
  return 0;
}

對於服務消費者來說,其需要有兩個功能:

  • 獲取服務提供者列表
  • 在服務提供者列表發生變化時,能得到通知

其中第一點可以通過

zoo_get_children(handle, path, 1, children);

來獲取列表,那麼如何在服務提供者列表發生變化時得到通知呢? 這就用到了zookeeper中的watcher機制。

watcher目的是在 znode 以某種方式發生變化時得到通知。watcher僅被觸發一次。 如果您想要重複通知,您將需要重新註冊觀察者。 讀取操作(例如exists、get_children、get_data)可能會建立監視。

okeeper中的watcher機制,不在本文的討論範圍內,有興趣的讀者,可以去查閱相關書籍或者資料。

下面,我們對使用zookeeper作為註冊中心,服務提供者和消費者需要做的操作進行下簡單的總結:

  • 服務提供者
    • 在服務啟動的時候,使用zookeeper的客戶端,建立一個臨時(Ephemeral)節點
  • 服務消費者
    • 通過zoo_get_children獲取子節點
    • 註冊watcher回撥,在path下發生變化時候,會直接呼叫該回撥函式,在回撥函式內重新獲取子節點,並重新註冊回撥

etcd

Etcd是基於Go語言實現的一個KV結構的儲存系統,支援服務註冊與發現的功能,官方將其定義為一個可信賴的分散式鍵值儲存服務,主要用於共享配置和服務發現。其特點如下:

  • :安裝配置簡單,而且提供了 HTTP API 進行互動,使用也很簡單 鍵值對儲存:
  • 據儲存在分層組織的目錄中,如同在標準檔案系統中
  • 變更:監測特定的鍵或目錄以進行更改,並對值的更改做出反應
  • :根據官方提供的 benchmark 資料,單例項支援每秒 2k+ 讀操作
  • :採用 Raft 演算法,實現分散式系統資料的可用性和一致性
服務註冊

每一個伺服器啟動之後,會向Etcd發起註冊請求,同時將自己的基本資訊傳送給 etcd 伺服器。伺服器的資訊是通過KV鍵值進行儲存。key 是使用者真實的 key, value 是對應所有的版本資訊。keyIndex 儲存 key 的所有版本資訊,每刪除一次都會生成一個 generation,每個 generation 儲存了這個生命週期內從建立到刪除中間的所有版本號。

更新資料時,會開啟寫事務。

  • 會根據當前版本的key,rev在 keyindex 中查詢是否有當前 key 版本的記錄。主要獲取 created 與 ver 的資訊。
  • 生成新的 KeyValue 資訊。
  • 更新 keyindex 記錄。
健康檢查

在註冊時,會初始化一個心跳週期 ttl 與租約週期 lease。伺服器需要在心跳週期之內向 etcd 傳送資料包,表示自己能夠正常工作。如果在規定的心跳週期內,etcd 沒有收到心跳包,則表示該伺服器異常,etcd 會將該伺服器對應的資訊進行刪除。如果心跳包正常,但是伺服器的租約週期結束,則需要重新申請新的租約,如果不申請,則 etcd 會刪除對應租約的所有資訊。

在 etcd 中,並不是在磁碟中刪除對應的 keyValue 資訊,而是對其進行標記刪除。

  • 首先在 delete 中會生成一個 ibytes,對其追加標記,表示這個 revision 是 delete。
  • 生成一個 KeyValue,該 KeyValue 只包含Key的資訊。
  • 同時修改 Tombstone 標誌位,結束當前生命週期,生成一個新的 generation,更新 kvindex。

再次需要做個說明,因為筆者是從事c++開發的,現線上上業務用的zookeeper來作為註冊中心實現服務發現功能。上半年的時候,也曾想轉到etcd上,但是etcd對c++並不友好,筆者用了將近兩週時間各種調研,編譯,發現竟然不能將其編譯成為一個靜態庫...

需要特別說明的是,用的是etcd官網推薦的c++客戶端etcd-cpp-apiv3

縱使etcd功能再強大,不能支援c++,算是一個不小的遺憾。對於筆者來說,算是個損失吧,希望後續能夠支援。

下面是etcd c++ client 不支援靜態庫,作者以及其他使用者的反饋,以此作為本章節的結束。

etcd官網指定的c++clientetcd官網指定的c++client 作者回復作者回復 使用者反饋使用者反饋

5結語

微服務架構模式下,服務例項動態配置,因此服務消費者需要動態瞭解到服務提供者的變化,所以必須使用服務發現機制。

服務發現的關鍵部分是註冊中心。註冊中心提供註冊和查詢功能。目前業界開源的有Netflix Eureka、Etcd、Consul或Apache Zookeeper,大家可以根據自己的需求進行選擇。

服務發現主要有兩種發現模式:客戶端發現和服務端發現。客戶端發現模式要求客戶端負責查詢註冊中心,獲取服務提供者的列表資訊,使用負載均衡演算法選擇一個合適的服務提供者,傳送請求。服務端發現模式,客戶端每次都請求註冊中心,由註冊中心內部選擇一個合適的服務提供者,並將請求轉發至該服務提供者,需要注意的是 當一個請求過來的時候,註冊中心內部獲取服務提供者列表和使用負載均衡演算法

這個世界沒有完美的架構和模式,不同的場景都有適合的解決方案。我們在調研決策的時候,一定要根據實際情況去權衡對比,選擇最適合當前階段的方案,然後通過漸進迭代的方式不斷完善優化方案。

相關文章