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

#108 出神入化的用介面 第六集:Interface 與 Dependency Injection

第二集的內容中,我們用了一個極為簡化的例子來說明物件如何在大型軟體系統中移動.當時的做法是在 CommonLibrary 裡放了一個 static 的 Dictionary,讓各元件把自己實做好的物件「存」進去,其他元件再從這個 Dictionary 裡「取」出來使用.透過這樣的方式,ClassLibrary1 和 ClassLibrary2 彼此不需要互相認識 (不需要加 reference),就能使用對方所提供的功能.

這個做法雖然簡單好懂,但如果你在實際的專案中也這樣做的話,隨著系統越來越大,你會發現幾個問題:

第一,你必須自己管理這個 Dictionary,包括什麼時候該把物件放進去,key 要用什麼字串,以及取出來的時候要記得做型別轉換.第二,每個團隊都必須知道這個 Dictionary 的存在,並且要遵守一些潛規則,例如 key 不能重複,放入的順序不能搞錯等等.第三,當物件之間的相依關係越來越複雜時,手動管理這些註冊和取用的程式碼會變得相當繁瑣且容易出錯.

那麼,有沒有什麼方法可以把這些手動管理的工作交給別人來做呢?答案就是這一集要談的主題:Dependency Injection (DI).

先回顧第二集的做法

在第二集裡,CommonLibrary 提供了一個 ObjectContainer,裡面有一個 Dictionary 來儲存各元件的物件:

public class ObjectContainer
{
    public static Dictionary<string, IOperation> Operations = new Dictionary<string, IOperation>();
    public static Dictionary<string, Form> Dialogs = new Dictionary<string, Form>();
}

然後各元件在啟動時把自己的物件註冊進去:

// ClassLibrary2 的註冊
public static class Starter
{
    public static void Register()
    {
        ObjectContainer.Operations["operation2"] = new Operation2();
        ObjectContainer.Dialogs["library2"] = new Lib2WinForm1();
    }
}

最後在 WindowsFormsApp1 啟動的時候呼叫各元件的註冊方法:

static void Main()
{
    ClassLibrary1.Starter.Register();
    ClassLibrary2.Starter.Register();
    Application.Run(new Form1());
}

而 ClassLibrary1 要使用 ClassLibrary2 的功能時,就去 Dictionary 裡撈:

if (ObjectContainer.Operations.TryGetValue("operation2", out IOperation op))
{
    int result = op.AddIntOperation(5);
}

如果你仔細看這整個流程,你會發現它其實做了三件事情:第一,定義一份 interface 作為雙方的合約 (contract);第二,提供一個容器 (Dictionary) 來存放物件;第三,在程式啟動時把物件註冊到容器裡.這三件事情就是 Dependency Injection Container 的核心概念.換句話說,第二集的做法就是一個最原始的、手動版的 DI Container.

什麼是 Dependency Injection

Dependency Injection 這個名字聽起來很嚇人,但其實概念非常簡單.所謂的 "Dependency" 就是一個物件所依賴的其他物件.所謂的 "Injection" 就是把這個依賴的物件「注入」到需要它的地方.

舉個日常生活的例子,你去餐廳吃飯,你不需要自己跑到廚房去拿菜,服務生會把菜端到你的桌上.在這個比喻裡,菜就是你的 dependency,服務生把菜端給你就是 injection,而餐廳的出菜系統就是 DI Container.你只需要告訴服務生你要什麼菜 (透過菜單,也就是 interface),至於這道菜是哪個廚師做的、用什麼鍋子、幾點開始煮的,你完全不需要知道.

回到程式的世界,在第二集的做法裡,ClassLibrary1 要使用 IOperation 時,必須自己去 Dictionary 裡面找,要知道 key 是什麼,還要做型別的判斷.這就好像你去餐廳吃飯,還得自己走到廚房去翻鍋子找你點的菜一樣.而 DI 的做法則是讓框架自動把 IOperation 的實作送到 ClassLibrary1 手上,ClassLibrary1 只需要說「我需要一個 IOperation」就好了.

自己動手做一個簡單的 DI Container

在談現成的 DI 框架之前,讓我們先自己動手做一個極為簡化的 DI Container,這樣你就能完全理解它的運作原理.其實它的本質就是一個用 Type 當作 key 的 Dictionary,取代第二集裡用字串當作 key 的 Dictionary:

public class SimpleDIContainer
{
    // 用 Type 當作 key,存放的是「如何建立這個物件」的方法
    private Dictionary<Type, Func<object>> _registrations 
        = new Dictionary<Type, Func<object>>();

    // 註冊: 告訴 Container 「當有人要 TInterface 時,請建立 TImplementation」
    public void Register<TInterface, TImplementation>() 
        where TImplementation : TInterface, new()
    {
        _registrations[typeof(TInterface)] = () => new TImplementation();
    }

    // 取得: 根據 interface 的型別,自動建立並回傳對應的實作
    public TInterface Resolve<TInterface>()
    {
        if (_registrations.TryGetValue(typeof(TInterface), out var factory))
        {
            return (TInterface)factory();
        }

        throw new Exception($"Type {typeof(TInterface).Name} is not registered.");
    }
}

這個 SimpleDIContainer 做的事情和第二集的 ObjectContainer 非常相似,但有一個關鍵的不同:它用 Type 來當 key,而不是字串.這表示你不再需要記住每個物件對應的字串是什麼,也不會因為打錯字而導致找不到物件.

接著來看看怎麼用它.首先是註冊的部分:

static void Main()
{
    var container = new SimpleDIContainer();

    // 告訴 Container: 當有人需要 IOperation 時,請給他 Operation2 的實體
    container.Register<IOperation, Operation2>();

    // 取得 IOperation 的實作
    IOperation op = container.Resolve<IOperation>();
    int result = op.AddIntOperation(5);  // result = 7
}

對比一下第二集的做法:

// 第二集: 用字串當 key,手動放入和取出
ObjectContainer.Operations["operation2"] = new Operation2();
ObjectContainer.Operations.TryGetValue("operation2", out IOperation op);

// 這一集: 用 Type 當 key,透過泛型自動對應
container.Register<IOperation, Operation2>();
IOperation op = container.Resolve<IOperation>();

你可以看到,核心概念一模一樣,都是「先存再取」,只是手段從字串對應變成了型別對應,讓編譯器可以在編譯時就幫你檢查型別是否正確,不用等到執行時期才發現字串打錯.

讓 Container 自動注入到建構子

上面的 SimpleDIContainer 雖然已經比手動 Dictionary 好了一些,但使用者還是得自己呼叫 Resolve 來取得物件.真正的 DI 精神是讓 Container 自動把 dependency 注入到需要它的地方.最常見的方式就是透過建構子 (constructor) 來注入.

我們把 SimpleDIContainer 稍微加強一下,讓它能夠分析建構子的參數,並且自動注入對應的物件:

public class DIContainer
{
    private Dictionary<Type, Type> _registrations = new Dictionary<Type, Type>();

    public void Register<TInterface, TImplementation>() 
        where TImplementation : class, TInterface
    {
        _registrations[typeof(TInterface)] = typeof(TImplementation);
    }

    public T Resolve<T>()
    {
        return (T)Resolve(typeof(T));
    }

    private object Resolve(Type type)
    {
        // 如果有註冊,就找到對應的實作型別
        if (_registrations.TryGetValue(type, out Type implementationType))
        {
            return CreateInstance(implementationType);
        }

        // 如果沒有註冊但本身是可建立的類別,也嘗試建立
        if (!type.IsAbstract && !type.IsInterface)
        {
            return CreateInstance(type);
        }

        throw new Exception($"Type {type.Name} is not registered.");
    }

    private object CreateInstance(Type type)
    {
        // 找到建構子
        var constructor = type.GetConstructors().First();
        
        // 取得建構子的參數型別,並且遞迴地解析每一個參數
        var parameters = constructor.GetParameters()
            .Select(p => Resolve(p.ParameterType))
            .ToArray();

        // 用解析好的參數來建立物件
        return Activator.CreateInstance(type, parameters);
    }
}

這個加強版的 Container 會做一件很重要的事:當它要建立一個物件時,它會去看這個物件的建構子需要哪些參數,然後遞迴地把每一個參數對應的物件也建立出來並且注入進去.

於是 ClassLibrary1 的程式碼可以寫成這樣:

public class Lib1WinForm1 : Form
{
    private readonly IOperation _operation;

    // Container 會自動把 IOperation 的實作注入到這個建構子
    public Lib1WinForm1(IOperation operation)
    {
        _operation = operation;
        InitializeComponent();
    }

    private void button2_Click(object sender, EventArgs e)
    {
        // 直接使用,不需要再去 Dictionary 裡面找
        int i = 5;
        richTextBox1.Text += $"int starts at {i}\n";
        i = _operation.AddIntOperation(5);
        richTextBox1.Text += $"int becomes {i} after Operation2\n";

        string s = "aBc";
        richTextBox1.Text += $"string starts as {s}\n";
        s = _operation.ChangeStringOperation(s);
        richTextBox1.Text += $"string becomes {s} after Operation2";
    }
}

注意看 Lib1WinForm1 的建構子,它宣告了一個 IOperation 的參數.當 DI Container 要建立 Lib1WinForm1 的時候,它會看到這個建構子需要一個 IOperation,於是就自動去找已經註冊好的 Operation2 來注入.這就是 "Dependency Injection" 這個名字的由來.

對比第二集的做法,ClassLibrary1 不再需要知道 Dictionary 的 key 是什麼字串,不需要做 TryGetValue,也不需要處理找不到的情況.它只需要在建構子裡說「我需要一個 IOperation」,Container 就會自動把正確的實作塞給它.這就像你在餐廳只需要看菜單點菜,不需要自己去廚房找菜一樣.

三種常見的注入方式

上面的例子是透過建構子來注入,這是最常見也是最推薦的方式,叫做 Constructor Injection.除此之外,還有另外兩種方式:

第一種是 Property Injection,就是透過設定屬性來注入:

public class Lib1WinForm1 : Form
{
    // 透過 Property 注入
    public IOperation Operation { get; set; }
}

第二種是 Method Injection,就是透過方法的參數來注入:

public class Lib1WinForm1 : Form
{
    public void DoSomething(IOperation operation)
    {
        // 透過方法參數注入
        int result = operation.AddIntOperation(5);
    }
}

在實務上,Constructor Injection 是最被推薦的方式,因為它可以確保物件在被建立的時候就已經擁有它所需要的所有 dependency,不會有遺漏的情況.而且透過建構子的參數,你可以一眼就看出這個類別依賴了哪些其他物件,程式碼的意圖很明確.

物件的生命週期

在第二集的做法中,我們把 Operation2 的實體放進 Dictionary 以後,它就一直待在那裡直到整個程式結束.但在真實的系統中,不同的物件可能需要不同的生命週期.有些物件只需要一個就夠了 (例如設定檔的管理),有些物件每次使用都需要一個新的 (例如資料庫的連線),還有些物件在同一個請求範圍內需要共用同一個實體.

一般來說,常見的 DI Container 都會提供以下三種生命週期的管理:

Singleton: 整個應用程式只會建立一個實體,所有人共用同一個.以第二集的做法來說,物件放進 Dictionary 之後就不會再變了,所以等同於 Singleton 的概念.

Transient: 每次有人要求時都建立一個全新的實體.如果你的物件有狀態,而且不同的使用者不應該共用這個狀態,就適合使用 Transient.

Scoped: 在同一個範圍內共用一個實體,離開這個範圍後就銷毀.例如在一個 Web 應用中,同一次 HTTP Request 裡面的所有程式碼共用同一個實體,下一次 Request 進來時再建立一個新的.

如果你的系統需要更細膩的生命週期控管,手動用 Dictionary 來管理就會變得很痛苦,這時候 DI Container 的價值就顯現出來了.

Interface 在 DI 中扮演的角色

從第一集到第二集再到現在,Interface 一直都是整個架構的核心.它的角色就是定義合約 (contract),讓提供服務的一方和使用服務的一方之間有一份共同的協議,而雙方不需要直接認識彼此.

在 DI 的架構中,Interface 更是不可或缺的角色.因為 DI Container 的運作原理就是:你告訴 Container「當有人要求 Interface A 的時候,請提供 Class B 的實體」.Container 就像是一個仲介,它認識所有的 interface 和實作之間的對應關係,而使用者只需要跟 Container 說他需要什麼 interface,Container 就會在 runtime 時把正確的實作注入進去.

這樣做最大的好處就是,如果有一天 ClassLibrary2 團隊決定把 Operation2 替換成 Operation2V2,只需要改一行註冊的程式碼就好:

// 原本
container.Register<IOperation, Operation2>();

// 替換成新版本,只改這一行
container.Register<IOperation, Operation2V2>();

ClassLibrary1 團隊的程式碼完全不需要改動,因為它從頭到尾只認識 IOperation 這個 interface,至於背後是誰實做的,它不知道也不需要知道.在第二集的做法中,如果要替換實作,你也需要改一行程式碼,但你還得確保 Dictionary 的 key 沒有變、其他使用這個 key 的地方也都要對應到,這些在大型系統中是容易出差錯的地方.

從手動管理到自動管理

讓我們把第二集和這一集做一個對照,你會更清楚看到兩者之間的關係:

在第二集中,CommonLibrary 提供了 ObjectContainer (一個用字串當 key 的 Dictionary),各元件在啟動時手動將自己的物件註冊到 Dictionary 裡,使用的一方再手動從 Dictionary 裡用字串取出物件來用.這整個流程中的「註冊」和「取用」都是手動完成的.

而在 DI 的做法中,DI Container 用 Type 來當 key (取代字串),各元件透過泛型方法來註冊 interface 和實作的對應關係,使用的一方只需要在建構子宣告 interface 參數,Container 就會在 runtime 自動注入正確的實作.「註冊」仍然是手動的 (你需要告訴 Container 誰對應誰),但「取用」變成自動的了.

所以本質上,DI Container 就是一個進化版的 Dictionary,它把第二集中我們手動做的事情包裝起來,讓整個流程更自動化、更不容易出錯、也更好維護.

實務上的 DI 框架

上面我們自己手寫了一個簡化版的 DI Container 來說明原理,但在實務的專案中,你不需要自己造輪子,幾乎所有主流的程式語言和框架都有提供現成的 DI Container 可以直接使用.

在 C# 的世界裡,除了框架本身內建的 DI 機制以外,也有像 Autofac、Ninject、Unity 等老牌的 DI Container 套件可以選擇,它們各自提供了更多進階的功能,如自動掃描組件來註冊、模組化的註冊、攔截器 (interceptor) 等.在 Java 的世界裡,Spring Framework 的 IoC Container 是最經典的代表,它的概念和我們今天談的完全一樣.在 Python 中也有像 dependency-injector 這類的套件.不管是哪一種語言或框架,DI 的核心精神都是一樣的:透過 interface (或抽象類別) 定義合約,讓 Container 在 runtime 自動注入正確的實作.

值得一提的是,DI 和 IoC (Inversion of Control,控制反轉) 這兩個名詞常常被放在一起談.IoC 是一個比較抽象的設計原則,意思是「把控制權交出去」.在沒有 DI 的時候,一個物件需要什麼 dependency,它就自己去 new 一個出來,控制權在物件自己手上.而用了 DI 之後,物件不再自己建立 dependency,而是由外部 (Container) 來提供,控制權就「反轉」了.所以你可以把 DI 理解為實現 IoC 原則的一種具體做法.

小結

DI Container 帶來的好處包括:不需要手動管理 key 和 Dictionary、物件的生命週期可以被統一管理、替換實作只需要改一行註冊的程式碼、以及程式碼的可測試性大幅提升 (因為你可以很容易地在測試時注入 mock 物件).

如果你還沒有在專案中使用 DI,我會建議你從小地方開始嘗試.先在一個新的小功能中試著用 DI Container 來管理物件的建立和注入,慢慢地你就會體會到它的好處,然後你再也不會想回到手動管理 Dictionary 的日子了.

Share:

#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(用戶端發現)


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

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


重點: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 裡的運作:


重點:寫入只能透過 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 模式) ===

     

     應用只管商業邏輯,網路通訊的一切交給 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: