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:

#106 分散式系統的「搬家」難題 — 淺談資料分片 (Sharding)

想像一下,你開了一間很受歡迎的早餐店。一開始,只有一個廚師、一個收銀機、一本手寫的訂單記錄本,生意好好的,什麼都很順。隨著口碑越來越好,客人越來越多,有一天你發現那本訂單記錄本已經快寫滿了,翻找某一天的訂單也越來越費時,光是整理那本子就開始讓廚師頭痛。這個時候,「找一本更大的本子」也許是個辦法,但如果生意繼續成長,遲早還是會面臨一樣的問題。更根本的解法,是把訂單記錄分散到多本本子上管理。

這個比喻,幾乎完美地描述了大型系統在面對「資料量暴增」時所遭遇的難題。

在資料庫的世界裡,當一個資料庫表格的資料量成長到幾千萬甚至幾億筆,光是一台資料庫伺服器可能就撐不住了。磁碟空間不夠、記憶體放不下索引、查詢速度越來越慢,這些問題都會接踵而來。通常工程師會先嘗試「垂直擴展(vertical scaling)」,也就是換一台更強的機器,加更多的 RAM、更快的 SSD、更多的 CPU。這個方法簡單直接,而且通常有效。但硬體的規格是有上限的,而且價格往往不是線性成長,越頂規的機器,通常貴得不成比例。

當垂直擴展已經到了極限,或者成本實在太高的時候,工程師就必須考慮「水平擴展(horizontal scaling)」,也就是把資料分散到多台機器上儲存和處理。這種技術,就叫做資料分片(Sharding)(我不確定這翻譯是否適當,所以列出英文)。

Sharding 這個字的字面意思是「碎片」,在資料庫的脈絡下,我們把一張大表格切成多個小塊,每一塊就是一個 shard,每個 shard 存放在不同的機器上。概念聽起來很美好,但實作起來,「要怎麼切」這個問題,卻大有學問。

最直覺的方式:Range Sharding(範圍分片)

Range Sharding,顧名思義,就是依照某個欄位(通常是主鍵或日期)的「範圍」來切割資料。舉個例子,假設你有一個電商平台,裡面有一張「訂單」表格,主鍵是訂單編號。你可以這樣切:

  • 節點 A:存放訂單編號 1 到 1,000,000
  • 節點 B:存放訂單編號 1,000,001 到 2,000,000
  • 節點 C:存放訂單編號 2,000,001 到 3,000,000
  • 以此類推……

這種方式最大的優點是直覺易懂,而且對於「範圍查詢(range query)」非常友善。比如說你想查「2024 年 1 月份的所有訂單」,如果是用日期當分片鍵,系統馬上就知道要去哪個節點找,不需要把所有節點都掃一遍。這在資料分析、報表系統裡非常實用。

但 Range Sharding 有一個非常致命的缺點:熱點問題(hot spot)

以電商訂單為例,剛剛建立的新訂單,編號一定是最大的,所以永遠都被分配到最後一個節點。這台機器要承受所有的新寫入流量,其他機器反而閒閒沒事。這種情況下,分片非但沒有解決問題,反而製造了一個「超載的節點」,讓整個系統的瓶頸更加明顯。就像你開了四間倉庫,結果所有貨都只堆到第四間,前三間都空著,這做法只分散了資料,但沒有分散工作負擔,這並不是真正意義上的分散。

如果用時間當分片鍵,這個現象更嚴重。因為永遠是「最近的時間分片」在承擔所有的讀寫流量,舊的分片反而幾乎沒有人去動。

所以 Range Sharding 適合什麼場合呢?適合那些讀取模式是以範圍掃描為主、並且寫入流量相對均勻分佈的情境。例如地理位置(依照郵遞區號分片)、或者確定每段範圍的資料量大致平均的場合。如果你的情境不符合這些條件,就要考慮下一種方法了。

用雜湊打散資料:雜湊分片(Hash Sharding)

Hash Sharding 的出發點,就是要解決 Range Sharding 的熱點問題。它的做法很簡單:對分片鍵做一個雜湊運算(hash function),然後用結果除以節點數量取餘數,來決定這筆資料要放到哪個節點。

公式大概是這樣:

node = hash(key) mod N

其中 N 是節點的總數量。舉個例子,假設有 4 個節點(編號 0 到 3),現在要決定訂單 ID 為 12345 的資料要放哪裡:

hash(12345) = 987654321  (某個雜湊值)
987654321 mod 4 = 1
→ 放到節點 1

雜湊函數的特性是「輸入稍微不同,輸出就會差很多」,所以就算訂單編號是連續的,經過雜湊之後會散落到各個節點,不會集中在同一台機器上。這樣就有效地解決了 Range Sharding 的熱點問題。

Hash Sharding 的優點是資料分佈均勻,對於以主鍵查詢(point query)為主的情境非常有效。你知道一個使用者 ID 或一個訂單 ID,馬上就能算出它在哪台機器,查詢速度很快。

不過,天下沒有白吃的午餐。Hash Sharding 的缺點有兩個:

第一,範圍查詢效率極差。因為資料被打散了,如果你想查「所有 2024 年 1 月的訂單」,系統沒有辦法只去一台機器找,必須把所有節點都查一遍,再把結果合併起來。這種操作叫做 scatter-gather,在節點數量多的時候,效能開銷非常大。

第二,也是更棘手的問題:節點增減的時候,資料幾乎全部要搬移。這就是本文標題所說的「搬家難題」。

假設原本有 4 台節點,某天流量大增,你決定加一台,變成 5 台。那麼公式就從 mod 4 變成了 mod 5。原本 hash(key) mod 4 = 1 的資料,現在 hash(key) mod 5 可能是 2 或 3。幾乎所有資料的「預期位置」都改變了,這意味著你要把幾乎全部的資料都從原來的節點搬到新的節點。對於一個存有幾十 TB 資料的系統來說,這是一個非常痛苦、耗時,又充滿風險的過程。

更糟的是,資料搬移本身會消耗大量的網路頻寬和磁碟 I/O。在「搬家」進行的這段時間裡,這些資源都在忙著搬資料,真正來自使用者的查詢請求反而要排隊等待,造成服務延遲明顯上升。如果搬移速度太慢、持續太久,整個叢集甚至可能觸發連鎖反應,讓系統陷入更大的麻煩。這種在擴充期間特別容易發生的故障連鎖,正是分散式系統工程師最害怕遇到的「雪崩效應(cascading failure)」。

那有沒有一種方法,能夠兼顧「資料分佈均勻」又能讓「節點增減時只需要搬移少量資料」呢?

一致性雜湊:讓搬家的痛苦降到最低

一致性雜湊(Consistent Hashing) 就是為了解決這個問題而生的。這個方法的設計非常巧妙,但只要跟著例子一步一步走,你會發現它其實並不難懂。

從一個生活比喻開始:接力賽的交接區

在正式介紹技術之前,先想像一個情境。

你的班級要舉辦一個「管理公告欄」的輪值任務。班上目前有三個同學負責:小明、小華、小美,他們三個人圍成一個圓圈站好(想像成一個時鐘的圓盤)。現在老師手上有一疊佈告,每張佈告上面有一個號碼(例如 42 號、17 號、88 號……),規則是:號碼落在哪個區間,就交給站在那個區間終點的同學管理

一開始,三個人把圓圈分成三段:

0 ~ 33 號  → 小明
34 ~ 66 號 → 小華
67 ~ 99 號 → 小美

某天,班上轉來一個新同學小強,老師安排他在小華和小美之間。現在圓圈變成四段,小強負責原本屬於小美的前半段(67 ~ 82 號)。所以只有那些 67 ~ 82 號的佈告,需要從小美那裡轉交給小強。小明和小華管的佈告完全不動,小美也只需要把一部分交出去,整個「搬家」的動作非常小。

這個圓圈輪值的概念,就是一致性雜湊的核心精神。接下來我們用真正的技術語言來描述它。

把節點和資料都放到同一個「環」上

一致性雜湊的核心工具叫做「雜湊環(hash ring)」。

想像一個時鐘的圓形錶盤,但刻度不是 1 到 12,而是從 0 到 2^32 - 1(也就是 4,294,967,295)。然後把這個數字的頭尾相接,形成一個完整的環,這就是一致性雜湊的雜湊空間 [0, 2^32 - 1]。

第一步:把「節點」放上環。

對每一台伺服器節點的名稱做雜湊運算,得到一個數字,然後把這個節點「釘」在環上對應的位置。假設我們有三個節點 A、B、C,雜湊之後分別落在環上 12 點、4 點、8 點的位置(這只是示意,實際上是 0 到 2^32 之間的某個數字)。

         A(12點)
        /         \
  C(8點)       B(4點)

第二步:把「資料」放上環,並決定它的歸屬。

當一筆資料要決定要存到哪台伺服器時,對它的分片鍵做雜湊,得到一個數字,也標在環上。

接著,要決定這筆資料歸哪個節點管,規則是這樣的:

從資料的位置出發,沿著數字增大的方向前進,第一個碰到的節點,就是這筆資料的負責人。如果走到環的末端都沒碰到節點,就繞回環的起點繼續找。

換個角度來理解,其實每個節點負責的是環上的一段弧,從「上一個節點的位置(不包含)」到「自己的位置(包含)」這一段。所有落在這段弧裡的資料,都由這個節點負責。這樣想,是不是更清楚?

讓我們直接用數字來驗證。假設環的刻度是 0 到 99,節點和資料的位置如下:

節點 A 的雜湊值 = 10
節點 B 的雜湊值 = 40
節點 C 的雜湊值 = 75

資料 D1 的雜湊值 = 5
資料 D2 的雜湊值 = 15
資料 D3 的雜湊值 = 50
資料 D4 的雜湊值 = 60
資料 D5 的雜湊值 = 80

套用「每個節點負責前一個節點到自己這一段弧」的規則,先算出各節點的負責範圍:

節點 A(位置 10)→ 負責 76 ~ 10  (從 C 之後繞回來,包含 76、77、...、99、0、1、...、10)
節點 B(位置 40)→ 負責 11 ~ 40
節點 C(位置 75)→ 負責 41 ~ 75

再來看每筆資料落在哪個範圍:

  • D1(位置 5)→ 落在 76~10 這段弧 → 歸 A 管
  • D2(位置 15)→ 落在 11~40 這段弧 → 歸 B 管
  • D3(位置 50)→ 落在 41~75 這段弧 → 歸 C 管
  • D4(位置 60)→ 落在 41~75 這段弧 → 歸 C 管
  • D5(位置 80)→ 落在 76~10 這段弧(繞回起點那段)→ 歸 A 管

新增節點的時候,只需要搬一小部分

現在重點來了。假設系統需要擴充,我們加入一台新節點 X,它的雜湊值是 55,落在 B(40)和 C(75)之間。

(加入前)環上順序:... A(10) ... B(40) ... C(75) ...
(加入後)環上順序:... A(10) ... B(40) ... X(55) ... C(75) ...

現在再重新套用「從資料位置出發,找到的第一個節點」的規則,看看哪些資料的歸屬改變了:

  • D1(位置 5)→ 遇到 A(10)→ 歸 A 管。不變。
  • D2(位置 15)→ 遇到 B(40)→ 歸 B 管。不變。
  • D3(位置 50)→ 原本會遇到 C(75),但現在 X(55)插進來了,先遇到 X → 改歸 X 管!
  • D4(位置 60)→ 原本會遇到 C(75),但現在先遇到 X(55)→ 改歸 X 管!
  • D5(位置 80)→ 繞回去,遇到 A(10)→ 歸 A 管。不變。

結果只有 D3 和 D4 需要從 C 搬到 X,其他三筆資料完全待在原地不動!

相比之下,如果我們用普通的 Hash Sharding,節點從 3 台變成 4 台,幾乎每一筆資料的 hash(key) mod 3 和 hash(key) mod 4 的結果都不同,造成大量資料需要搬家。而 Consistent Hashing 的情況是:加一台新節點,只有新節點「身後那一段弧」的資料需要搬過來,大約是全部資料的 1/N(N 是節點總數)。節點越多,每次擴充需要搬的比例就越小。這就是它名字裡「一致性」的由來——節點增減時,大多數資料的歸屬「保持一致,不受影響」。

移除節點的邏輯完全對稱:如果 X 要下線,只有它負責的 D3、D4 需要轉移給 C,其他節點的資料完全不動。

虛擬節點:解決分佈不均的問題

Consistent Hashing 聽到這裡很美好,但有一個實務上很現實的問題:節點在環上分佈不均勻

假設一個特別情境,A、B、C 三個節點雜湊之後剛好都擠在環的同一側,例如全部落在 0 到 30 之間,那麼 30 到 99 這一大段弧幾乎都是 A(因為從 30 之後順時針繞一圈才會回到 A)。結果 A 要負責幾乎全部的資料,B 和 C 反而很閒。這樣分了等於沒分,甚至更糟。

解決方法是引入「虛擬節點(virtual node)」。

做法是這樣的:每一台實體伺服器,不是只在環上放一個點,而是放很多個點。例如,每台機器放 150 個虛擬節點,做法是對「節點名稱 + 編號」做雜湊,產生 150 個不同的位置:

節點 A 的虛擬節點:hash("A-1") = 5,  hash("A-2") = 38, hash("A-3") = 71, ...(共 150 個)
節點 B 的虛擬節點:hash("B-1") = 12, hash("B-2") = 49, hash("B-3") = 83, ...(共 150 個)
節點 C 的虛擬節點:hash("C-1") = 21, hash("C-2") = 55, hash("C-3") = 90, ...(共 150 個)

這 450 個虛擬節點散落在環上,由於數量夠多,統計上就會趨近於均勻分佈。而每個虛擬節點負責的實際資料,最終還是由它背後的那台真實機器來儲存。

虛擬節點還帶來另外兩個在實務上非常重要的好處。

第一個是應對異質性(heterogeneity)。現實中的機器叢集往往不是整齊劃一的:有些是舊機器(CPU 慢、記憶體小),有些是新機器(效能強)。如果強迫每台機器負責相同份量的資料,舊機器可能早就喘不過氣,新機器卻還很悠閒。有了虛擬節點,就可以彈性調整:效能強的機器配 200 個虛擬節點,效能弱的只配 50 個,讓每台機器承擔與自己能力相稱的負載。這種彈性,在需要混用新舊機器的維運環境中,是非常實用的特性。Amazon Dynamo 的原始論文中就明確指出,虛擬節點的設計正是為了處理這種「節點效能不一致」的問題。

第二個是故障時的負載分散。當一台機器突然故障下線時,它的多個虛擬節點會分別把資料轉移給環上各自的下一個節點,等於把負擔分散給了多台機器,而不是全部壓給同一台。如果沒有虛擬節點,一台機器掛掉,它的所有資料就全部湧向單一的下一台,那台機器的負載瞬間暴增,說不定也跟著掛掉,接著又觸發下一台……這正是前面提到的雪崩效應。

一致性雜湊搭配虛擬節點的組合,最早在 2007 年被 Amazon 發表的一篇名為「Dynamo: Amazon's Highly Available Key-value Store」的論文中系統性地提出,並完整描述了它在大規模生產系統中的應用。這篇論文後來被公認為分散式資料庫領域最具影響力的論文之一,直接啟發了 Apache Cassandra(Facebook 開發)等一代 NoSQL 資料庫的設計。現今 AWS 雲端服務上的 DynamoDB,雖然底層實作已經遠比當年複雜,但其核心概念——透過 Partition Key 進行雜湊分片、並在後台自動處理資料搬遷——仍然延續自 Dynamo 的設計哲學。

三種策略的比較

講了這麼多,讓我們來整理一下這三種方法的優缺點,方便日後查閱。

Range Sharding 的優點是範圍查詢效率高,資料的分佈邏輯直覺,容易調試和維運。缺點是容易產生熱點,資料分佈可能不均,增減節點時部分資料需要搬遷。適用情境是讀取以範圍掃描為主、寫入流量較均勻的場合,例如時間序列資料(log、監控指標)的歸檔系統。

Hash Sharding 的優點是資料分佈均勻,單鍵查詢速度快,有效避免熱點。缺點是範圍查詢需要掃全部節點,以及增減節點時幾乎所有資料都要搬移,代價極高。適用情境是查詢模式以 point query 為主、資料量穩定不常增刪節點的系統。

Consistent Hashing 的優點是增減節點時只搬少量資料,搭配虛擬節點後資料分佈均勻,非常適合動態擴縮容的分散式環境。缺點是範圍查詢同樣不友善,實作相對複雜,調試成本也比前兩者高一些。適用情境是需要頻繁動態擴縮的大型分散式系統,例如 Apache Cassandra 這類分散式 NoSQL 資料庫。

事實上,很多成熟的系統會混合使用這些策略,或是在這些策略上發展出變形。例如 MongoDB 同時支援 Range Sharding 和 Hash Sharding,工程師在建立 Sharded Cluster 時必須決定「分片鍵(Shard Key)」,一旦選定,分片策略就固定下來,無法任意更改,所以選對分片鍵是 MongoDB 維運中非常關鍵的決策。

Redis Cluster 則是一個有趣的案例。它官方文件明確寫道「Redis Cluster does not use consistent hashing」,而是採用了一套叫做雜湊槽(Hash Slots)的機制:將整個 key 空間切成固定的 16,384 個槽位,每個節點負責其中一部分槽位。擴充節點時,是把槽位從舊節點搬到新節點,而不是重新計算整個雜湊環。這個設計在邏輯上與 Consistent Hashing 非常相似,但因為槽位數量固定,對資料分佈的控制更精確,實作和維運工具也更易於管理。

Sharding 只是開始

Sharding 解決了「資料量太大放不下」的問題,但它同時也帶來了一系列新的複雜度。例如,跨 shard 的 JOIN 查詢怎麼做?跨 shard 的交易(transaction)如何保持一致性?如果一筆資料需要根據不同的查詢維度存放在不同的 shard(例如訂單既要依使用者 ID 查,又要依商品 ID 查),怎麼辦?

這些問題,每一個都是分散式系統裡的大哉問,每一個都有一整套的理論和工程實踐來對應。Sharding 是分散式資料儲存的入門門票,但要真正把一個大型分散式系統做好,還有非常漫長的路要走。

但至少現在,當你下次在面試被問到「系統資料量太大怎麼辦」的時候,你已經知道不只是「加一台更大的機器」這個答案了。你會知道怎麼「搬家」,也會知道怎麼讓搬家的過程痛苦少一點。

Share:

#105 出神入化的用介面 第五集:Interface 與 Unit Test — 讓你的程式可以被測試

在前幾集的內容裡,我們透過寄信程式的例子說明了 Interface 如何幫助不同的團隊在不互相 "認識" 的情況下一起工作,也說明了當 Interface 需要修改時,該如何用繼承的方式讓新舊版本都能正常運作。這一集,我們繼續用同一個寄信程式的故事,但要切入一個完全不同的角度 — Unit Test。


你的程式,有辦法被測試嗎?

先問你一個問題。假設你今天寫好了第一集裡那個 SendEmail() method,你怎麼確認它有正確地執行呢?也許你會說,我執行程式,看一下信箱,如果信收到了,就代表它是對的。這個方法當然可行,但你有沒有想過,這樣的驗證方式有幾個很明顯的問題。

問題一,每次你改了 SendEmail() 裡的任何一行程式碼,你就得再跑一次整個程式,然後去信箱確認,這很麻煩。

問題二,如果你的測試信件伺服器今天不穩定或是連不上,你的測試就失敗了,但這個失敗並不是你的程式有問題,而是外部環境的問題,這樣的結果讓人很困惑。

問題三,如果你的程式有五十個 method,難道每一個都要用手動的方式去驗證嗎?

相信你在業界工作久了,應該對 Unit Test 這個詞不陌生。Unit Test 的精神就是讓每一個 method 都可以用程式碼自動驗證,讓你不需要靠手動的方式來確認程式有沒有正確執行。而 Interface,就是讓 Unit Test 變得可行的關鍵工具之一。


問題的根源 — 直接依賴 Class 讓測試變得很痛

讓我們回頭看第一集裡的寄信程式。在最原始的版本裡,SendEmail() 長這樣:

public void SendEmail(IMailContent mail)
{
    EmailServer server = new EmailServer();
    server.IP = "1.1.1.1";
    server.Username = "admin";
    server.Password = "password";
    server.Connect();
    server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance);
    server.Close();
}

你看到第一行的 new EmailServer() 了嗎?這一行,就是讓這個 method 難以被測試的根本原因。

當 SendEmail() 裡面直接 new 出一個 EmailServer,這就代表你的程式和 EmailServer 這個 class 是緊緊綁在一起的,用業界的說法叫做 hard dependency。只要你呼叫 SendEmail(),它就一定會去嘗試連線到那台 IP 是 1.1.1.1 的 email 伺服器。如果你想寫一個 Unit Test 來驗證 SendEmail() 有沒有正確執行,你就被迫要依賴那台真實的伺服器。一旦那台伺服器不在,或是你的測試環境根本連不到那個 IP,你的 Unit Test 就永遠無法正常跑完,這樣的 Unit Test 是沒有意義的。


解法: 用 Interface 把「怎麼寄」抽離出來

從前幾集的內容,相信你已經能猜到解法的方向了。既然直接依賴 EmailServer class 是問題的根源,那麼我們就把 EmailServer 的行為抽成一個 Interface。

public interface IEmailServer
{
    void Connect(string ip, string username, string password);
    void Send(string receipent, string content, string subject, bool importance);
    void Close();
}

然後讓真正的 EmailServer 去實作這個 Interface:

public class EmailServer : IEmailServer
{
    public void Connect(string ip, string username, string password)
    {
        // 真正的連線邏輯
    }

    public void Send(string receipent, string content, string subject, bool importance)
    {
        // 真正的寄信邏輯
    }

    public void Close()
    {
        // 真正的關閉連線邏輯
    }
}

接下來,把原本的寄信程式改造一下。我們把它包成一個 class,並且讓 IEmailServer 從外面傳進來,而不是在 method 裡面直接 new:

public class EmailSender
{
    private readonly IEmailServer _server;

    public EmailSender(IEmailServer server)
    {
        _server = server;
    }

    public void SendEmail(IMailContent mail)
    {
        _server.Connect("1.1.1.1", "admin", "password");
        _server.Send(mail.ReceipentEmail, mail.HtmlContent, mail.Subject, mail.Importance);
        _server.Close();
    }
}

你有沒有注意到,EmailSender 現在完全不知道傳進來的 IEmailServer 到底是哪一個 object,它只認得 IEmailServer 這個 Interface 的長相。


做一個假的 EmailServer

因為 EmailSender 現在依賴的是 IEmailServer 而不是真正的 EmailServer,所以在寫 Unit Test 的時候,我們完全可以自己做一個假的 EmailServer object 傳進去。這個假的 object 業界通常叫做 Mock 或是 Fake。

public class FakeEmailServer : IEmailServer
{
    public bool ConnectWasCalled { get; private set; }
    public bool SendWasCalled { get; private set; }
    public string LastReceipent { get; private set; }

    public void Connect(string ip, string username, string password)
    {
        ConnectWasCalled = true;
    }

    public void Send(string receipent, string content, string subject, bool importance)
    {
        SendWasCalled = true;
        LastReceipent = receipent;
    }

    public void Close() { }
}

這個 FakeEmailServer 不會真的連線到任何伺服器,也不會真的把信寄出去。它做的事情非常簡單,就是記錄「有沒有被呼叫過」以及「傳進來的值是什麼」。也許你現在覺得這樣的 class 感覺很蠢,但等一下你就會看到它的用途。


Unit Test 實際長什麼樣子

有了 FakeEmailServer,我們就可以寫出一個完全不依賴真實伺服器的 Unit Test:

[TestMethod]
public void SendEmail_ShouldCallSendOnServer_WithCorrectReceipent()
{
    // Arrange — 準備測試所需的物件
    var fakeServer = new FakeEmailServer();
    var sender = new EmailSender(fakeServer);
    var mail = new MailContent
    {
        ReceipentEmail = "test@example.com",
        HtmlContent = "<p>Hello</p>",
        Subject = "Test Subject",
        Importance = false
    };

    // Act — 執行你要測試的動作
    sender.SendEmail(mail);

    // Assert — 驗證結果是否符合預期
    Assert.IsTrue(fakeServer.ConnectWasCalled);
    Assert.IsTrue(fakeServer.SendWasCalled);
    Assert.AreEqual("test@example.com", fakeServer.LastReceipent);
}

Unit Test 通常會分成三個階段,分別是 Arrange、Act、Assert,這三個階段在業界也常被簡稱為 AAA。Arrange 是準備測試所需的物件和資料,Act 是執行你想要測試的動作,Assert 則是驗證執行結果是否符合你的預期。

在這個例子裡,你可以看到 fakeServer 被傳進 EmailSender,當 sender.SendEmail(mail) 被呼叫後,我們透過 fakeServer 裡記錄的狀態來確認 Connect() 有被呼叫、Send() 有被呼叫,而且傳進去的收件人 email 也是正確的。整個測試過程完全不需要真實的伺服器,你在任何一台電腦上跑這個 test,結果都會是一致的。


這一集的重點

回頭看一下整個過程,其實我們做的事情並不複雜。我們把第一集的寄信程式稍微整理了一下,用 Interface 把對 EmailServer 的依賴抽離出來,然後透過建構子把 IEmailServer 從外面傳進去。光是這兩個動作,就讓原本難以測試的程式碼變成可以被自動驗證的程式碼。

記得第一集說過的一句話嗎?「一個 object 可以有多個外衣」。EmailServer 和 FakeEmailServer 都穿著 IEmailServer 這件外衣,對 EmailSender 來說,它完全看不出來傳進來的是哪一個,也不需要知道。在正式環境裡,你傳進去的是真正的 EmailServer;在測試環境裡,你傳進去的是 FakeEmailServer。程式碼一行都不用改,行為卻可以完全不同,這就是 Interface 帶給你的自由。

Interface 帶來的好處,遠遠不只是讓元件之間 decouple 而已。可測試性只是其中一個受益者,而且在實務上,這個好處往往是最直接讓你感受到程式品質提升的地方。如果你的程式裡有很多直接 new 出來的 class,不妨試著問自己一個問題:這個 class,有辦法換成 Interface 嗎?

Share:

#104 資料完整性的守護者 - 淺談 Merkle Tree

當你在使用 Git 版本控制時,當你在下載 BitTorrent 檔案時,當你在使用區塊鏈應用時,有一個默默運作的資料結構正在背後確保你拿到的資料是正確的、完整的、沒有被竄改的。這個資料結構就是 Merkle Tree,它的發明者是密碼學家 Ralph Merkle,因此也有人稱它為 Hash Tree。

Merkle Tree 要解決什麼問題?

想像你正在從網路上下載一個 10GB 的電影檔案。下載完成後,你怎麼知道這個檔案是完整的?沒有在傳輸過程中損壞?更重要的是,沒有被惡意竄改?

最直覺的做法是什麼?對,就是計算整個檔案的 hash 值,例如 SHA-256,然後跟官方公布的 hash 值比對。如果一致,就代表檔案是完整且正確的。這方法聽起來不錯,但有個致命的缺點:如果下載到一半發現檔案損壞了,你得重新下載整個 10GB 的檔案。

再進一步想,如果你是從 P2P 網路下載檔案,檔案被切成了數千個小區塊 (chunk),分別從不同的節點下載。你要怎麼確保每一個區塊都是正確的?難道要為每一個區塊都儲存一個 hash 值嗎?如果有 10,000 個區塊,你就得儲存 10,000 個 hash 值,這也是個很大的負擔。

Merkle Tree 就是為了解決這個問題而生的。它讓你能夠:

  1. 快速驗證某一小塊資料是否正確,而不需要下載或檢查整個檔案
  2. 用相對少量的 hash 值就能驗證大量的資料區塊
  3. 當資料有問題時,能快速定位出是哪一塊出了問題

Merkle Tree 的結構

Merkle Tree 本質上就是一種二元樹 (Binary Tree),但它有一個很特別的性質:每一個節點都儲存了一個 hash 值。

讓我們從最簡單的例子開始。假設你有四筆資料:A, B, C, D。

第一步:計算葉節點 (Leaf Nodes) 的 hash

  • Hash(A) = h_A
  • Hash(B) = h_B
  • Hash(C) = h_C
  • Hash(D) = h_D

第二步:兩兩配對,計算父節點的 hash

  • Hash(h_A + h_B) = h_AB
  • Hash(h_C + h_D) = h_CD

第三步:繼續往上,計算根節點 (Root) 的 hash

  • Hash(h_AB + h_CD) = h_ABCD
最終的結構會長這樣:
           h_ABCD (Root Hash)
          /                \
       h_AB                h_CD
      /    \              /    \
    h_A    h_B          h_C    h_D
     |      |            |      |
     A      B            C      D

這個最頂端的 h_ABCD 就是 Merkle Root,它代表了整棵樹的 hash 值。只要底下任何一筆資料改變了,Merkle Root 也會跟著改變。

如何建立 Merkle Tree

建立 Merkle Tree 的演算法其實很簡單,步驟如下:

  1. 準備資料區塊:將原始資料切成固定大小的區塊。如果資料總數不是 2 的次方,可以用空白資料或重複最後一個區塊來補齊。
  2. 計算葉節點 hash:對每一個資料區塊計算 hash 值,這些 hash 值就是葉節點。
  3. 向上建構:將相鄰的兩個節點配對,將它們的 hash 值串接後再做一次 hash,得到父節點的 hash 值。
  4. 重複第三步:繼續往上層建構,直到只剩下一個節點,這就是 Merkle Root。

讓我們看一個簡單的 Python 實作概念:

import hashlib

def hash_data(data):
    """計算資料的 SHA-256 hash"""
    return hashlib.sha256(data.encode()).hexdigest()

def build_merkle_tree(data_blocks):
    """建立 Merkle Tree"""
    # 第一層:葉節點
    current_level = [hash_data(block) for block in data_blocks]
    
    # 向上建構,直到剩下一個根節點
    while len(current_level) > 1:
        next_level = []
        # 兩兩配對
        for i in range(0, len(current_level), 2):
            left = current_level[i]
            # 如果是奇數個節點,最後一個自己跟自己配對
            right = current_level[i + 1] if i + 1 < len(current_level) else left
            # 計算父節點的 hash
            parent_hash = hash_data(left + right)
            next_level.append(parent_hash)
        current_level = next_level
    
    # 回傳 Merkle Root
    return current_level[0]

# 使用範例
data = ["區塊 A", "區塊 B", "區塊 C", "區塊 D"]
merkle_root = build_merkle_tree(data)
print(f"Merkle Root: {merkle_root}")

建立一棵 n 個葉節點的 Merkle Tree,時間複雜度是 O(n),因為每一個節點只需要計算一次 hash。空間複雜度也是 O(n),因為總共會有大約 2n 個節點(葉節點 n 個,內部節點約 n 個)。

Merkle Tree 的應用場景

Merkle Tree 在許多分散式系統和需要資料完整性驗證的場景中都有重要的應用。

1. P2P 檔案分享系統

在 BitTorrent 這類的 P2P 檔案分享系統中,Merkle Tree 扮演了關鍵角色。檔案被切成數千個小區塊,分散在網路上不同的節點。當你下載這些區塊時,系統會用 Merkle Tree 來驗證每一個區塊的正確性。

傳統方法需要為每個區塊儲存一個 hash 值。如果有 10,000 個區塊,你就需要儲存 10,000 個 hash(假設每個 hash 是 32 bytes,就是 320KB)。但使用 Merkle Tree,你只需要儲存一個 Merkle Root(32 bytes)以及驗證時需要的路徑 hash 值即可。

2. 分散式資料庫

在分散式資料庫系統中,不同節點上的資料需要保持一致性。Merkle Tree 可以快速比較兩個節點的資料是否相同。如果兩個節點的 Merkle Root 相同,代表它們的資料完全一致;如果不同,可以從樹的上層往下層比較,快速找出哪些資料區塊不一致,只需同步這些不一致的部分即可。

Apache Cassandra 和 DynamoDB 等 NoSQL 資料庫就使用 Merkle Tree 來進行副本之間的資料同步。

3. 區塊鏈技術

在區塊鏈中,每一個區塊都包含了許多筆交易記錄。這些交易記錄會被組織成一棵 Merkle Tree,區塊的標頭 (Header) 只需要儲存 Merkle Root 即可。這帶來兩個好處:

  • 輕量級節點 (Light Client) 只需下載區塊標頭,就能驗證某筆交易是否存在於區塊中
  • 任何一筆交易被竄改,都會導致 Merkle Root 改變,立即被發現

4. Git 版本控制系統

Git 內部使用了類似 Merkle Tree 的結構(雖然不完全相同)來追蹤檔案的變更。每一個 commit 物件都包含了一個指向檔案樹的 hash,確保了程式碼的完整性和不可竄改性。

以 P2P 檔案傳輸為例說明 Merkle Tree 運作

讓我們用一個具體的例子來看 Merkle Tree 如何在 P2P 檔案傳輸中運作。

假設 Alice 想要從 P2P 網路下載一個檔案,這個檔案被切成 8 個區塊:D0, D1, D2, D3, D4, D5, D6, D7。

步驟一:建立 Merkle Tree

                      Root
                    /      \
                H01          H23
               /  \          /  \
           H01a  H01b    H23a  H23b
           /  \   /  \    /  \   /  \
          L0  L1 L2 L3  L4  L5 L6  L7
          |   |  |  |   |   |  |   |
          D0  D1 D2 D3  D4  D5 D6  D7

檔案的提供者(種子節點)會先建立這個檔案的 Merkle Tree:


其中:

  • L0 = Hash(D0), L1 = Hash(D1), ..., L7 = Hash(D7) ← 葉節點(Leaf)
  • H01a = Hash(L0 + L1), H01b = Hash(L2 + L3), H23a = Hash(L4 + L5), H23b = Hash(L6 + L7) ← 第二層
  • H01 = Hash(H01a + H01b), H23 = Hash(H23a + H23b) ← 第三層
  • Root = Hash(H01 + H23) ← 根節點

種子節點只需要公布 Merkle Root 給所有想下載檔案的節點。

步驟二:下載資料區塊

Alice 開始從不同的節點下載檔案區塊。假設她從節點 Bob 那裡下載了區塊 D2。

步驟三:驗證資料完整性

為了驗證 D2 是否正確,Alice 需要什麼?她不需要下載所有的資料區塊,她只需要:

  1. D2 本身
  2. D2 的 "姐妹節點" L3(D3 的 hash)
  3. D2 父節點的姐妹節點 H01a
  4. 第三層的姐妹節點 H23
  5. Merkle Root(這個她早就知道了)
驗證過程如下:(驗證 D2)
                      Root (已知)
                    /      \
                H01          H23 (需要)
               /  \          
           H01a    H01b (計算)
                    /  \
                   L2   L3 (需要)
                  (計算)  
                   |   
                   D2 (下載的資料)  
  
  1. Alice 計算 L2 = Hash(D2)
  2. Bob 同時提供 L3(D3 的 hash)給 Alice
  3. Alice 計算 H01b = Hash(L2 + L3)
  4. Bob 提供 H01a 給 Alice
  5. Alice 計算 H01 = Hash(H01a + H01b)
  6. Bob 提供 H23 給 Alice
  7. Alice 計算 Root' = Hash(H01 + H23)
  8. 比較 Root' 是否等於 Root

如果 Root' == Root,代表 D2 是正確的!整個驗證過程,Alice 只需要額外接收三個 hash 值(L3、H01a 和 H23),而不需要下載其他 7 個資料區塊。

步驟四:持續下載與驗證

Alice 繼續從不同節點下載其他區塊,每次都用類似的方法驗證。如果某個區塊驗證失敗,她知道這個區塊有問題(可能損壞或被竄改),可以向其他節點重新請求這個特定的區塊,而不用重新下載整個檔案。

為什麼這個方法有效?

關鍵在於 hash 函數的特性:

  1. 確定性:相同的輸入永遠產生相同的輸出
  2. 不可逆:無法從 hash 值反推原始資料

輸入的微小改變會導致 hash 值完全不同。所以,如果 D2 被竄改了哪怕一個位元,計算出來的 L2 就會完全不同,最終計算出來的 Root' 也會不同,驗證就會失敗。

Merkle Proof:驗證的效率

在上面的例子中,為了驗證 D2,Alice 需要接收 log₂(n) 個額外的 hash 值(其中 n 是資料區塊總數)。在我們的例子中,n = 8,所以需要 log₂(8) = 3 個額外的 hash(L3, H01a, H23,加上 Root 本身已知)。

這組用來驗證某個資料區塊的 hash 值集合,稱為 Merkle ProofMerkle Path

假設每個 hash 是 32 bytes(SHA-256),那麼:

  • 傳統方法:需要儲存 8 個 hash = 256 bytes
  • Merkle Tree:只需要 3 個 hash = 96 bytes

當資料區塊數量增加時,這個優勢更加明顯:

  • 1,024 個區塊:傳統方法 32KB vs Merkle Tree 320 bytes(log₂(1024) = 10)
  • 1,048,576 個區塊:傳統方法 32MB vs Merkle Tree 640 bytes(log₂(1048576) = 20)

這就是為什麼 Merkle Tree 在大規模分散式系統中如此重要且有效率。了解 Merkle Tree 不僅能幫助你更深入了解這些系統的運作原理,也能在設計需要資料驗證的系統時,提供一個優雅且高效的解決方案。


Share:

#103 分散式系統的「開會」藝術 - 淺談共識演算法

 上一篇文章聊到了在沒有統一時鐘的分散式世界裡,如何利用 Lamport Timestamp 這樣的邏輯時鐘來替事件排出先後順序。知道了「順序」固然重要,但這只是第一步。在現實的分散式系統中,往往有一群機器需要協同工作,它們不僅要知道誰先誰後,更需要對「某件事」達成一致的看法。

想像一下,一個由五台伺服器組成的資料庫叢集。當一個使用者送出請求:「把我的帳戶餘額扣除 100 元,轉給小明」。這五台機器必須對這個操作達成共識:

  • 這筆交易發生了嗎?
  • 這筆交易發生在另一筆交易之前還是之後?
  • 現在大家的餘額資料庫裡,最新的狀態是什麼?
如果其中一台機器認為轉帳成功了,而另一台認為失敗了,那整個系統就會陷入精神分裂般的混亂。

讓一群不可靠的機器(可能會當機、網路會延遲或斷線)對某個值達成一致的過程,就是著名的「共識問題」(Consensus Problem)而解決這個問題的方法,就稱為「共識演算法」。共識演算法是分散式系統中最困難、也最迷人的主題之一。這篇文章就用最淺顯的方式來揭開它的內容。

Share:

#102 - 分散式系統的基礎建設 - 時間與順序

為什麼時間在分散式系統中如此重要

想像一下,你和三個好朋友分別在台北、台中、高雄和花蓮,你們正在用手機的群組聊天軟體討論週末要去哪裡玩。每個人都在自己的手機上打字、傳送訊息,這些訊息會透過網路送到不同地方的伺服器,再轉發給其他人。這個看似簡單的聊天過程,其實就是一個「分散式系統」的例子。

在這個系統裡,時間扮演了一個至關重要的角色。當你看到聊天記錄時,你會期望訊息是「有順序的」。如果小明說「我們去墾丁好不好?」,小華回答「好啊,我贊成!」,但你的手機卻先顯示小華的回答,再顯示小明的提議,那就會讓人摸不著頭緒了。

時間問題在分散式系統中特別棘手,是因為這個世界上並沒有一個「絕對正確的時鐘」。每台電腦、每支手機、每個伺服器都有自己的時鐘,而這些時鐘可能快一點或慢一點。就像你家的掛鐘可能走得比學校的鐘快兩分鐘一樣,當你有上千台機器分散在世界各地時,要讓它們對「現在是幾點幾分」達成共識,是一件非常困難的事情。

讓我們看一個更嚴重的例子。想像你正在用網路銀行轉帳,你先在帳戶 A 存入一萬元,然後立刻從帳戶 A 轉帳五千元到帳戶 B。如果銀行系統搞錯了這兩個操作的順序,先處理轉帳再處理存款,那系統可能會以為你的帳戶沒有足夠的錢,導致轉帳失敗。更糟的是,如果系統允許你「先轉帳再存款」,那就等於讓你用了還沒存進去的錢,這在金融系統中是絕對不能發生的錯誤。

在資料庫系統中,時間的問題同樣關鍵。現代的大型網站通常會把資料複製到世界各地的多個資料中心,這樣才能讓使用者快速存取資料。當你在台北修改了一筆資料時,這個修改需要同步到美國、歐洲、日本的資料中心。如果有另一個使用者幾乎在同一時間也修改了同一筆資料,系統要如何判斷哪個修改應該被採用?如果不同地方的伺服器時鐘不一致,系統可能會做出錯誤的決定,導致資料錯亂。

除此之外,當分散式系統出現問題時,工程師需要查看各個伺服器的日誌檔案來找出原因。但如果這些日誌的時間戳記不準確,工程師可能會看到一個「不可能的」事件序列,例如系統先收到回應,然後才發送請求,這就像看一部倒著播放的電影,會讓人完全無法理解到底發生了什麼事。

所以,時間在分散式系統中之所以重要,是因為它直接影響到系統的正確性、可靠性和可維護性。沒有正確的時間概念,我們無法確保操作的順序、無法維護資料的一致性、無法進行有效的除錯。這就是為什麼,雖然時間看起來是個簡單的概念,但在分散式系統中卻是一個需要被認真對待的重大挑戰。

系統需要的是真實時間,還是順序?

在探討分散式系統的時間問題時,有一個很重要的觀念需要先釐清:系統真正需要的,究竟是「真實的時間」(例如現在是 2026 年 1 月 1 日下午三點二十分),還是只需要知道「事件的順序」(例如 A 事件發生在 B 事件之前)?這兩種需求看起來類似,但其實有著本質上的不同,而且需要用完全不同的方式來解決。

讓我們先從需要「真實時間」的情況開始談起。想像你正在開發一個股票交易系統,每天上午九點整開盤,下午一點半收盤。在這個系統中,時間不僅僅是用來排序交易的,它有著明確的「實際意義」。如果有人在早上八點五十分下單,系統必須要知道「開盤時間還沒到」,所以這筆交易要等到九點才能執行。又或者有人在下午一點三十五分想要下單,系統必須明確告訴他「市場已經收盤了」。在這種情況下,系統需要的是真實的、精確的時間,而不只是一個抽象的順序關係。

另一個需要真實時間的例子是日誌記錄和稽核。假設你的公司有一個規定,要求所有對重要資料的存取都必須被記錄下來,而且這些記錄必須保留七年。當監管單位來稽查時,他們可能會問:「在 2025 年 3 月 15 日下午兩點到四點之間,有誰存取過客戶的個人資料?」在這種情況下,系統需要能夠提供準確的時間資訊。如果你的日誌只記錄了「事件 A 發生在事件 B 之前」這樣的相對順序,而沒有記錄真實的時間,那就完全無法回答這個問題了。

金融交易也是一個對真實時間要求極高的領域。金融法規通常會規定,所有的交易都必須精確記錄到毫秒甚至微秒的層級。這不僅是為了合規性,也是為了公平性。在高頻交易的世界裡,晚了一毫秒可能就意味著錯過了一個獲利機會,或者在價格變動前來不及出場。更重要的是,當發生糾紛時,精確的時間戳記可以作為法律證據。例如,如果有人宣稱他在價格暴跌前就已經下了賣單,但交易系統顯示那筆賣單的時間戳記是在價格暴跌之後,那這個時間戳記就成為了判斷真相的關鍵證據。

然而,並不是所有的系統都需要真實的時間。在很多情況下,我們真正在意的只是「事件的順序」。讓我們回到一開始的聊天室例子。當你在看聊天記錄時,你在意的是訊息的順序是否合理,而不是每則訊息精確是在哪一秒發送的。如果小明說「我們去墾丁好不好?」,然後小華說「好啊」,接著小美說「+1」,你關心的是這三則訊息的順序,而不是它們是在下午兩點零三分十五秒、兩點零三分十八秒、還是兩點零三分二十秒發送的。事實上,就算這些訊息的發送時間只相差零點幾秒,在人類的感知中幾乎是「同時」,但我們仍然希望它們能按照正確的因果順序顯示。

版本控制系統是另一個只需要順序而不需要真實時間的例子。當你使用 Git 管理程式碼時,系統需要知道的是「哪個版本是基於哪個版本修改的」。假設你從版本 A 創建了一個分支,做了一些修改形成版本 B,然後又做了更多修改形成版本 C。在這個過程中,重要的是 A→B→C 這個順序關係,而不是每個版本精確是在什麼時候創建的。即使兩個版本在實際時間上只相差一秒鐘,只要我們知道哪個版本是基於哪個版本,就能正確地進行合併和衝突解決。

資料庫的因果一致性也是一個很好的例子。假設你在社群媒體上發了一則貼文,然後有人留言,接著又有人回覆那則留言。這裡有一個清楚的因果鏈:貼文必須先存在,才能有留言;留言必須先存在,才能有回覆。資料庫需要確保這個因果順序被正確維護,這樣使用者才不會看到「對一則不存在的貼文的留言」這種不合理的情況。但資料庫不需要知道貼文是在「下午三點零五分二十三秒」發布的,它只需要知道「貼文發生在留言之前」就夠了。

分散式系統中的互斥鎖(mutex)也只需要順序資訊。當多個程序想要同時存取同一個資源時,它們需要競爭一個鎖。系統需要決定「誰先取得鎖」,但這個「先」指的是邏輯上的先後順序,而不是實際時間上的先後。如果程序 A 和程序 B 幾乎同時請求鎖,可能程序 B 的請求在實際時間上早了零點零一秒,但因為網路延遲,程序 A 的請求先到達,那麼讓程序 A 先取得鎖是完全合理的。重要的是系統有一個一致的方式來判斷順序,而不是這個順序是否精確反映了「實際時間」。

這兩種需求的差異,也導致了完全不同的解決方案。當系統需要真實時間時,我們必須投資在精確的時鐘同步技術上,例如使用 GPS 衛星、原子鐘,或者像 Google 的 TrueTime 這樣的基礎設施。這些解決方案通常很昂貴,而且有其物理限制,例如光速限制了訊號傳遞的速度,所以不同地點的時鐘永遠無法做到「絕對同步」。

相反地,當系統只需要順序資訊時,我們可以用「邏輯時鐘」來解決問題。邏輯時鐘不依賴實際的物理時間,它只是一種為事件賦予順序的機制。這種方法的好處是,它完全不受物理時鐘誤差的影響。即使兩台機器的系統時鐘相差了一分鐘,邏輯時鐘仍然能正確地記錄事件的因果順序。而且邏輯時鐘的實作通常非常簡單,不需要昂貴的硬體或複雜的同步協定。

理解這個區別是非常重要的,因為它直接影響到系統的設計決策。如果你誤以為系統需要真實時間,但其實只需要順序,那你可能會花費大量的資源去建置精確的時鐘同步系統,卻發現這些投資其實是不必要的。反過來說,如果你的系統確實需要真實時間,卻只用邏輯時鐘來處理,那系統就無法滿足需求,可能會在稽核、合規或關鍵業務邏輯上出現問題。

所以,在設計分散式系統時,第一個要問的問題就是:我的系統真的需要知道「現在幾點」嗎?還是我只需要知道「誰先誰後」?回答這個問題,將會引導你走向完全不同的技術方案。

Lamport Timestamp:用邏輯建立秩序

在了解了系統有時候只需要順序而不需要真實時間之後,我們來看一個非常優雅的解決方案:Lamport Timestamp(Lamport 時間戳)。這個方法是由計算機科學家 Leslie Lamport 在 1978 年提出的,它的美妙之處在於,用一個極其簡單的機制,就解決了分散式系統中事件排序的難題。

Lamport Timestamp 要解決什麼問題?

讓我們先想像一個具體的情境。假設你正在開發一個多人協作的線上文件編輯系統,就像 Google Docs 那樣。當多個使用者同時編輯同一份文件時,每個人的修改都需要被同步到其他人那裡。假設小明在台北刪除了第三行的文字,幾乎同時,小華在高雄修改了第五行的文字。這兩個操作需要被同步到所有使用者的畫面上,但問題是:系統要如何決定這兩個操作的順序?

如果系統依賴各自電腦的時鐘,就會遇到麻煩。假設小明的電腦時鐘比標準時間快了兩秒,小華的電腦時鐘是準確的。小明在「他的時間」下午三點整進行刪除操作,小華在「他的時間」下午三點零一分進行修改操作。如果系統單純比較時間戳,會認為小明的操作發生在前。但實際上,因為小明的時鐘快了兩秒,他的操作在真實時間上可能是在下午兩點五十八分,而小華的操作是在下午三點零一分,所以小華的操作實際上晚了三分鐘。

更複雜的情況是,這兩個操作可能根本沒有因果關係。它們是兩個人在各自的電腦上獨立進行的操作,沒有誰影響誰。在這種情況下,不管以什麼順序執行,只要所有使用者看到的順序一致,系統就是正確的。Lamport 發現的關鍵洞察是:在分散式系統中,我們真正需要的不是「絕對的時間」,而是「因果關係」。如果事件 A 導致了事件 B(例如,小明發送了一則訊息,小華收到後回覆),那麼系統必須確保 A 被排在 B 之前。但如果兩個事件沒有因果關係(例如兩個人同時在不同地方編輯文件),那麼它們的順序其實並不重要,只要所有人看到的順序一致就好。

Lamport Timestamp 的核心概念

Lamport 提出了一個叫做「發生在之前」(happens-before)的關係。這個關係用符號「→」來表示,讀作「A 發生在 B 之前」。這個關係有三個簡單的規則:

第一個規則是關於同一個程序內的事件。如果在同一台電腦、同一個程式裡,事件 A 在事件 B 之前執行,那麼 A→B。這是最直觀的規則,就像你先打開一個檔案,再編輯它,「打開」一定發生在「編輯」之前。

第二個規則是關於訊息傳遞。如果事件 A 是「發送一則訊息」,事件 B 是「接收這則訊息」,那麼 A→B。這也很直觀,你不可能在訊息被發送之前就收到它。這個規則建立了不同程序之間的因果關係。

第三個規則是遞移性。如果 A→B,而且 B→C,那麼 A→C。就像如果你知道小明比小華高,小華比小美高,那麼你就可以推論出小明比小美高。

如果兩個事件之間沒有 happens-before 關係,那麼它們就是「並發的」(concurrent)。注意,這裡的並發不一定表示它們在實際時間上同時發生,而是表示它們之間沒有因果關係,系統無法判斷誰先誰後。

Lamport Timestamp 的運作方式

有了 happens-before 的概念之後,Lamport 設計了一個非常簡單的演算法來給每個事件分配一個時間戳,而且這個時間戳能夠反映 happens-before 關係。這個演算法的規則簡單到令人驚訝:

每個程序維護一個本地的計數器,我們稱它為 C。這個計數器一開始是 0。每當這個程序要執行任何事件(不管是本地的計算、發送訊息、還是接收訊息),它都會先把計數器加 1,然後用新的計數器值作為這個事件的時間戳。

當程序發送訊息時,它會把當前的時間戳附加在訊息裡一起發送出去。當另一個程序接收到這則訊息時,它會看到訊息裡的時間戳,然後把自己的計數器更新為「自己的計數器」和「訊息的時間戳」中較大的那個,再加 1。

為什麼要取較大值再加 1 呢?這是為了確保接收訊息的時間戳一定大於發送訊息的時間戳,從而維持因果順序。讓我們用一個例子來說明。

假設有兩個程序 P1 和 P2。一開始,P1 的計數器是 0,P2 的計數器也是 0。P1 執行了一個本地事件,它把計數器加 1,所以這個事件的時間戳是 1。接著 P1 又執行了一個本地事件,計數器變成 2,時間戳是 2。然後 P1 發送一則訊息給 P2,發送前先把計數器加 1 變成 3,所以這則訊息帶著時間戳 3。

在 P2 這邊,它在收到訊息之前,自己的計數器還是 0。當它收到帶著時間戳 3 的訊息時,它會計算 max(0, 3) + 1 = 4,所以接收這則訊息的事件時間戳是 4。如果 P2 接下來執行一個本地事件,計數器會變成 5,時間戳就是 5。

這個機制保證了一個重要的性質:如果事件 A 發生在事件 B 之前(根據 happens-before 關係),那麼 A 的時間戳一定小於 B 的時間戳。在我們的例子中,P1 發送訊息(時間戳 3)確實發生在 P2 接收訊息(時間戳 4)之前,而 3 < 4,符合我們的期望。

用程式碼理解 Lamport Timestamp

讓我們用一段簡單的 Python 程式碼來實作 Lamport Timestamp,這樣能更具體地理解它的運作方式:

class LamportClock:
    def __init__(self, process_id):
        """初始化 Lamport 時鐘
        
        參數:
            process_id: 程序的識別編號,用於辨識是哪個程序
        """
        self.time = 0  # 本地時間戳計數器
        self.process_id = process_id
    
    def local_event(self):
        """處理本地事件(例如執行計算、修改資料)
        
        回傳:
            這個事件的時間戳
        """
        self.time += 1
        print(f"程序 {self.process_id}: 本地事件,時間戳 = {self.time}")
        return self.time
    
    def send_message(self, message_content):
        """發送訊息給其他程序
        
        參數:
            message_content: 訊息內容
            
        回傳:
            包含訊息內容和時間戳的元組
        """
        self.time += 1
        timestamp = self.time
        print(f"程序 {self.process_id}: 發送訊息 '{message_content}',時間戳 = {timestamp}")
        return (message_content, timestamp)
    
    def receive_message(self, message_content, received_timestamp):
        """接收來自其他程序的訊息
        
        參數:
            message_content: 訊息內容
            received_timestamp: 訊息攜帶的時間戳
            
        回傳:
            接收事件的時間戳
        """
        # 這是關鍵:取兩個時間戳的最大值,再加 1
        self.time = max(self.time, received_timestamp) + 1
        print(f"程序 {self.process_id}: 接收訊息 '{message_content}',時間戳 = {self.time}")
        return self.time
現在讓我們模擬兩個程序的互動:
# 建立兩個程序的 Lamport 時鐘
process_1 = LamportClock(1)
process_2 = LamportClock(2)

# 程序 1 執行一些本地事件
process_1.local_event()  # 時間戳 = 1
process_1.local_event()  # 時間戳 = 2

# 程序 2 也執行本地事件
process_2.local_event()  # 時間戳 = 1

# 程序 1 發送訊息給程序 2
message, timestamp = process_1.send_message("你好")  # 時間戳 = 3

# 程序 2 接收訊息
process_2.receive_message(message, timestamp)  # 時間戳 = max(1, 3) + 1 = 4

# 程序 2 繼續執行本地事件
process_2.local_event()  # 時間戳 = 5
執行這段程式碼會輸出:
程序 1: 本地事件,時間戳 = 1
程序 1: 本地事件,時間戳 = 2
程序 2: 本地事件,時間戳 = 1
程序 1: 發送訊息 '你好',時間戳 = 3
程序 2: 接收訊息 '你好',時間戳 = 4
程序 2: 本地事件,時間戳 = 5

從這個輸出可以看到,程序 2 在接收訊息時,它的時間戳從 1 跳到了 4。這是因為它需要確保接收事件的時間戳(4)大於發送事件的時間戳(3),從而維持因果順序。 一個更複雜的例子 讓我們看一個更複雜但更貼近實際情況的例子。假設有三個程序在協同工作:

# 建立三個程序
p1 = LamportClock(1)
p2 = LamportClock(2)
p3 = LamportClock(3)

# P1 執行一些工作
p1.local_event()  # P1: 1
p1.local_event()  # P1: 2

# P1 發送訊息給 P2
msg1, ts1 = p1.send_message("任務資料")  # P1: 3

# P2 在收到訊息前,也在做自己的工作
p2.local_event()  # P2: 1
p2.local_event()  # P2: 2

# P2 接收 P1 的訊息
p2.receive_message(msg1, ts1)  # P2: max(2, 3) + 1 = 4

# P2 處理後,發送結果給 P3
msg2, ts2 = p2.send_message("處理結果")  # P2: 5

# P3 一直在做自己的事
p3.local_event()  # P3: 1
p3.local_event()  # P3: 2
p3.local_event()  # P3: 3
p3.local_event()  # P3: 4

# P3 接收 P2 的訊息
p3.receive_message(msg2, ts2)  # P3: max(4, 5) + 1 = 6

在這個例子中,我們可以觀察到幾個重要的現象。首先,P1 的第二個本地事件(時間戳 2)和 P2 的第二個本地事件(也是時間戳 2)雖然有相同的時間戳,但它們是並發的,因為它們之間沒有訊息傳遞,也就沒有因果關係。Lamport Timestamp 並不會試圖區分這兩個事件的先後,因為在邏輯上,它們的順序並不重要。

其次,注意 P3 在接收訊息前已經執行了四個本地事件,時間戳到了 4。但當它接收到時間戳為 5 的訊息時,它的時間戳立刻跳到 6。這確保了「P2 發送訊息」(時間戳 5)這個事件,在因果順序上一定排在「P3 接收訊息」(時間戳 6)之前。

Lamport Timestamp 的優勢與限制

Lamport Timestamp 的最大優勢就是它的簡單性。每個程序只需要維護一個整數計數器,每次執行事件時加 1,接收訊息時做一個簡單的 max 運算。不需要任何複雜的同步協定,不需要存取全域的時鐘伺服器,也完全不依賴實際的物理時間。這使得它非常容易實作,而且不會有額外的網路通訊開銷。

這個方法保證了一個很強的性質:如果事件 A 發生在事件 B 之前(根據 happens-before 關係),那麼 A 的時間戳一定小於 B 的時間戳。這個性質讓我們可以用 Lamport Timestamp 來判斷因果順序,確保系統不會違反因果律,不會出現「先看到回覆,再看到原始訊息」這種不合理的情況。

然而,Lamport Timestamp 也有一個重要的限制:它只能保證單向的推論。也就是說,如果 A 發生在 B 之前,那麼時間戳(A) < 時間戳(B);但反過來說,如果時間戳(A) < 時間戳(B),我們不能推論 A 一定發生在 B 之前,因為它們也可能是並發的。

在我們之前的例子中,P1 的第二個本地事件(時間戳 2)和 P3 的第四個本地事件(時間戳 4),雖然 2 < 4,但這兩個事件其實是並發的,它們之間沒有因果關係。Lamport Timestamp 無法告訴我們這一點。如果你的系統需要能夠判斷「兩個事件是否並發」,那麼你需要使用更進階的方法,例如向量時鐘(Vector Clock)。

儘管有這個限制,Lamport Timestamp 在很多實際應用中已經足夠了。例如在分散式資料庫中,它可以用來實作因果一致性;在分散式互斥鎖中,它可以用來決定哪個程序應該先獲得鎖;在版本控制系統中,它可以用來追蹤修改的依賴關係。它的簡單性使得它成為許多系統的首選方案,而當需要更強的保證時,才會考慮使用更複雜的機制。

Lamport Timestamp 的出現,標誌著分散式系統理論的一個重要突破。它告訴我們,即使在沒有全域時鐘、沒有精確同步的情況下,我們仍然可以建立一個有序的世界。這個看似簡單的想法,影響了後來數十年的分散式系統設計,也為我們理解「時間」在分散式系統中的意義,提供了一個全新的視角。時間不再只是牆上的鐘錶指針,而是一種反映事件之間因果關係的邏輯結構。這種思維方式的轉變,正是 Lamport Timestamp 帶給我們最珍貴的啟發。

Share:

#101 重新回顧 B-Tree: 資料庫引擎快速搜尋的基石

 B-Tree(Balanced Tree 的縮寫)是計算機科學中最具影響力的資料結構之一,特別是在資料庫系統中。其設計確保了高效的資料儲存、檢索和修改,使資料庫引擎能夠更快速地執行查詢操作。本文將探討 B-Tree 的歷史、構建方法、操作原理及其變體,提供對其在資料庫優化中作用的全面理解。

Share:

#100 這是第 100 篇文章...

 這是第一百篇文章,不是學術理論文章,也不是工程技術說明,只是一個單純的生活回顧文.

這幾年的變化很大,對許多年輕人來說,看部落格應該不是件流行的事情.對我這個老人來說,用文字方式來表達想法,是一個比較嚴謹的方式,畢竟是腦筋思考過,文字修飾過的產物.除此之外,還能練習自己的中文寫作能力,讓自己的頭腦繼續保持對文字寫作的熱情.

從 2019 年開始,我的工作和生活有些改變,所以讓我沒有太多空餘時間能整理自己的腦袋來寫部落格的文章,特別是那種圖文並茂的技術說明文章.回顧來看,前面許多文章都是我用小畫家或是 Powerpoint 一個框一個箭頭去畫下來,然後再上傳到 Github 做儲存空間.這一路走來,我收到了一些讀者回應,大部份的人都是來謝謝我寫出那些文章,幫助他們了解背後的原理.非常感謝這些讀者們的回應.誠如第 0 篇文章所說的,這個部落格的目的就是希望把我走過的路,看到的事與物紀錄下來,我以前跌倒過的地方,我相信後面的人也很可能會遇到.如果我能留下一些蛛絲馬跡讓後面的人在 google 答案時能為他的工作或人生有一點啟發,我想這也是我為大家做出一點小小的貢獻.

2019 年開始,尤其嚴峻的工作挑戰和責任,再加上我收了一位小徒弟,當時他是一位國中二年級的學生,擁有優於常人的數學頭腦.從那時起,我平日時間幾乎都是工作 12 小時以上,星期六下午則是小徒弟的上課時間,星期日不是家庭時間就是工作時間,所以比較少有力氣和精神來寫部落格文章了.一年半後,工作上終於有個很成功的回報,同時小徒弟也考上建中.這兩件事情是我在 2020 年最值得寫在人生記錄上的事情.

小徒弟考上建中後,仍維持每個星期六下午來上課,我差不多把我知道的資料結構,演算法等大大小小都告訴他了.我會的當然是一部份而己,為了補不足,我也特別去買了許多課外書給小徒弟自己念,然後我們再一起討論.在那過程中,其實我也學了不少.這就是最典型的,教學相長.當時本來心想要鼓勵小徒弟要不要放棄課業,全力學習寫程式去拼資訊的奧林匹克獎.但是,我後來退縮了.想想我自己也沒做過的事,要我小徒弟去拼,我可能也幫不上什麼忙.打消這種想法,重心還是放在學校功課上,寫程式就當課後興趣.同時,他班上也有幾個興趣相同的同學們,常常討論一些問題的解法以及一些特別的資料結構,在這過程中,我也跟他學習了.

去年,小徒弟在學測後考上了交大資工.他的分數排不上台大資工,而清大資工和交大資工是非常有機會的,湊巧清大交大的面試考試時間又衝突,這擺明了逼高中生只能選一間,於是我建議小徒弟選擇交大資工.後來,緣份來了,交大資工考上了,果然是全村的希望.小徒弟上了大學後,我仍會詢問課程修了什麼,內容學了些什麼.從他學習的內容中,可以看到現在資工系學的東西比我們以前那年代好很多了,尤其是在寫程式這一塊.小徒弟未來想走人工智慧的相關領域,這也可想而見.這兩年來,AI 相關題材相當熱門.其實,AI 一直都在,只不過是 ChatGPT 的出現讓 AI 這個名詞有如中樂透一夜致富的感覺,就像十年寒窗無人問,一舉成名天下知的情況.這兩年來,大大小小的 AI 模型如雨後春荀般的出現,許多相關的工具也是前後相繼的出現,也因為這些 AI 模型和相關的工具鏈的出現,讓一般不是 AI 專長的軟體工程師有機會能做出一些很有趣的 AI 應用,包括我自己也是.同時,也帶來許多新的挑戰,因為你可能已經發現了,ChatGPT 寫出來的程式碼幾乎可以輕易地打敗你寫出來的程式碼了.對軟體工程師來說,這是一個警訊,也是一個機會,就看你如何讓這些 AI 工具成為你的助手,而不是害你成為失業的人.當時,Github copilot 剛問世時,我鼓勵我所有的工程師們一定要學習如何使用它,我們不需要擔心是不是會被 AI 取代,我們要擔心的是別人都會了,而你卻不會!


回首來時路,山高無坦途.

這句話是我在許多登山界的影片裡常會聽到的一句話.人生跟登山也是類似的過程,有時上坡,有時下坡,有時陽光,有時陰雨.對大部份的人來說,生活處處充滿挑戰,因為人們心中都有個夢想,所以才願意時時接受挑戰.挑戰沒成功,這是正常的事情.無論如何,生活還是要過下去,明天還是要繼續活下去,bug 還是要解的.

而我呢? 接下來,我的工作更多了,也很難擠出時間寫更多文章.我常在想,ChatGPT 知道了很多知識,我可以把部落格的文章改成我和 ChatGPT 的對話內容,透過一問一答的方式來讓讀者了解我的思路,讓 ChatGPT 來產生簡單易懂的答案,不知道讀者們會喜歡這想法嗎 ?


Share:

#99 一個特別的資料結構 K-D 樹

前陣子和團隊成員討論工作內容時,看到了一位特別的資料結構.老實說,我以前不知道這個結構,因此特別去查了資料,然後將它的內容寫在這裡.

在電腦科學中,組織數據的方式對於快速查找和使用數據至關重要。其中一種特別的數據結構稱為 K-D 樹(K-Dimension tree),主要用於在多維空間中組織點,非常適合應用於機器學習、電腦圖形和資料庫搜索等領域。現在,讓我們來看看 K-D 樹是什麼、為什麼被發明、它可以解決哪些問題,以及如何編寫 K-D 樹的程式碼。

假設你有一組在 2 維空間中的點(例如地圖上的座標),地圖是由 X, Y 軸所組成的.當你希望快速找到某個位置的最近點,K-D 樹就是一個很好的工具.K-D 樹是一種二元搜尋樹,它在具有 k 維空間中將點組織起來的能力。每一層代表一個維度,並且通過交替各個維度的分割,可以使得在空間內找到鄰近點變得更加容易。

所以, K 代表的是維度數,以地圖來說,K=2.

假設你有一組城市地標的座標,並希望快速找到離給定位置最近的地標,假設你有台北 101 的座標,要尋找附近著名的景點座標。在沒有任何其他資料結構的幫助下,每次搜尋都需要比較每一個地點,計算距離,然後依照 "附近" 的定義來決定那些景點的座標符合要求.如果一個地圖裡有成千上萬甚至數百萬個景點座標時,則尋找會非常耗時。K-D 樹就是為了解決這個問題而發明的。它的核心理念是將座標進行合理的排列,以便在搜尋時可以排除大量不相關的點。K-D 樹最早由 Jon Bentley 在 1975 年發明,並在需要快速搜尋和排序空間數據的應用中廣泛使用,例如 最近鄰搜尋(找到最近的點)。

接下來談談 K-D 樹如何建立以及如何利用它來搜尋.

1.建立過程 - 首先選擇一個維度作為 "分割" 維度。為了簡單起見,假設在 2D 空間中,所以我們有兩個維度:X 和 Y。 - 在樹的根節點上,根據 X 座標分割點。 - 找到 X 值的中位數,並將其作為 "支點" (pivot) 來劃分。把其他座標點 X 大於中位數的放在左邊,小於中位數的放在右邊。 - 接下來,在每一層上交替進行維度分割。下一層使用 Y 座標進行分割,然後再回到 x 座標,以此類推。

因此,樹的每一層就是由不同的維度來形成,以地圖為例,就是 X , Y , X , Y 以此類推,一直到把所有的資料都寫入到樹裡.

2. 尋找過程 - 若要找到最近的鄰居,將目標點與樹的每一層進行比較,根據點是更接近左子樹還是右子樹來選擇路徑。 - 當搜尋到 LEAF 節點時,檢查距離以確認它是否為目前發現的最近點。 - 通過使用樹的結構,我們不需要查看每一個點,這使得搜尋比直接比較更快。

我們將上述的想法利用程式碼來呈現時,它看起來如下:

以下是使用 KD 權的程式碼範例

K-D 樹是一種強大的資料結構,可以幫助我們在多維空間中快速找到點。通過有效組織數據,K-D 樹使得進行最近鄰搜尋和範圍搜尋變得更快。當你在地理位置相關的應用中尋找附近的選項時,K-D 樹很可能就在背後默默運作。

Share:

#98 淺談機器學習

人工智慧在電腦科學的領域不是一個新主題,已經存在很長的時間.其目的是希望電腦可以做出接近人腦一樣的決策,甚至希望比人腦更好.從以前到現在曾試過了數種不同的方式,從 90 年代開始,機器學習成為了人工智慧裡主流的方式,革新了我們處理數據分析、自動化和決策的方式。這文章將討論機器學習的基礎概念,淺談其不同類型、訓練模型等.

機器學習是近二三十年來人工智慧裡流行的方法,專注於開發能夠基於數據的了解並做出預測或決策的演算法。它涉及創建模型和使用模型,這些模型可以找到模式、做出決策,並且隨著時間的推移提高其準確性。機器學習的廣泛分類可以分為 3 大類:監督學習 (supervised learning), 非監督學習 (non-supervised learning) 和半監督學習 (semi-supervised learning)。這一區分主要基於用在開發模型的訓練數據是否包含要預測的結果的答案。

在監督學習中,模型在標籤化的數據集上訓練,也就是說每一筆訓練資料都已經附上了正確答案,也就是標籤,這也代表了每一筆訓練資料都有正確輸入和輸出配對,通常來說,這是基於人類的決策來決定是否正確,所以早期的機器學習專案都需要依賴大量人力的介入來為每個數據給予正確答案,也就是標籤化的動作。模型學習從輸入數據中預測輸出答案,並且可以根據已知結果來衡量其性能。舉個例子,你要做一個能自動辦別貓或狗的程式,你收集了許多有關狗和貓的照片,假設超過了一萬張各式各樣的貓狗照片.並且每一個照片你都給出了正確答案,如第一張是貓,第二張是狗,第三張是貓等等.機器學習裡最重要的任務就是為你的程式產生出一個數學公式 (function),公式的輸入數據就是照片, 公式的輸出結果是答案,貓或狗. 所以,機器學習就是要產生這樣的 數學公式給你的程式來使用. 我們先跳過製作數學公式的細節過程,這個數學公式的行為是從現有的一萬張照片裡 “學習” 而來的,因此,你可以想成用過去的照片,找出一個模式,然後去預測未來的照片. 因此,只要訓練時輸入的照片別太少,在預測的效果就不會太差. 預測準確率也是評估該數學公式表現好壞的重要指標,也常是用來衝量該數學公式的效果好壞. 所以,機器學習裡講的模型就是在訓練過程後產生的數學公式.因為每一張照片都搭配著正確答案,所以這種方式稱為監督學習,也就是說,每個訓練資料的輸入和輸出的關係都是由人類預先定義好.如果每張照片並沒有搭配著正確答案,這種方式則稱為非監督學習。如果訓練的數據裡有部份有標籤,而部份沒有,則這種方式稱為半監督學習。

既便你完全沒接觸過任何的機器學習演算法,單純地從上述的說明加上你對基本演算法的認識,你一定能推斷出監督學習和非監督學習是兩種完全不同的方式.監督學習的訓練資料已經有答案,所以你需要的演算法是在輸入 (照片) 和輸出 (貓狗) 之間找出關係,而非監督學習沒有答案,所以比較適合把 “類似” 的輸入聚合在一起,所謂的 "類似" 可依照功能來區別,讓人們可以很直覺地知道為何這些資料會被放一起.舉個例子,產品推薦功能,在現在許多電子商務或是影音平台網站上都會常見到這樣的功能,網站裡有許多的商品和影片,一開始沒人會知道你想買些什麼.一旦資料越來越多後,許多客戶隨著時間會建立出許多訂單,從訂單的產品裡可以整理出一套 “類似” 的規則.舉例,常買烤雞的客戶也常買牛排,常看搞笑影片的客戶比較少看知識型的影片,這些 “類似” 規則的建立就是非監督學習的成品.

從上面的說明你可以看到不論是那一種方式的機器學習方法都需要足夠多的資料,建立出來的模型也都是依照過去資料的累積而建立出來的,至少這個模型能不能拿來用在未來的預測上,這實在很難保證.舉例,貓狗照片的辦識應該沒太大問題,畢竟十年後二十年後,貓狗的長相都還是會一樣 (除非他們都成了變種貓狗了),但同一家購物商店的商品推薦功能在十年後二十年後還能用嗎? 這沒人敢保證,畢竟商品推薦的模型是用過去數年的客戶訂單資料所建立出來的,裡面都是這些客戶的行為,如果整個社區的住戶全部都換了,你覺得你還能拿一樣的模型來用嗎 ? 答案可能可以,也可能不行.

在監督學習裡所建立出來的模型基本上都是依照你給的新輸入來算出預測的答案. 一般來說,會有兩種不同的情況,要看答案本身之間的關係.舉例,以貓狗照片辦識來說,輸入的數據是貓或狗或是其他動物的照片,模型的輸出是貓或狗或其他,答案本身是一種固定在三個值的變數,我們把這種答案稱為 “分類型” (Classification) .舉另外一個例子,某公司下個月的銷售量預測,輸入的數據是每個月的銷售量,季節因素,經濟好壞等數據,而模型的輸出是一個預測銷售量數字,這些答案並不是固定在幾個固定值的變數,而且會隨著時間和許多其他不同因素變動時而變動的數字,我們把這種答案稱為 “回歸型” (Regression).不同答案類型所需要的演算法或統計法也是不同的.同理也能用在非監督學習裡,答案也可分為 “群組型” (Cluster) 和 “資料探勘型” (Data mining).其實,不論是那一種方法,都是在從你給的輸入數據裡依照你要的答案找出一個數學公式。我們要把輸入資料能 “座標化”,然後數學公式就是在這座標系統裡一個 n 維度的線或面,透過把輸入數據傳給該數學公式後就能推導出答案.

我們來用另一個例子, 假設我們得到十個病人的身高體重資料,而我們要的答案是從這些病人的資料來學習 (預測) 某一個人會不會有高血壓.假設,我們知道每一個病人有沒有高血壓,也就是說我們有答案,我們將身高體重座標化,體重是 x 軸,身高是 y 軸,呈現如下,

因為我們知道那些病人有高血壓 (紅色圈標記) ,因此,我們很容易能找一個能區分有高血壓和沒高血壓的直線或曲線. (線條的選擇或建立就是機器學習課本裡的重點.)

以上是透過 SVM 演算法所製作出來的直線,它就是一個數學公式.因此,當你有新的病人數據進入到這個直線時,我們就能知道這新病人是否有高血壓,因為只要看新輸入數據的座標是在直線的左邊或右邊就能得到答案,左邊沒有高血壓,右邊則有.以上只是一個單純的例子,真實情況下,病人的輸入參數絕對不會只有身高和體重,有意義且足夠多的參數才能幫助產生更好的預測.現在你知道機器學習模型就是在座標系統裡的一條線或是一個面.問題越複雜時,我們需要提供的參數將越多,也代表座標系統的維度越高.這些高維度的座標系統運算在數學上用矩陣來表示,方便閱讀,也方便寫程式,因此在執行機器學習專案時才會需要擅長矩陣運算的 GPU 來執行座標系統裡相關的計算.

另一種情況是我們不知道有那些病人有高血壓,這表示我們要採用非監督學習的方式. 例如,採用 K-means 並將 cluster 數量設定為 2,會得到以下的圖型,

K-means 演算法把十個病人分成兩群組, 5,6,1,7,3是一組, 9,2,8,10,4 是一種. 可想而知,這是一個失敗的數學公式,因為和真實答案相比有很高的錯誤率.在這例子裡會有這種情況是很正常的,畢竟資料太少,只有十個人,資料維度也不夠,只有身高和體重,所以很難在這麼少的數據下得到些結論. 從這個簡單的例子也可以知道有正確答案的監督學習是處在多麼有利的位置.所以,在建立機器學習模型前的資料數據整理和答案標記是對模型的建立和準確率有很大的幫助.

在監督學習和非監督學習之間存在一個灰色區域,稱為半監督學習,也就是指有部份的輸入數據有了正確答案,其中模型在部分標籤化的數據上訓練。當獲取數據的標籤昂貴或耗時時,這種方法很有用。舉個例子,當你有一萬張的動物照片時,你要建立一個機器學習模型能辦識出照片裡是那一種動物,為了讓模型得到最好的辦識結果,你希望採用監督學習方式進行,也就是要將正確答案準備好.但你可以沒有足夠的時間金錢或是其他原因導致無法將一萬張照片標示好正確答案,可能只有三千張照片能完成正確答案標示,另外七千張照片沒有正確答案.此時,可以用一個簡單的方法,將三千張有正確答案標示的照片先進行模型訓練,訓練後得到的模型用於七千張沒正確答案照片的預測,然後再將七千張照片的預測視為正確答案和三千張已有正確答案的照片再重新訓練出一個模型,半監督學習的方法有好幾種,這只是其中一種簡單好懂的.如果這個程過是循序漸進的,則模型還可以適時地改變,這樣就能漸漸地得到更好的模型,這也是所謂的增強學習 (reinforcement learning)。從這個方法延伸下去還可以有許多的理論和應用產生.

機器學習是一個動態且快速發展的領域,具有廣泛的應用範圍。理解不同類型的機器學習、如何訓練模型以及最新進展對於任何希望利用這項強大技術的人來說都至關重要。隨著機器學習繼續融入各個行業,它對社會的影響勢必增長,使其成為一個令人興奮的研究和創新領域。


Share:

#97 Segment Tree

在 binary tree 的文章時曾提到從 tree 開始後的資料結構文章將會開啟另一個大門,看來許多問題可以透過 tree 結構來解決.今天要介紹的 segment tree 就是一個例子.

試想一個情況,在現實生活中一定常常會遇到一個工作,一堆資料裡面,在某一個特定範圍依照某一個資料特徵來尋找資料.例如,在公司組織結構裡,找出 A 老闆底下的員工數總和.假設你已經有了員工組織樹狀結構,第一件要做的事情便是尋找 A 老闆節點,找到 A 老闆節點後,再從這裡出發將所有子節點計算個數,便能得到答案.一旦這樣的查詢工作常常發生的話,你會發現前面說的動作並不能馬上回應答案,因為還是要對員工組織樹狀結構進行 traversal 的動作.公司員工組織樹狀結構有一個特別的特性,員工的流動率正常來說都是小的.一家正常的公司不可能每天有很大比例的人員異動.公司員工組織樹狀結構不需要常常更新,並且需要更新時,也都是小比例的更新.當問題有這樣的特性時,segment tree 便是一個很好的資料結構用來解決這問題。

首先,為了簡單起見,讓我把上面描述的問題簡化,將人名變成一個數字的 array, 然後將非 leaf 的 node 變成是將兩個小孩節點之值的總和,因此,相對應的題目就變成在這個 array 裡面某個連續的空間找出值的總和。這個過程是一個特別的 reduction,專為這個題目做的一個轉換。

Segment tree 是一種 binary tree,它的 leaf node 就是 array element 的值,如下圖,

將它製做成 segment tree,其長相如下,

在中間節點的值就是底下兩個小孩節點的值總和。所以,在上圖中最左邊的 leaf node 是14 和15,他們的上一層節點就是14+15=29,依此類推。同時,在每個節點裡有一個特別的資料,用橘色來表示。橘色的字串代表的是一組位置,array 的index 編號的開始編號與結束編號。例如,上面提的29節點,它的橘色字串為1-2,代表了29的值是從array element 的第一個加到第二個,依此類推。再舉個例子,當你需要第三個到第六個的值時,你只需要找到兩個節點,3-4和5-6這兩個,就可以得到答案。當這個 segment tree 變得很大時,就省下了不少 traversal 步驟,以節省時間。

當 array 的某一個 element 的值改變了時,也只需要更新這個 leaf node 往上的值,一路更新到 root 即可,並不是全部的 tree node 都要更新。

以上談的 segment tree 雖然是用數字來作為例子,但實務上是可以有許多變化的,例如,每個node 的值不一定是數字,也可以是其他種類的資料型別,只要在往上資料彙聚的過程中可以用一個不複雜的方式將下一層的資料會聚在一起寫在上一層的結果。同時,這些結果一定要對你需要的搜尋結果有意義就行了。

Hope it helps,

Share: