The two most important days in your life are the day you were born and the day you find out why. -Mark Twain

#107 分散式系統的「通訊錄」— 淺談服務發現 (Service Discovery)

為什麼分散式系統需要一本「通訊錄」?

在前幾篇文章裡談論了分散式系統中的時間與順序、共識演算法,以及資料分片等重要的基礎知識。如果你跟著這系列文章一路讀過來,你應該已經能感受到,分散式系統就是一群電腦在一起合作達成目標的過程,而這個過程中有非常多需要被解決的問題。今天我們要來聊另一個看似簡單、但在實務上極其重要的事情:在一個擁有成百上千個電腦的分散式系統裡,一個電腦到底要怎麼知道它應該去找誰?

先用一個生活中的例子來了解這問題。想像你剛到一家大型企業上班,這家公司有上千名員工分佈在不同的辦公室和樓層。你的第一個任務要找到「會計部的小陳」幫你處理報帳的事情。問題是,你不知道小陳坐在哪裡,你甚至不知道會計部在哪一層樓。該怎麼辦?最直覺的方式就是翻開公司的通訊錄,上面清楚地寫著每個部門、每個人的座位位置和分機號碼。有了通訊錄,你就能快速地找到小陳,而不需要一間一間辦公室去敲門詢問。

分散式系統裡的「服務發現」(Service Discovery) 就是在做這件事情。在現代的軟體架構中,特別是所謂的微服務 (Microservices),一個大型的應用程式會被拆分成許多個小型的、獨立運行的服務。例如,一個電商網站可能會有「使用者服務」負責處理登入和帳號管理、「商品服務」負責管理商品資訊、「訂單服務」負責處理訂單、「付款服務」負責處理金流等等。這些服務各自運行在不同的機器上,它們之間需要透過網路溝通來完成一個完整的業務流程。當使用者下了一筆訂單,「訂單服務」需要去呼叫「商品服務」確認庫存,然後再去呼叫「付款服務」來扣款。

在只有少數幾台機器的時代,這件事情不難。工程師可以把每台機器的 IP 直接寫在設定檔。就好像你的手機通訊錄只有五個人,你閉著眼睛都記得每個人的號碼。但在現代的雲端環境裡,情況完全不同了。一個服務可能同時有十幾個甚至上百個實例 (Instance) 在運行,這些實例會因為自動擴展 (Auto-Scaling) 動態地增加或減少,機器 IP 位址會因此而變化。在這種情況下,如果你還把 IP 寫死在程式碼或設定檔裡,很快就會出問題,因為你通訊錄上的號碼全部都過期了。

所以,分散式系統需要一個「動態的通訊錄」,這個通訊錄能夠即時地反映系統中每個服務的最新位置和狀態。用一個簡單的圖來表示,這個動態通訊錄長這樣:

+-----------------------------------------------------------+
|                  Service Registry (通訊錄)                 |
+-----------------------------------------------------------+
|  Service Name  |  Instance          |  Status             |
|----------------|--------------------|---------------------|
|  payment       |  10.0.0.1:8080     |  healthy            |
|  payment       |  10.0.0.2:8080     |  healthy            |
|  order         |  10.0.1.1:8080     |  healthy            |
|  order         |  10.0.1.2:8080     |  unhealthy (removed)|
|  product       |  10.0.2.1:8080     |  healthy            |
+-----------------------------------------------------------+

這就是服務發現機制要解決的核心問題。

Naming 和 Discovery 是不同的事情

在深入服務發現的細節之前,有一個觀念值得先釐清:「命名」(Naming) 和「發現」(Discovery) 雖然經常被放在一起談,但它們是兩件不同的事情。

Naming 解決的是「名稱到位址的對應」。你有一個服務叫做 "order-service",Naming 機制告訴你它的 IP 位址是 192.168.1.100。這就像你知道一個人的名字,然後在通訊錄裡查到他的電話號碼。DNS 就是最典型的 Naming 機制,你輸入 www.google.com,DNS 幫你解析出對應的 IP 位址。

Discovery 解決的是更進一步的問題:「在多個候選者中,找到一個目前可用的、健康的實例」。它不只告訴你這個服務在哪裡,還告訴你目前哪些實例是活的、哪些是掛掉的、應該把請求發給誰。這就像你不只是要找到會計部小陳的分機號碼,你還需要知道小陳今天有沒有來上班、他現在是不是正在忙、如果小陳不在的話還有誰可以幫你處理。

傳統的 DNS 基本上只做 Naming 的工作。它可以告訴你一個域名對應到哪些 IP,但它不會去檢查這些 IP 後面的服務是不是還健康。像 Consul、etcd 這類工具則同時涵蓋了 Naming 和 Discovery 兩個面向,它們不僅維護名稱與位址的對應關係,還會主動監測每個實例的健康狀況,確保查詢者拿到的位址是真正可用的。理解這個區別很重要,因為它會影響你在設計系統時對工具的選擇。如果你只需要簡單的名稱解析,DNS 可能就夠了;但如果你需要動態的、有健康檢查的服務發現,你就需要更完整的解決方案。

服務發現的三個核心元素

在我們深入了解那些有名的工具之前,先來理解一個完整的服務發現機制需要那些元素。很多人一聽到服務發現,直覺就想到「服務註冊表」,覺得只要有一個中央資料庫記錄所有服務的位址就行了。但實際上,一個完整的服務發現機制至少包含三個不可或缺的元素:Service Registry(服務註冊表)、Health Model(健康模型)、以及 Resolution / Routing(解析與路由策略)。

第一個元素是 Service Registry,也就是我們的「通訊錄」本體。所有的服務在啟動時都會向它「報到」,告訴它自己的名字、IP 位址和 Port 號碼;在服務關閉時,也會向它「登出」。Service Registry 必須是高可用的,而且資料必須保持一致,否則整個服務發現就失去了意義。

第二個元素是 Health Model。光是知道「這個服務曾經在這裡註冊過」是不夠的,你還需要一個機制來持續判斷每個已註冊的實例是否仍然健康、能夠正常處理請求。Health Model 定義了什麼叫做「健康」、用什麼方式去檢查、以及多久檢查一次。

第三個元素是 Resolution / Routing。當用戶端需要呼叫某個服務時,它要如何從多個可用的實例中選擇一個?這涉及到負載平衡 (Load Balancing) 策略、路由規則,甚至是 failover(故障轉移)的邏輯。它們共同構成了一個完整的服務發現機制。只有 Registry 沒有 Health Model,你可能會把請求送給已經掛掉的實例;只有 Registry 和 Health Model 而沒有 Routing,你的用戶端就不知道該怎麼從一堆健康的實例中做出明智的選擇。

服務發現的兩種基本模式

了解了三個核心元素之後,我們來看服務發現在架構上有哪些做法。大致上,服務發現可以分成兩種基本的模式:用戶端發現 (Client-Side Discovery) 和伺服器端發現 (Server-Side Discovery)。讓我們用兩張對比圖來看它們的差異。

模式一:Client-Side Discovery(用戶端發現)

                    +--------------------+
                    | Service Registry   |
                    +--------------------+
                       ▲            |
              1. 查詢  |            | 2. 回傳 instance 清單
                       |            ▼
                 +--------------------+
                 |  Client            |
                 +--------------------+
                   |       |       |
      3. 自行選擇   |       |       |  (Load Balancing 邏輯在 Client 端)
      並直接呼叫    |       |       |
                   ▼       ▼       ▼
              +------+ +------+ +------+
              | Inst | | Inst | | Inst |
              |  A   | |  B   | |  C   |
              +------+ +------+ +------+

重點:Client 負責查詢 Registry、選擇 Instance、直接呼叫

模式二:Server-Side Discovery(伺服器端發現)

                 +-----------+
                 |  Client   |
                 +-----------+
                        |
         1. 請求送到     |  (Client 只知道 LB 的位址)
          Load Balancer |
                        ▼
              +------------------+          +--------------------+
              | Load Balancer /  | 2. 查詢  | Service Registry   |
              |     Router       |--------> |                    |
              +------------------+          +--------------------+
                 |       |       |
      3. 路由到  |       |       |  (Load Balancing 邏輯在 LB 端)
      目標實例   |       |       |
                ▼       ▼       ▼
            +------+ +------+ +------+
            | Inst | | Inst | | Inst |
            |  A   | |  B   | |  C   |
            +------+ +------+ +------+

重點:Client 不知道 Instance 位址,由中間層負責路由,多一層 Network Hop,但 Client 邏輯簡單

用戶端發現的模式比較像是你自己查通訊錄。在這種模式下,每個想要呼叫其他服務的用戶端(也就是發起請求的那一方)會自己去查詢 Service Registry,拿到目標服務所有可用實例的位址清單,然後用戶端自己決定要呼叫清單中的哪一個實例。

聽起來好像很簡單?但在實務上,用戶端需要處理的事情遠比「查 Registry 然後挑一個」複雜得多。首先是負載平衡策略的選擇。最簡單的是 Round Robin(輪流),但這完全不考慮每個實例的實際負載狀況。好一點的做法是 Least Connections(最少連線數),把請求送給目前手上工作最少的實例;或是 Latency-Based(基於延遲),利用類似 EWMA(指數加權移動平均)的方法追蹤每個實例的回應時間,優先把請求送給回應最快的實例;又或者是 Weighted Routing(加權路由),根據每個實例的硬體規格或其他因素分配不同的權重。

常見的 Load Balancing 策略:

1. Round Robin (輪流)
   請求順序:A → B → C → A → B → C → ...
   優點:最簡單    缺點:不考慮實際負載

2. Least Connections (最少連線數)
   Instance A: 5 個連線中
   Instance B: 2 個連線中  ← 下一個請求送這裡
   Instance C: 8 個連線中
   優點:考慮負載  缺點:需追蹤連線狀態

3. Latency-Based / EWMA (基於延遲)
   Instance A: 平均回應 15ms
   Instance B: 平均回應  5ms ← 優先選擇
   Instance C: 平均回應 30ms
   優點:最佳體驗  缺點:實作複雜度高

4. Weighted Routing (加權路由)
   Instance A (8 CPU):  權重 40%
   Instance B (4 CPU):  權重 20%
   Instance C (8 CPU):  權重 40%
   優點:適應異質環境  缺點:權重需手動設定

除了負載平衡以外,用戶端還需要處理 Retry(重試)邏輯:當請求失敗時,要不要自動重試?重試幾次?要不要換一個實例重試?還有 Timeout(逾時)設定:等多久算是對方沒有回應?以及 Circuit Breaker(斷路器)模式:當某個實例連續失敗太多次時,自動暫時停止對它發送請求,避免持續浪費資源在一個明顯有問題的實例上。

這些邏輯全部都要內建在用戶端的 SDK 裡面,這讓用戶端的複雜度大幅提升。如果你的系統裡有用 Java、Go、Python、Node.js 等不同語言寫的服務,每種語言都得實作一套這樣的 SDK,而且還要確保它們的行為一致。這是用戶端發現模式最大的痛點。

伺服器端發現的模式則比較像是你打電話給公司總機,告訴總機你要找會計部,然後總機幫你轉接過去。在這種模式下,用戶端不需要知道服務的具體位址,它只要把請求送到一個中間的負載平衡器或路由器 (Router),再由這個中間層去查詢 Service Registry,找到目標服務的可用實例,然後把請求轉發過去。用戶端只需要知道這個中間層的位址就好,其他的事情都不用操心。這種模式的好處是用戶端非常單純,不需要內建任何發現邏輯。缺點是多了一個中間層,增加了一次跳轉,而且這個中間層本身也可能成為系統的瓶頸或單點故障 (Single Point of Failure),所以中間層自己也得做到高可用 (High Availability)。

不論採用那一種模式,負載平衡和服務發現是強耦合的。你選擇了什麼樣的服務發現模式,就在很大程度上決定了負載平衡的邏輯要放在哪裡、由誰來執行。這兩者必須被放在一起考慮。

etcd:Kubernetes 背後的功臣

如果你有接觸過容器化 (Containerization) 和容器編排 (Container Orchestration) 的技術,你一定聽過 Kubernetes(k8s)。Kubernetes 是目前業界最主流的容器編排平台,而在 Kubernetes 的核心裡,負責儲存所有叢集狀態和設定資料的元件,就是 etcd。

etcd 是一個分散式的鍵值儲存系統 (Distributed Key-Value Store)。你可以把它想像成一個功能非常強大、而且高度可靠的字典。你給它一個 key(鍵),它就回傳對應的 value(值)。例如,你可以存入 key 是 "/services/payment/instance1",value 是 "192.168.1.100:8080",這樣其他服務就能透過查詢這個 key 來找到付款服務的第一個實例的位址。

etcd 最重要的特點,也是它與一般的鍵值儲存系統最大的不同之處,就在於它使用了 Raft 共識演算法來保證資料的一致性和高可用性。如果你還記得「分散式系統的開會藝術 — 淺談共識演算法」那篇文章裡介紹的 Raft 演算法,你就能很容易地理解 etcd 的運作方式。讓我們用一張圖來回顧 Raft 在 etcd 裡的運作:

                          etcd 叢集 (Raft 共識)

                         +----------------+
         1. 寫入請求 --> |    Leader      |
                         +----------------+
                          /       |       \
            2. 複製日誌  /        |        \  2. 複製日誌
                        ▼         ▼         ▼
                 +---------+ +---------+ +---------+
                 |Follower | |Follower | |Follower |
                 |   (1)   | |   (2)   | |   (3)   |
                 +---------+ +---------+ +---------+
                     |            |
                     ▼            ▼
              3. 過半數回覆 ACK (Leader + Follower 1 + Follower 2 = 3/5)
| ▼ 4. Leader 確認 Commit,回覆 Client 寫入成功 重點:寫入只能透過 Leader,超過半數 (Quorum) 確認即算成功,5 台掛 2 台仍可運作 (3 > 5/2)

etcd 叢集通常由奇數台機器組成(例如三台或五台),其中一台會被選為 Leader(領導者),所有的寫入操作都必須經過 Leader。當 Leader 收到一個寫入請求時,它會先把這個操作寫入自己的日誌,然後將日誌複製給其他的 Follower(跟隨者)。只要超過半數的節點確認收到了這筆日誌,這個寫入操作就被視為成功。

這種機制讓 etcd 能夠容忍一定數量的節點故障。一個五台機器的 etcd 叢集,即使有兩台機器同時掛掉,剩下的三台仍然能正常運作,因為三台已經超過了五台的一半。這正是我們之前文章裡提到的 Quorum(多數決)的概念。所以你可以看到,我們之前介紹的共識演算法並不是紙上談兵的理論,它是真真切切被使用在每天支撐著全世界無數服務運行的基礎設施裡。

除了基本的讀寫操作以外,etcd 還提供了一個非常實用的功能叫做 Watch。你可以對某個 key 或某個 key 的前綴路徑設定 Watch,當這個 key 的值發生變化時(例如有新的服務實例註冊進來,或者某個實例被移除了),etcd 會主動通知你。這個功能對於服務發現來說非常重要,因為用戶端不需要每隔幾秒就去輪詢 (Polling) 一次來檢查有沒有變化,它只要設定好 Watch,然後等著接收通知就好。這大幅減少了不必要的網路流量,也讓服務發現的反應速度更快。值得一提的是,etcd 的 Watch 機制底層是基於 MVCC(Multi-Version Concurrency Control,多版本並行控制)的 revision 來追蹤變更的,而不是單純的事件通知,這使得即使用戶端暫時斷線重連,也能從上次的 revision 繼續接收後續的變更,不會遺漏任何更新。

在 Kubernetes 裡,etcd 扮演的角色不僅僅是服務發現。所有關於叢集的資訊都存放在 etcd 裡面,包括有哪些節點、每個節點上運行了什麼容器、目前系統的期望狀態是什麼等等。你可以說 etcd 就是整個 Kubernetes 叢集的大腦和記憶體。沒有 etcd,Kubernetes 就什麼都不知道了。

Consul:功能全面的服務發現方案

如果說 etcd 是一把精緻的瑞士刀,Consul 就比較像是一個完整的工具箱。Consul 是由 HashiCorp 公司開發的,它不僅僅是一個分散式鍵值儲存,它從一開始就是專門為了服務發現和服務管理而設計的。

Consul 在內部也使用了 Raft 共識演算法來保證 Server 節點之間的資料一致性。你可以看到,Raft 演算法在現代分散式系統中的應用有多麼廣泛。Consul 的架構分成 Server 和 Client(或稱為 Agent)兩種角色:

                      Consul 架構

         +---------------------------------------+
         |        Server Cluster (Raft)          |
         |  +--------+ +--------+ +--------+     |
         |  |Server 1| |Server 2| |Server 3|     |
         |  |(Leader)| |(Follow)| |(Follow)|     |
         |  +--------+ +--------+ +--------+     |
         +---------------------------------------+
                ▲            ▲            ▲
                |            |            |
         +------+-----+-----+-----+------+-------+
         |            |            |             |
    +---------+  +---------+  +---------+  +---------+
    | Agent   |  | Agent   |  | Agent   |  | Agent   |
    | (Node1) |  | (Node2) |  | (Node3) |  | (Node4) |
    +---------+  +---------+  +---------+  +---------+
    | Service |  | Service |  | Service |  | Service |
    |    A    |  |    B    |  |    A    |  |    C    |
    +---------+  +---------+  +---------+  +---------+
         ↕            ↕            ↕            ↕
    Health Check  Health Check Health Check Health Check

    Agent 負責:1. 將本機服務註冊到 Server
                2. 執行本機服務的 Health Check
                3. 回報健康狀態給 Server

Server 節點負責維護叢集的狀態,使用 Raft 來達成共識;Client 節點(Agent)部署在每一台運行服務的機器上,負責將本機上的服務資訊註冊到 Server,同時也負責執行 Health Check。

除了服務發現以外,Consul 還內建了 Key-Value Store、多資料中心 (Multi-Datacenter) 支援,以及一個叫做 Consul Connect 的功能可以做服務間的加密通訊和存取控制 (Service Mesh)。如果你需要一個開箱即用、功能齊全的服務發現方案,Consul 會是一個不錯的選擇。

不過,也必須誠實地說,隨著 Kubernetes 生態系的不斷壯大,Consul 在雲原生環境中的使用比例正在下降。在 Kubernetes 的世界裡,k8s 本身已經透過內建的 Service 和 Endpoints 機制提供了基本的服務發現功能,而更進階的需求則可以透過 Service Mesh(例如 Istio 搭配 Envoy)來解決。對於已經全面擁抱 Kubernetes 的團隊來說,額外引入 Consul 可能反而增加了系統的複雜度。但在非 Kubernetes 的環境裡,或者需要跨多個資料中心的混合架構中,Consul 仍然有它獨特的價值。Consul 還提供了 DNS 介面和 HTTP API 兩種查詢方式。DNS 介面讓你可以直接用 DNS 查詢的方式來發現服務,對於一些較老舊系統的整合非常方便。而 HTTP API 則提供了更豐富的查詢功能和更多的服務詳細資訊。

Health Check:通訊錄上的號碼還能打得通嗎?

在前面談服務發現的三個核心元素時,我們提到了 Health Model 的重要性。現在讓我們更深入地來聊聊 Health Check 這件事,因為它是整個服務發現機制中最容易被低估、但也最容易出問題的環節。

想像一下,你翻開公司通訊錄,找到了會計部小陳的分機號碼,撥了過去,結果一直沒人接。可能小陳今天請假了,可能小陳被調到其他部門了,也可能小陳的電話壞了。不管是什麼原因,如果通訊錄不能即時反映出「這個號碼目前是不是能打得通」的狀態,那它的實用性就大打折扣了。

在分散式系統裡也是一樣的道理。一個服務實例即使已經向 Service Registry 報到了,也不代表它隨時都是健康的。它可能因為記憶體不足而變得極度緩慢,可能因為它依賴的資料庫掛了而無法提供服務,也可能因為程式的 Bug 而進入了一個不正常的狀態。這些情況下,服務實例的程序本身可能還在運行中(所以它不會從 Registry 上消失),但它實際上已經無法正常工作了。

Health Check 就是用來解決這個問題的。它的概念很簡單:定期地去檢查每個服務實例是否還「健康」。如果一個實例被判定為不健康,服務發現機制就會把它從可用的清單中移除,這樣其他的服務就不會再把請求發送給它,直到它恢復健康為止。讓我們用一張圖來看這個流程:

                Health Check 流程與結果

  Health Checker                     Service Instances
  (定期檢查)
       |
       |--- HTTP GET /health --->  Instance A  ---> 200 OK      ✓ healthy
       |
       |--- HTTP GET /health --->  Instance B  ---> (timeout)   ✗ unhealthy
       |
       |--- HTTP GET /health --->  Instance C  ---> 200 OK      ✓ healthy
       |
       ▼
  更新 Service Registry:
  +------------------+----------+
  | Instance         | Status   |
  |------------------|----------|
  | A (10.0.0.1)     | 可用     |
  | B (10.0.0.2)     | 已移除   |  ← 不再接收新請求
  | C (10.0.0.3)     | 可用     |
  +------------------+----------+

  其他服務查詢時,只會拿到 Instance A 和 C 的位址

常見的 Health Check 方式有幾種。第一種是 HTTP Health Check,也是最常見的方式。每個服務實例會提供一個特定的 HTTP 端點(通常是類似 /health 或 /healthz 這樣的路徑),Health Check 機制會定期對這個端點發送 HTTP GET 請求。如果回傳的狀態碼是 200(代表一切正常),就認為這個實例是健康的;如果回傳其他的錯誤碼,或者在一定時間內沒有回應,就認為它不健康。這種方式的好處是服務可以在這個端點的處理邏輯裡做一些自訂的檢查,例如檢查資料庫連線是否正常、檢查磁碟空間是否足夠等等,而不只是簡單地回傳「我還活著」。第二種是 TCP Health Check。這種方式更為單純,它只是嘗試與服務實例建立一個 TCP 連線。如果連線成功,就認為實例是健康的;如果連線失敗,就認為它不健康。這種方式適用於那些不是 HTTP 服務的情況,例如一個自定義協定的 TCP 伺服器或資料庫。第三種是 Script/Command Health Check。這種方式允許你定義一個自訂的腳本或指令,Health Check 機制會定期執行這個腳本。如果腳本的結束碼 (Exit Code) 是 0,就代表健康;非 0 就代表不健康。這種方式的彈性最大,你可以在腳本裡做任何你想做的檢查邏輯。

前面提到的 Consul 在 Health Check 方面做得特別出色,它原生就支援了上述所有的 Health Check 方式,而且這些 Health Check 是由部署在每台機器上的 Consul Agent 來執行的。這意味著 Health Check 的流量是分散的,不會集中在某一台機器上造成瓶頸。當 Agent 偵測到本機上的某個服務實例不健康時,它會立即通知 Consul Server,Server 會更新 Service Registry,其他查詢這個服務的用戶端就能立刻知道要避開這個不健康的實例。

Health Check 不是萬靈丹

雖然 Health Check 非常重要,但我必須提醒你一件事情:Health Check 通過,不代表服務真的「沒問題」。這個觀念非常關鍵,在實務中卻經常被忽略。

         Health Check 通過 ≠ 服務真的沒問題

  +-----------------------+-----------------------------------+
  | Health Check 能判斷   | Health Check 無法判斷              |
  |-----------------------|-----------------------------------|
  | 程序是否還在運行       | 回應延遲是否在可接受範圍 (效能)      |
  | 網路 Port 是否還在監聽 | 回傳的資料是否正確 (正確性)          |
  | 基本的依賴是否連得上   | 是否能在合理時間內完成工作 (可用性)   |
  +-----------------------+-----------------------------------+

  實際案例:
  +-----------------------------------------------------------------+
  | 訂單服務的 /health 端點回傳 200 OK                                |
  | 但底層 DB 查詢延遲從 5ms 暴增到 3000ms                            |
  | Health Check 判定:✓ healthy                                    |
  | 實際狀況:所有請求都在等待 DB 回應,使用者體驗極差                  |
  | 結果:請求堆積 → 記憶體耗盡 → 系統崩潰                             |
  +-----------------------------------------------------------------+

Health Check 不等於可用性 (Availability)。一個服務的 Health Check 端點可能每次都回傳 200 OK,但如果它處理每個請求都要花三十秒,對用戶端來說這和掛掉幾乎沒有差別。Health Check 只是告訴你「我還活著」,但「活著」和「能在合理的時間內正確地完成工作」是兩回事。

Health Check 不等於正確性 (Correctness)。一個服務可能通過了所有的健康檢查,但它回傳的資料卻是錯誤的。也許它讀到的是舊的快取資料,也許它的商業邏輯有 Bug。Health Check 不會檢查這些事情。

Health Check 不等於效能 (Performance)。讓我舉一個很實際的例子。假設你的訂單服務依賴一個資料庫,而這個資料庫因為某些原因,查詢延遲從平常的 5 毫秒暴增到了 3 秒。你的 Health Check 端點在檢查資料庫連線時,只是確認「能不能連上」,而不管連線後的回應速度。所以 Health Check 仍然回傳 200 OK,你的服務在 Service Registry 裡仍然被標記為健康。但實際上,所有打到這個服務的請求都會變得極度緩慢,用戶端會因為等待而超時,最終整個系統可能因為請求堆積而崩潰。

所以,請記住:Health Check 只是一種「最低限度的判斷」。它能幫你過濾掉那些明確已經掛掉的實例,但它無法保證通過檢查的實例就一定能正常為你服務。

不是所有的服務發現都是強一致的

在前面介紹 etcd 和 Consul 時,我們提到它們都使用了共識演算法來保證資料的一致性。這意味著當你向這些系統查詢一個服務的位址時,你能得到強一致 (Strong Consistency) 的結果:只要資料被成功寫入了,所有後續的讀取都能看到最新的值。

在實務中,並不是所有的服務發現機制都提供這種強一致性的保證。最明顯的例子就是基於 DNS 的服務發現。DNS 天生就是一個最終一致 (Eventual Consistency) 的系統。當你更新了一筆 DNS 記錄,這個更新需要一段時間才能傳播到全球所有的 DNS 伺服器。在這段傳播的時間窗口裡,不同地方的用戶端查到的結果可能不一樣,有的拿到了新的位址,有的還在看舊的位址。DNS 的 TTL(Time To Live)設定決定了這個不一致的窗口有多長。

更需要注意的是,即使你用的是像 etcd 這樣的強一致系統,用戶端這一側的快取 (Cache) 也可能導致資料過期的問題。很多用戶端 SDK 為了提升效能,會把從 Registry 查到的結果快取起來,不會每次都去 Registry 做即時查詢。這意味著即使 Registry 裡的某個實例已經被移除了,用戶端可能因為快取還沒過期而繼續把請求送給那個已經不存在的實例。

理解這一點很重要,因為它會影響你對系統行為的預期。在一個使用 DNS 做服務發現的架構裡,當你下線一台機器時,你不能期望其他服務「立刻」就不再向它發送請求。你可能需要等到 DNS TTL 過期之後,流量才會完全切換。這也是為什麼在做服務下線 (Graceful Shutdown) 時,通常需要先將服務從 Registry 移除,然後等一段時間(讓快取過期、讓正在處理的請求完成),最後才真正關閉服務程序。

現代趨勢:從 Client-Side Discovery 到 Service Mesh

在前面談用戶端發現模式時,我們提到它的一大痛點是用戶端的 SDK 需要內建大量的邏輯:服務發現、負載平衡、Retry、Timeout、Circuit Breaker 等等。而且如果系統裡有多種程式語言,每種語言都得實作一套。這個問題在微服務架構越來越普遍之後變得更加嚴重。

為了解決這個問題,近年來出現了一個重要的架構趨勢:Service Mesh(服務網格)。Service Mesh 的核心思想是把所有與網路通訊相關的邏輯(服務發現、負載平衡、Retry、加密、觀測等等)從應用程式碼中抽離出來,放到一個獨立的基礎設施層。

     演進趨勢:從 Client-Side Discovery 到 Service Mesh

     === 傳統 Client-Side Discovery ===

     +--------------------------------------------+
     | Application Code                           |
     |  +---------------------------------------+ |
     |  | 商業邏輯                               | |
     |  +---------------------------------------+ |
     |  | SDK: Discovery + LB + Retry + Timeout | |  ← 全部塞在應用程式裡
     |  |      + Circuit Breaker + ...          | |
     |  +---------------------------------------+ |
     +--------------------------------------------+

     每個語言都要實作一套 SDK,維護成本高

     === Service Mesh (Sidecar 模式) ===

     +-------------------------+    +-------------------------+
     | Service A               |    | Service B               |
     |  +-------------------+  |    |  +-------------------+  |
     |  | 商業邏輯 (純粹)    |  |    |  | 商業邏輯 (純粹)    |  |
     |  +-------------------+  |    |  +-------------------+  |
     |  | Sidecar Proxy     |  |    |  | Sidecar Proxy     |  |
     |  | (Envoy)           |◄-+----+-►| (Envoy)           |  |
     |  | Discovery / LB /  |  |    |  | Discovery / LB /  |  |
     |  | Retry / mTLS /    |  |    |  | Retry / mTLS /    |  |
     |  | Observability     |  |    |  | Observability     |  |
     |  +-------------------+  |    |  +-------------------+  |
     +-------------------------+    +-------------------------+

     應用只管商業邏輯,網路通訊的一切交給 Sidecar Proxy
     語言無關,所有服務共用同一套基礎設施

具體的做法是,在每個服務實例旁邊部署一個叫做 Sidecar Proxy 的輕量級代理程式(最常見的就是 Envoy)。所有從這個服務發出的請求,以及所有進到這個服務的請求,都會經過這個 Sidecar Proxy。Proxy 負責處理所有的服務發現、負載平衡、Retry、Circuit Breaker 等邏輯,而應用程式本身只需要把請求發到 localhost(本機)就好,完全不需要知道目標服務在哪裡、有幾個實例、要怎麼做負載平衡。

在 Kubernetes 的生態系裡,Istio 搭配 Envoy 是目前最常見的 Service Mesh 方案。這種架構把服務發現的複雜度從應用程式(Application Layer)往下推到了基礎設施層(Infrastructure Layer),讓開發者可以專注在商業邏輯上,而不用擔心網路通訊的各種細節。這是一個從 Client-Side Discovery 走向 Infrastructure Abstraction(基礎設施抽象化)的演進過程。

限制與現實

在結束這篇文章之前,我想做一個重要的提醒。服務發現是分散式系統中不可或缺的一塊基礎建設,但它不是銀彈 (Silver Bullet),它不能解決所有的問題。

服務發現不能保證完全正確。Registry 的資料可能有短暫的過期、Health Check 可能無法偵測到所有類型的故障、用戶端的快取可能導致請求被送到已經失效的實例。服務發現不能保證即時一致。不論是 DNS 的 TTL、用戶端的快取、還是 Health Check 的間隔,都會造成系統在某些時間點上的狀態不一致。服務發現也不能保證無錯誤路由。

服務發現真正做到的事情是:在一個動態的、不斷變化的分散式環境裡,大幅降低了「找到對的服務」這件事的複雜度。它把原本需要人工維護的靜態設定,變成了自動化的動態機制;它讓系統能夠在一定程度上自動適應節點的上線和下線。但最終,你仍然需要在應用層做好防護:Retry、Timeout、Circuit Breaker、Graceful Degradation(優雅降級)。這些機制和服務發現一起配合,才能構成一個真正健壯的分散式系統。

希望這篇文章能幫助你理解服務發現的基本概念、常用工具、實務風險,以及它和我們之前談過的共識演算法之間的關聯。

Share:

0 意見:

張貼留言