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

#112 出神入化的用介面 第八集:Interface 與 Strategy Pattern 把「怎麼做」從「做什麼」分離出來

如果你一路追這個系列,從第一集的 interface 是什麼元件之間怎麼透過 interface 溝通多型的威力,到後面聊的 單元測試依賴注入,再到上一集的 介面隔離原則 (ISP),你對 interface 這東西應該已經很有感覺了.

這一集,我想帶你回到第一集那個寄信的程式,那個例子當初只用來說明什麼是 interface,但其實它還藏著更多東西可以挖.這次我們要用它來聊一個很實用的設計模式:Strategy Pattern.它的核心精神,用一句話講就是:把怎麼做 從 做什麼 分離出來.

在這部落格裡,我沒打算寫太多有關物件導向 design pattern 的文章,而許多 design pattern 的做法其實在 interface 有很大的關係.雖然現在很流行用 AI coding tool 來寫程式,但不見得都能做出很好的設計,畢竟許多需求我們不知道該怎麼寫,在沒有好的資料輸入情況下,語言模型也不見得會給你較佳的設計,所以自己還是要多懂一點比較可靠.

第一集的故事是這樣的:你負責寫一個寄信程式,公司各部門都會把信件內容丟給你寄出去.但 A 部門有 A 部門的 email class,B 部門有 B 部門的 email class,如果你為每一個部門都寫一個 SendEmail(),程式碼就會蠢到不行.

當時的解法是:定義一個 IMailContent 的 interface,讓每個部門的 email class 都去實作它.這樣一來,不管是哪個部門的信件內容,都穿上了 IMailContent 這件外衣,你的寄信程式只要認得這件外衣就好,一個 SendEmail() 通吃所有部門.

我當時是這樣描述的:Interface 提供了 object 一種外衣,不同的 email content object 只要有相同的外衣 (IMailContent),都可以在 SendEmail() 裡面使用.一個 object 可以有多個外衣,因此使得物件導向程式設計變得很彈性也很有趣.

第一集那件外衣是穿在 "資料" 上的 —— 也就是信件的內容.今天這一集,我們要把同樣的概念穿到一個完全不同的東西上面 - 寄信這個動作本身.這就是我說的更深一層的應用.

隨著時間過去與業務成長,需求也變複雜了.現在寄一封信這件事,有好幾種不同的做法:

  • 有些信走公司自己架的 SMTP server (省錢,但量大時容易塞車)
  • 行銷部門的大量電子報改用 SendGrid 這類第三方服務 (送達率高、有統計報表)
  • 跑在 AWS 上的系統則直接用 AWS SES (跟雲端環境整合得好、便宜)

同樣是寄信,但底層怎麼寄,已經有三種完全不同的方式了.那你會怎麼寫?很多人第一直覺會用以下的程式碼來做,用一個參數告訴程式要走哪一種,然後用 if/else 去判斷.

public class EmailService
{
    public void Send(EmailMessage message, string provider)
    {
        if (provider == "SMTP")
        {
            // 連到公司自架的 SMTP server
            var server = new SmtpServer("mail.mycompany.com");
            server.Connect();
            server.SendMail(message.To, message.Subject, message.Body);
            server.Disconnect();
        }
        else if (provider == "SendGrid")
        {
            // 呼叫 SendGrid 的 API
            var client = new SendGridClient("API_KEY_HERE");
            client.Post("/mail/send", message.To, message.Subject, message.Body);
        }
        else if (provider == "AWS_SES")
        {
            // 用 AWS SES 的 SDK
            var ses = new AmazonSimpleEmailServiceClient();
            ses.SendEmail(message.To, message.Subject, message.Body);
        }
        else
        {
            throw new ArgumentException("不支援的寄信方式");
        }
    }
}

這程式能跑,乍看之下也沒什麼大問題.但這種寫法會慢慢變成一場惡夢.

我們來想一下接下來會發生什麼事.

過了一段時間後,公司又要接一個新的寄信服務,比如說 Mailgun.你怎麼辦? 你只能把 Send() 這個 method 再打開,塞進一段新的 else if.再過半年又來一個,你又得打開它,再塞一段.這個 method 就像吹氣球一樣,越長越大,最後變成一坨幾百行、沒人敢碰的怪物.

這裡有幾個很實際的問題:

問題一:每次新增都要動到舊的程式碼.你只是想加一個新的寄信方式,卻被迫去改一個已經在正常運作的 method.每改一次,就有機會手滑改壞旁邊那些原本好好的邏輯.本來只想加東西,結果把別人的功能弄爆了,這種事在工程界天天上演.軟體設計裡有個很有名的原則叫做開放封閉原則 (Open-Closed Principle),講的就是 "對擴充開放,對修改封閉",理想的狀況是,要加新功能時用加新檔案的方式,而不是去改舊檔案.這個 if/else 的寫法剛好完全違反了這件事.

問題二:所有寄信方式全部攪在一起.SMTP 的連線邏輯、SendGrid 的 API 呼叫、AWS SES 的 SDK 操作,三套八竿子打不著的程式碼,硬是被塞在同一個 method 裡面.它們各自有各自的細節、各自的相依套件,現在全部纏在一起,光是讀都讀得很累.

問題三:根本沒辦法好好測試.還記得我們在前面的文章聊過 interface 跟單元測試的關係嗎?現在這個 Send() 你要怎麼測?你想測 SendGrid 那段邏輯有沒有正確組出參數,結果你一呼叫 Send(),它就真的去連 SMTP server 或真的去打 AWS 的 API 了.測試環境哪來這些東西? 這個 method 把決定用哪種方式 跟 實際去寄 死死綁在一起,導致它幾乎無法測試.

講到這裡,解法其實已經呼之欲出了.既然這些寄信方式做的是同一件事 (寄信),只是做法不同,那我們是不是可以幫它們設計一件共同的外衣? 此時又回答了 interface 的本質了.沒錯,這就是 interface 出場的時候了.我們先問自己一個問題:不管是 SMTP、SendGrid 還是 AWS SES,它們共同的行為是什麼?

答案很簡單:把一封信寄出去.就這樣.於是我們可以定義這樣一件外衣:

public interface IEmailSender
{
    void Send(EmailMessage message);
}

這件外衣只描述了一件事: 我會寄信,你給我一個 EmailMessage,我就把它送出去.至於是怎麼送的?外衣不管,那是各家自己的事.

接著,讓三種寄信方式各自去實作這件外衣,一種一個 class 如下所示

public class SmtpEmailSender : IEmailSender
{
    public void Send(EmailMessage message)
    {
        var server = new SmtpServer("mail.mycompany.com");
        server.Connect();
        server.SendMail(message.To, message.Subject, message.Body);
        server.Disconnect();
    }
}

public class SendGridEmailSender : IEmailSender
{
    public void Send(EmailMessage message)
    {
        var client = new SendGridClient("API_KEY_HERE");
        client.Post("/mail/send", message.To, message.Subject, message.Body);
    }
}

public class AwsSesEmailSender : IEmailSender
{
    public void Send(EmailMessage message)
    {
        var ses = new AmazonSimpleEmailServiceClient();
        ses.SendEmail(message.To, message.Subject, message.Body);
    }
}

看到差別了嗎?原本那一大坨 if/else,現在被拆成了三個各自獨立、乾乾淨淨的 class.每個 class 只專心做好自己那一種寄信方式,互不干擾.SMTP 的歸 SMTP,SendGrid 的歸 SendGrid,誰也不認識誰.

這裡的每一個 class,就是一個策略 (strategy).SmtpEmailSender 是一個策略,SendGridEmailSender 是另一個策略,AwsSesEmailSender 又是一個策略.它們穿著同一件外衣 (IEmailSender),但裡面各做各的.

外衣定義好了,策略也都實作好了,那真正使用它的主程式會變成如下:

public class EmailService
{
    private readonly IEmailSender _sender;

    public EmailService(IEmailSender sender)
    {
        _sender = sender;
    }

    public void SendWelcomeEmail(string userEmail)
    {
        var message = new EmailMessage
        {
            To = userEmail,
            Subject = "歡迎加入!",
            Body = "感謝你的註冊,很高興認識你..."
        };

        _sender.Send(message);   // 注意:EmailService 根本不知道背後是 SMTP 還是 SendGrid
    }
}

請你仔細盯著那行 _sender.Send(message) 看.EmailService 在做的事情,是準備好一封歡迎信,然後寄出去.它從頭到尾完全不知道這封信是透過 SMTP 寄的,還是 SendGrid 寄的,還是 AWS SES 寄的.它只認得 IEmailSender 這件外衣,知道這東西會寄信,這樣就夠了.

這就是把怎麼做從做什麼分離的真正意思:

  • EmailService 負責的是做什麼 -> 寄一封歡迎信
  • 各個 Sender 負責的是怎麼做 -> 用什麼管道、什麼技術去寄

這兩件事,被 IEmailSender 這件外衣俐落地切開了.

現在最爽的地方來了.如果哪天要換寄信方式,程式怎麼改?

// 開發環境,用公司的 SMTP 就好
var emailService = new EmailService(new SmtpEmailSender());

// 要改用 SendGrid?換一件外衣就好,EmailService 一個字都不用動
var emailService = new EmailService(new SendGridEmailSender());

// 改用 AWS SES?也是一樣
var emailService = new EmailService(new AwsSesEmailSender());

就這樣.你想用哪一種,就在建立 EmailService 的時候,丟一件對應的外衣給它穿上去.EmailService 本身的程式碼完全不需要改動.

而且,如果哪天又要接 Mailgun 呢?還記得前面 if/else 版本要把 method 打開來改嗎?現在你完全不用碰任何舊程式碼,只要新增一個 MailgunEmailSender class,讓它實作 IEmailSender 就好.舊的東西一個都不用動,這就是前面提到的 "對擴充開放,對修改封閉".

有沒有覺得這個換外衣的感覺,跟第一集很像? 第一集是各部門的信件內容穿上同一件外衣,讓寄信程式通吃.這一集是各種寄信方式穿上同一件外衣,讓主程式自由替換.同一個概念,一個套在資料上,一個套在行為上.這就是 interface 的威力,它不只能描述資料長什麼樣,更能描述一個東西會做什麼動作.

如果你有看第六集講依賴注入 (DI) 的那篇,看到上面那個建構式應該會會心一笑.沒錯,Strategy Pattern 跟 DI 根本是絕配.

EmailService 透過 constructor 接收一個 IEmailSender,這完全就是建構式注入 (Constructor Injection) 的標準做法.實務上你根本不會手動去 new 那些 sender,而是交給 DI 容器處理.例如在 ASP.NET Core 裡,你可以這樣設定:

// 整個專案要用哪種寄信策略,集中在這裡決定
builder.Services.AddScoped<IEmailSender, SendGridEmailSender>();

之後所有需要寄信的地方,只要在建構式裡寫上 IEmailSender,DI 容器就會自動把 SendGridEmailSender 注入進去.哪天要換策略? 改這一行就好,整個專案其他地方完全不用動.策略的 "選擇" 被收斂到了一個地方,乾淨又好管理.

看到這裡,比較敏銳的你可能會皺眉頭:「等等,你只是把那串 if/else 搬到別的地方去了吧? 總得有人決定要用哪個策略吧!」 問得好,這是個很重要的問題.答案是:對,那個判斷還在,但它被移到了一個正確的地方,而且只剩一個.

差別在哪?原本的 if/else 散落在核心的寄信邏輯裡面,每次寄信都要跑一次判斷,而且判斷跟實作攪在一起.現在如果你真的需要在 runtime 根據 configuration 來決定用哪個策略,你可以把這個判斷集中在一個地方,通常是程式啟動時的設定處或是一個專門的 factory,如下所示:

public IEmailSender CreateSender(string provider)
{
    return provider switch
    {
        "SMTP"     => new SmtpEmailSender(),
        "SendGrid" => new SendGridEmailSender(),
        "AWS_SES"  => new AwsSesEmailSender(),
        _          => throw new ArgumentException("不支援的寄信方式")
    };
}

關鍵的差異是:這個判斷現在只出現在一個地方,而且它只負責挑策略,挑完就退場了.真正在做事的 EmailService 依然乾乾淨淨,完全不受影響."選擇策略" 跟 "執行策略" 這兩件事被分開了,這才是重點.這跟原本那種每個 method 裡都重複一份判斷、判斷跟實作糾纏不清的狀況是天差地遠的兩回事.

學了一個新工具,最怕的就是看什麼都想用它.所以這裡也誠實講一下,什麼時候適合、什麼時候不適合.當你發現自己符合下面這些情況時,Strategy pattern 通常會幫上忙:

  • 同一件事有好幾種不同的做法,而且未來可能還會增加 (像寄信、付款方式、檔案壓縮格式、運費計算規則)
  • 你的程式裡開始出現越來越長的 if/else 或 switch,每個分支都是同一類事情的不同做法
  • 你希望這些做法可以隨時抽換,或是想針對每一種做法分別寫單元測試

這跟我在之前的文章談 ISP 時的提醒一樣,設計模式都是拿來解決問題的,不是拿來炫技的.先確定你真的有那個問題,再用對應的工具去解.為了用而用,往往比不用還糟.

Hope it helps!

Share:

#111 分散式系統的重播歷史 - 淺談事件溯源與 Write-Ahead Log (WAL)

過去幾篇文章 (例如分散式系統系列) 我們一直在討論 "多個節點之間怎麼達成共識" 這件事,從 Raft 到拜占庭容錯,主題都圍繞著「如何讓一群電腦對某件事達成一致」.但今天我想退一步,談一個更基礎的問題:在任何分散式系統的底層,都有一個更原始的需求,單一台機器怎麼確保自己寫進去的資料,不會因為突然斷電就消失?

這個問題的答案,從資料庫的 WAL (Write-Ahead Log) 出發,延伸出了事件溯源 (Event Sourcing)這個想法,最後甚至演化成了像 Kafka 這種影響整個業界的系統設計.這條技術演進的脈絡其實非常漂亮,今天我們就來把它連起來看.

程式當掉,資料怎麼辦?

我們先從一個場景談起.想像你在網銀界面操作轉帳,從你的帳戶轉 1000 元給朋友.後台的伺服器收到指令後,準備執行兩個動作:

  1. 把你的帳戶餘額從 5000 改成 4000
  2. 把朋友的帳戶餘額從 3000 改成 4000

如果在動作 1 和動作 2 之間,伺服器突然斷電了會怎樣? 你的錢沒了,但朋友也沒收到.這 1000 元就憑空消失了.

這是一個典型的「原子性 (atomicity)」問題.我們需要的是要嘛全部成功,要嘛全部失敗的保證.但現實的硬體不會給你這種保證 —— 寫入磁碟是一個 byte 一個 byte 寫的,斷電可能發生在任何時間點,而且系統重啟之後,我們甚至不知道斷電之前到底寫到了哪裡.

那資料庫是怎麼處理這個問題的呢? 答案就是 WAL.

WAL (Write-Ahead Log) 的核心想法

WAL 的概念講起來很簡單,但是非常聰明.它說:在你真的去改資料之前,先把你「打算要改什麼」這件事,寫到一個 log 檔案裡.

具體流程是這樣:

  1. 把「我要從 A 扣 1000,加到 B」這筆紀錄寫到 log 檔
  2. 確保 log 檔已經 flush 到磁碟 (也就是真的寫進去了,不只是停留在 OS 的 buffer 裡)
  3. 真正去修改 A 和 B 的餘額
  4. 修改完成後,標記這筆 log 為「已完成」

為什麼這樣做可以解決問題? 我們把斷電可能發生的時間點一個一個檢查:

  • 斷電發生在步驟 1 之前:什麼都還沒做,重啟後當作沒這回事.
  • 斷電發生在步驟 2 之前:log 還沒成功 flush 到磁碟,重啟後系統根本看不到這筆 log,也是當作沒這回事.使用者那邊會收到「轉帳失敗」的回應,可以重試.
  • 斷電發生在步驟 3 中間 (例如只扣了 A 還沒加到 B):重啟時系統會掃過 log,發現這筆操作的狀態是「已記錄、未完成」,於是把它接著做完.這個動作叫做 recovery
  • 斷電發生在步驟 4 之前 (A 和 B 其實已經改好了,但還沒標記 log 為完成):這時候 recovery 該怎麼辦? 它不能無腦地「再做一次」,否則 A 就會被多扣一次錢.關鍵在於每筆資料的修改都會附帶一個編號 (例如 Log Sequence Number),recovery 時系統會比對「資料目前的編號」跟「log 裡的編號」,如果發現資料其實已經套用過了,就只需要補上「標記為完成」這個動作,不會重做扣款.

這個機制的核心精神是:先把意圖寫下來,再去執行.只要意圖 (也就是 log) 保存住了,即使執行中途出問題,下次開機都可以根據 log 跟資料的對照,把系統修復到一個一致的狀態.這個事先寫下的 log 就是 Write-Ahead Log.

順帶提一個容易被忽略的技術細節:上面提到的「flush 到磁碟」,對應到作業系統其實是 fsync 這個系統呼叫.普通的 write() 呼叫只是把資料交給 OS 的 buffer,遇到斷電仍然會丟失;只有 fsync 完成後,儲存系統才會真正承諾資料已經 durable.這也是整個 transaction commit 流程中最昂貴的一步.現代資料庫常會用 group commit 的技巧,把短時間內多筆 transaction 的 fsync 合併成一次 (你想想,5 筆 transaction 各自 fsync 5 次,跟一批一起 fsync 1 次,後者快多了),來平攤 durability 的成本.這也是 throughput 與 durability latency 之間經典的 tradeoff.

幾乎所有主流的資料庫都用了 WAL 的概念.PostgreSQL 直接就叫做 WAL,MySQL InnoDB 把它叫做 redo log,SQLite 也有 WAL mode 可以開.這已經是資料庫工程師的標配技術.

如果我們只保留 log 呢?

讀到這邊你可能會發現一個有趣的事實:WAL 裡其實已經包含了重建資料需要的所有資訊.如果我把所有的 log 都保留下來,從第一筆 log 開始一條一條重新執行,理論上我就能算出當前的資料狀態.

換句話說:當前的資料狀態,只是所有歷史操作累積起來的結果.

這句話聽起來很簡單,但它藏著一個非常深刻的觀念翻轉:真正 durable 的東西不是當前狀態,而是歷史,當前狀態只是歷史重播到此刻為止的快照而已.若用一個程式來表達,就像下面:

state = replay(history)

這個視角的翻轉是接下來幾個技術 (事件溯源、Kafka、甚至我們系列前面講過的 Raft) 的共同基礎.先把這句話放心上,後面會反覆看到它.

事件溯源 (Event Sourcing)

事件溯源 (Event Sourcing) 的核心思想就是:不要儲存當前的狀態,是儲存所有改變狀態的事件.當你需要當前狀態時,把所有事件從頭重播一次來計算出來.

我們用一個具體的例子來比較一下傳統 CRUD 跟事件溯源的差異.假設我們要做一個線上購物車的功能.

傳統 CRUD 的做法:資料庫裡有個購物車表格,每個使用者一筆紀錄,紀錄了目前購物車有什麼商品.使用者加商品、改數量、刪商品,都是直接 UPDATE 這筆紀錄.歷史資訊隨著每次 UPDATE 就消失了.

事件溯源的做法:資料庫裡只有一個 events 表,記錄一連串的事件:

  • 9:00 使用者 A 把商品 X 加入購物車
  • 9:05 使用者 A 把商品 X 的數量改成 2
  • 9:10 使用者 A 把商品 Y 加入購物車
  • 9:15 使用者 A 把商品 X 從購物車移除

要知道使用者 A 目前的購物車有什麼? 把上面這些事件依序重播,就能算出當前的狀態.注意這跟 WAL 在概念上完全一樣,只是 WAL 的 log 是給資料庫內部用的,而事件溯源的 log 是業務層級的事件.

事件溯源帶來幾個好處:

1. 完整的歷史紀錄:你能清楚知道任何時間點的狀態,因為所有變化都被記下來了.什麼時候被改的、被誰改的、改之前是什麼、改之後是什麼,全部一目了然.這對於需要嚴格審計的場景 (金融、醫療) 來說是極大的優勢.

2. 時光旅行 (time travel):想知道昨天下午三點的購物車是什麼狀態? 把昨天下午三點以前的事件重播一次就好.這在除錯或回溯資料時超級好用.

3. 多種視角:同一份事件流,可以重播出不同的視圖.例如同樣的訂單事件,可以重播出「使用者的訂單清單」、「商品的銷售統計」、「物流的出貨清單」等不同的資料表,給不同的功能使用.這個觀念在業界有個專有名詞叫做 CQRS (Command Query Responsibility Segregation),常常跟事件溯源搭配使用.

但事件溯源也有它的代價,這些代價在 production 系統裡常常被低估.

Schema 演進的長期負擔:events 一旦寫下去就是 immutable,所以五年前定義的 event schema,五年後系統還是得能讀懂它.這代表你必須長期維護 backward compatibility、做 event versioning、規劃 migration 策略.新人加入專案時,也得理解所有歷史版本的 schema.這在大型系統裡是一個持續性的維護負擔,常常被低估.

重播成本與 snapshot 機制:如果系統累積了上億筆 events,每次重建狀態都從第一筆開始重播是不切實際的.Production 系統幾乎一定要搭配 snapshot 機制 (定期把計算好的狀態存下來,下次重播只要從最近的 snapshot 開始接著算就好).這帶來額外的複雜度:snapshot 多久做一次? 怎麼確保 snapshot 跟 event log 之間的一致性? 這些都要好好設計.

CQRS 的最終一致性:事件溯源常常搭配 CQRS 一起用,意思是 寫 跟 讀 走不同的資料模型.寫進去的是 events,讀的時候則是從另一份 projection table 讀.這兩邊通常是 async 同步的,導致使用者可能會以為 我剛改了,但讀出來還是舊的 這種狀況.要處理 stale read、ordering、retry semantics 等等問題,整個系統的複雜度遠高於傳統 CRUD.

思維轉換的成本:習慣 CRUD 的工程師要花一段時間適應這種思維.事件一旦寫下去就不能修改 (要改的話只能再寫一筆「補正事件」),這對很多人來說是反直覺的.

從單機到分散式:Kafka 的登場

到目前為止我們講的 WAL 和事件溯源,都還是單機的概念.但這套以 log 為核心的思想,放到分散式系統裡有更大的威力.

設想這個場景:你有一個電商系統,使用者下訂單之後,需要做以下這些事情:

  • 通知庫存系統扣庫存
  • 通知物流系統準備出貨
  • 通知會計系統記帳
  • 通知通知系統發 email 給使用者
  • 通知數據分析系統更新統計

如果這些都直接呼叫對方的 API (同步呼叫),會有很多問題.任何一個系統慢,整個下單流程都會被拖累.任何一個系統當掉,下單就失敗了.新增一個訂閱者 (例如新增一個推薦系統),就要去改下單系統的程式碼.萬一某個系統突然 traffic 暴增處理不來,訊息可能就遺失了.

如果我們把事件溯源的思想套到這個情境呢? 訂單系統不直接呼叫別人,而是把「訂單成立了」這件事當作一個事件,發布到某個地方.其他系統就各自從這個地方讀取事件,自己處理.

這就是 Kafka 的設計核心.Kafka 本質上就是一個分散式的 commit log.

Kafka 為什麼長這樣

Kafka 的核心抽象其實非常簡單:

  • 生產者 (Producer) 把訊息 append 到一個 log 裡
  • 消費者 (Consumer) 從 log 裡讀訊息
  • log 本身按順序儲存,每個訊息有一個 offset (位置編號)
  • 訊息不會被刪除 (除非過期),所以可以被反覆讀取與重播

注意這個結構跟資料庫 WAL 的概念有多像! 差別只是:

  • WAL 是給單一資料庫內部用的,目的是 crash recovery
  • Kafka 是給多個獨立系統共用的,目的是 decouple 系統

但 append-only log 這個本質完全一樣.這也是為什麼 Kafka 在學術上常被稱為 distributed commit log.

Kafka 的這種設計帶來幾個很厲害的特性:

1. 多消費者獨立進度:這是 Kafka 設計上最關鍵的特色之一,跟傳統訊息佇列 (像是 RabbitMQ) 有本質上的差異.我們用前面電商下單的情境來具體比較.假設訂單系統發出一筆「訂單成立」的訊息,庫存、物流、會計、通知四個下游系統都需要處理這筆訊息.

傳統訊息佇列的做法是:訊息放進佇列裡,誰來「取」訊息誰就拿到,拿到之後訊息就從佇列裡消失了.那如果四個系統都要看到這筆訊息怎麼辦? 通常需要設計成「多個佇列」或 pub/sub 模式 —— 訂單系統要把同一筆訊息複製成四份,分別塞到四個佇列裡.如果哪天又新增第五個系統 (例如風控),訂單系統的程式碼就要再改一次.

Kafka 的做法則不一樣:訊息只寫一次,存在 log 裡.每個消費者各自記錄自己讀到哪裡了 (這個位置叫做 offset,可以理解成「書籤」).庫存系統的書籤在第 100 筆,物流系統的書籤在第 95 筆,會計系統因為比較慢還在第 80 筆,互不影響.新增風控系統? 直接讓它從第 0 筆開始讀就好,訂單系統的程式碼一行都不用改.

這就是 被讀 與 被拿走 的差別.Kafka 的訊息像是貼在公佈欄上的公告,誰都可以走過去看,每個人記住自己看到哪一則就好.傳統佇列的訊息則像是發給特定收件人的信,拿走就沒了.

2. 重播能力:因為訊息不會被刪掉,新加入的消費者可以從頭開始重播所有歷史.想新增一個資料分析的下游? 直接從第 0 筆 offset 開始讀就好,連歷史資料都自動補齊了.這簡直就是事件溯源在分散式系統上的完美實踐.

3. 自然的解耦:生產者只管把事件 append 進去,不需要知道有誰會讀.消費者要訂閱新主題就自己加,生產者完全不用改.這在大型系統的演化上是巨大的優勢.

4. 順序保證:因為是 append-only,同一個 partition 內的訊息順序是保證的.這對很多業務邏輯 (例如「先下訂單再付款」這種因果關係) 很重要.

Kafka 的原始作者之一 Jay Kreps 在他著名的文章 The Log: What every software engineer should know about real-time data's unifying abstraction 裡,把這個觀察講得非常透徹:log 不只是資料庫的內部實作細節,它其實是一個非常 fundamental 的抽象,可以用來統一處理整個分散式系統的資料流.這篇文章強烈推薦讀一下.

回到系列開頭:Raft 也是一個 log 系統

看到這裡,如果你有讀過本系列前面講過的 Raft 文章,可能會有一種熟悉感.Raft 是用來在多個節點之間達成共識的演算法.但有沒有想過 Raft 到底是在「對什麼東西達成共識」呢?

答案是:log 的順序.

Raft 的每個節點內部都維護一份 log,這份 log 記錄了所有要執行的操作 (例如「把 x 設成 5」、「把 y 加 1」).Leader 收到 client 的請求後,會把這個操作 append 到自己的 log,然後複製給所有 Follower.當大多數節點都 ack 之後,這筆 log 才被視為 committed.接著每個節點各自按照 log 的順序執行操作,計算出自己手上的 state.

注意這個流程跟單機 WAL 有多像! 差別只在於:

  • WAL 是一台機器內部的事,保證的是 crash recovery
  • Raft 把同樣的 log 概念複製到多台機器上,保證的是跨節點的 state consistency

更精準地說:Raft 真正在解決的問題並不是「選 Leader」,而是「讓所有節點對 log 的順序達成一致」.只要所有節點看到相同順序的 log,按照同樣的順序重播,最後算出來的 state 就一定一致.這個架構在文獻上有個正式名稱,叫做 State Machine Replication.

所以你會發現,整個系列文章其實一直在繞著同一個核心概念走:

  • 單機 WAL:在一台機器上保證有序、可持久化的歷史
  • Event Sourcing:把這個概念抬到應用層,以業務事件的形式記錄歷史
  • Kafka:把 log 變成跨系統共用的基礎設施
  • Raft:讓多個節點對 log 的順序達成共識

看似毫不相干的四個技術,骨子裡都是同一個抽象:append-only ordered log.差別只在於它們處理「有序性」的範圍,WAL 處理一台機器內部的有序性,Raft 處理多台機器之間的有序性.這也是為什麼 distributed systems 領域有一句很有名的話:分散式系統的本質問題不是「儲存」,而是「ordering」.當你能保證所有節點看到相同順序的事件,後面所有事情都好辦.

整理一下

我們把 WAL、事件溯源、Kafka、Raft 這四個概念放在一起比較:

概念 抽象層級 解決的問題 誰會用
WAL 資料庫內部 單機 crash recovery 幾乎所有現代資料庫
Event Sourcing 應用層 保留完整歷史、可重播狀態 需強審計性的系統 (金融、醫療)
Kafka 分散式系統基礎設施 系統解耦、跨系統事件流 大型分散式架構
Raft 分散式共識 多節點對 log 順序達成一致 需要強一致性的分散式系統 (etcd, CockroachDB)

四者的層級不同,但底下的核心思想是同一個:用 append-only ordered log 取代直接修改狀態.

從 WAL、Event Sourcing、Kafka 到 Raft,這條技術演進的脈絡其實在反覆強化同一個觀念:真正 durable 的不是當前狀態,而是歷史,當前狀態只是歷史的 cache (state = replay(history)).這四個技術名字完全不同、出現在系統的不同層級,但骨子裡都是同一個抽象 —— append-only ordered log;前幾篇談的 "多節點達成共識" 與今天談的 "單節點資料不遺失" 其實只是同一個核心抽象在不同層級的展現,前者讓多個節點對 log 順序達成一致,後者確保每個節點都能可靠地重播歷史.當然這種設計也有它的代價 (儲存成本、查詢複雜度、schema 演進負擔),不是每個系統都需要,這跟上一篇拜占庭容錯文章的結論一樣,工程的世界沒有銀彈,理解每種設計背後的思維與取捨,才能在實際工作中做出正確的選擇.

Help it helps

Share:

#110 分散式系統 - 淺談拜占庭容錯

上一篇文章 (#103 分散式系統的「開會」藝術 - 淺談共識演算法) 裡,我們聊到了 Raft 這一類的共識演算法.我們知道,分散式系統最頭痛的問題之一就是「節點會故障」,而 Raft 就是用來處理「當一群節點中有部分節點掛掉時,剩下的節點如何達成一致的決議」這樣的問題.如果你看過那一篇文章,你應該會對 Raft 的設計留下不錯的印象,畢竟它真的優雅而且實用.

不過,今天這篇文章我想要戳破一個 Raft 沒有處理的盲點.這個盲點在某些應用場景裡是天大的事情,特別是當我們把分散式系統用在生命攸關的場合,例如飛機的飛行控制系統.這個盲點就是:節點不一定都是誠實的

Raft 沒處理到的問題

我們先回顧一下 Raft 處理的故障模型 (failure model).Raft 假設一個節點如果出了事,它的表現方式就是「停下來」,可能是當機,可能是斷網,可能是處理速度慢到像沒在動.專業上我們稱這種故障型態為 crash failure 或是 fail-stop failure

換句話說,Raft 對節點有一個基本的假設:只要這個節點還活著,它一定會誠實地按照協議來辦事.Raft 不會懷疑你傳過來的訊息是真是假,因為它假設你不會說謊.

但是,現實世界並沒有這麼天真.節點可能會「壞得很奇怪」,例如硬體出錯導致記憶體裡的數值被偷偷改掉.軟體有 bug 導致它送出錯誤但格式正確的資料.網路設備故障導致同一個訊息被改成兩個不同的版本送給不同的接收者.甚至更糟的,節點被惡意入侵後故意搞破壞.

想像一個情境:你有一個五個節點組成的 Raft 集群,其中一個節點故障了,但它沒有當機,反而還活得好好的,更糟的是,它會傳出錯誤但看起來合法的訊息.對 A 節點說「我的日誌是 X」,對 B 節點說「我的日誌是 Y」.這時候 Raft 就完全沒招了.Raft 的設計裡,誰選上 Leader 大家就聽誰的,Follower 不會去質疑 Leader 給的指令是否合理.如果 Leader 自己就是出問題的那個節點,它可以隨意指派錯誤的日誌給每個 Follower,整個集群的狀態就會變得一塌糊塗.

這種「節點不只會壞,還會用各種奇怪的方式壞」的故障型態,我們稱為 拜占庭故障 (Byzantine Failure),能夠在這種情況下還可以正常運作的能力,就叫做 拜占庭容錯 (Byzantine Fault Tolerance, BFT)

拜占庭將軍問題

「拜占庭」這個名字聽起來很有文學氣息,其實它來自一個有名的故事.這個故事是由 Leslie Lamport 等人在 1982 年提出的,論文標題為 The Byzantine Generals Problem,有興趣的讀者可以直接讀原文.

故事的內容是這樣:拜占庭帝國的軍隊要圍攻一座城市,幾個將軍各自帶著部隊駐紮在城市周圍不同的方位.將軍們必須要協調出一個一致的行動:要嘛大家一起進攻,要嘛大家一起撤退.如果只有一部分將軍進攻,沒有得到其他人的支援,那一定會被殲滅.

問題來了:將軍之間只能透過信差來傳遞訊息.更糟的是,將軍裡面也可能藏有叛徒,他可能對 A 將軍說「進攻」,對 B 將軍說「撤退」,藉此讓盟軍亂成一團.

請問:在這樣的情況下,誠實的將軍們有辦法達成一致的決議嗎? 我們先用一個最小的例子來看看這個問題有多麻煩.

三個將軍的故事 (為什麼會行不通)

假設我們只有三個將軍:將軍 A (司令)、將軍 B、將軍 C.我們的目標是「即使其中有一個是叛徒,誠實的將軍仍然能達成一致的行動」.先看看會發生什麼事.

情境一:司令 A 是叛徒

叛徒司令要搞破壞,於是他發出兩個矛盾的命令:

  • A 對 B 說:「進攻」
  • A 對 C 說:「撤退」

B 和 C 都是誠實的,但他們各自收到不同的命令,於是他們互相通報自己收到了什麼:

  • B 告訴 C:「司令叫我進攻」
  • C 告訴 B:「司令叫我撤退」

現在站在 B 的視角看:「司令叫我進攻,但 C 卻說司令叫他撤退.兩個訊息不一致,到底誰在說謊呢? 是司令騙我? 還是 C 騙我?」B 完全沒辦法判斷.

情境二:司令 A 是誠實的,但 C 是叛徒

誠實的司令發出一致的命令:

  • A 對 B 說:「進攻」
  • A 對 C 說:「進攻」

叛徒 C 收到「進攻」之後決定搞破壞,於是他對 B 說謊:

  • B 告訴 C:「司令叫我進攻」 (誠實的)
  • C 告訴 B:「司令叫我撤退」 (撒謊)

站在 B 的視角看:「司令叫我進攻,但 C 卻說司令叫他撤退.兩個訊息不一致,到底誰在說謊呢? 是司令騙我? 還是 C 騙我?」

關鍵來了:請仔細比較這兩個情境,B 在這兩個情境裡看到的訊息是一模一樣的! 「司令說進攻,C 說司令叫他撤退」.但這兩種情境的真相完全不同:一個是司令該被排除,一個是 C 該被排除.B 沒有任何辦法分辨自己處於哪一種情境,所以他無法做出正確的判斷.

這就是為什麼 3 個節點處理不了 1 個叛徒.不論你設計什麼樣聰明的協議,只要叛徒能巧妙地說謊,誠實的節點就會被資訊不對稱搞得無所適從.

四個將軍可以怎麼解決

那如果我們把將軍的數量增加到 4 個呢? 假設只有 1 個叛徒,狀況立刻不一樣了.

假設將軍 A、B、C、D,且只有 D 是叛徒.司令 A 把「進攻」這個命令傳給 B、C、D.然後 B、C、D 之間互相通報自己收到的命令:

B 收到的訊息:

  • 司令 A 說:進攻
  • C 說:「司令對我說的是進攻」
  • D 說:「司令對我說的是撤退」 (叛徒在說謊)

B 看到三個訊息:進攻、進攻、撤退.用 多數決,「進攻」勝出,B 就決定進攻.C 也會看到類似的結果.這樣 B 和 C 兩個誠實的將軍就達成了一致.不論 D 怎麼搞破壞,他都無法讓 B 和 C 的決定不一致.

這個簡單的例子告訴我們一件事:拜占庭問題的解法核心,在於「同一個訊息要被多個來源確認,再用多數決排除說謊者」.這聽起來很直覺,但 Lamport 等人在他們的論文裡正式證明了一個更普遍的結論.

BFT 的數學門檻:3f+1

Lamport 的論文證明了一個很重要的結論:如果系統中可能有 f 個惡意節點,那麼總節點數至少要有 3f+1 個,才有可能達成共識

換句話說,要容忍 1 個惡意節點,至少要 4 個節點 (這就是上面那個四將軍的例子).要容忍 2 個惡意節點,至少要 7 個節點.要容忍 3 個惡意節點,至少要 10 個節點.這個條件比 Raft 嚴格很多,Raft 處理 crash failure 只需要 2f+1 個節點 (容忍 1 個故障節點只要 3 台就夠).

為什麼一定要 3f+1 ? 我們可以這樣直觀地理解:我們需要排除掉 f 個惡意節點的影響,所以實際在做決議的「可信節點」至少要有 2f+1 個 (這樣即使有 f 個惡意節點混進來投票,誠實節點仍然會是多數).加上原本可能有的 f 個惡意節點,總節點數最少就是 3f+1.

除了節點數的要求,BFT 演算法通常還需要更多輪的訊息傳遞才能確認結果.直觀上來想,每個節點不只要把自己的意見講出來,還要互相確認彼此聽到的內容是不是一致.這個「互相確認」的動作會讓訊息量大幅增加.

真實世界的 BFT:波音 777 的飛行控制系統

講到這裡你可能會想:這聽起來很學術,現實世界真的有人在用嗎? 答案是,當系統的失敗代價是人命的時候,BFT 就是必備的.接下來我們花一點時間仔細看一個經典範例:波音 777 的飛行控制系統.這是 BFT 在工業界最具代表性的應用之一,理解它,你就會看清楚 BFT 不只是學術概念,而是真的在保護人命的技術.

飛機其實也是個分散式系統

當你坐上一架 Boeing 777,你大概不會意識到,這架飛機上其實跑著一個非常複雜的分散式系統.飛機要穩定飛行,需要無數個感測器、致動器 (actuator)、和電腦彼此協調合作.感測器告訴電腦目前的姿態、速度、高度,飛行員給出操作指令,電腦做出計算,再把命令送到致動器來控制副翼、方向舵、升降舵等.這整個迴圈每秒要跑很多次,而且任何一個環節出錯都可能致命.

問題來了:要怎麼確保這個系統「絕對不會出錯」? 答案是不可能.硬體一定會故障,軟體一定會有 bug.我們要做的不是消除故障,而是即使故障發生了,飛機仍然能正常運作.這就是「容錯 (fault tolerance)」的概念.

三重三重備援的飛行電腦

波音 777 的飛行控制系統採用了一個叫做「triple-triple redundant」的架構,意思是:

  • 主飛行電腦 (Primary Flight Computer, PFC) 一共有 3 個獨立通道
  • 每個通道內部又有 3 條獨立的運算管線 (computing lane).
  • 加起來總共有 9 個運算單元同時計算同一件事情.

更有趣的是,每條運算管線使用的微處理器是「dissimilar」的,也就是來自不同廠商、不同架構的晶片.為什麼要這樣做? 因為如果三條管線都用同一款 CPU,萬一這款 CPU 有設計上的瑕疵,那三條管線會同時算出同一個錯誤答案,多重備援就完全失效了.用不同廠牌的 CPU,可以避免這種「集體犯同一個錯」的風險.

這個設計很厲害,但還沒到 BFT.真正讓它變成 BFT 的,是把這些運算單元連起來的那條匯流排:SAFEbus

關鍵問題:硬體不只會「壞掉」,還會「半死不活」

在進入 SAFEbus 的細節之前,我要先讓你理解一個非常重要的觀念,這個觀念就是 BFT 在飛機上不可或缺的根本原因.

我們設想一個非常具體的情境:飛行電腦的某個運算單元 X 算出來的飛行高度是 30,000 英呎.這個訊息要透過某個傳輸晶片送給其他電腦 B、C、D 來做後續比對.

如果這個傳輸晶片完全正常,事情很簡單:B、C、D 都收到 30,000,大家一致.

如果這個傳輸晶片完全壞掉 (例如電源沒了),事情也好辦:B、C、D 都收不到任何訊息,大家可以馬上判斷它故障了,然後把它隔離.這種就是前面提到的 crash failure,是 Raft 那種演算法可以處理的.

但是,半導體元件的世界裡還有第三種狀態,那就是「半死不活」.這才是飛機上真正棘手的問題.

我來舉一個具體的例子.數位電路用電壓來表示 0 和 1,例如「0V 代表 0」、「5V 代表 1」.接收端會設定一個判斷閾值 (例如 2.5V),低於閾值算 0,高於閾值算 1.

當一個輸出電路因為老化、輻射打到、或是電源不穩,導致它輸出的電壓飄到了中間值 (例如 2.4V),會發生什麼事?

  • 對接收端 B 而言,它的判斷閾值是 2.5V,所以 2.4V 被判讀成「0」.
  • 對接收端 C 而言,它的閾值因為製造誤差稍微低一點,是 2.3V,所以同樣的 2.4V 被判讀成「1」.

請仔細看:同一個發送端,送出同一個訊號,但是不同的接收端讀到了不同的內容.這個情境是不是很眼熟? 沒錯,這就是我們前面講過的「拜占庭叛徒」! 一個壞掉的晶片,就像是一個對不同人說不同話的叛徒將軍.對 B 說的高度是 0,對 C 說的高度卻是某個截然不同的值.

這個現象在飛機上有很多來源:宇宙射線打到記憶體可能讓某個 bit 翻轉.閃電打到飛機可能讓電路產生暫時的雜訊.焊點老化可能讓訊號變得不穩定.溫度變化可能讓元件特性改變.這些故障的共同特徵就是「電腦沒當掉,但它送出來的資料對不同的接收者來說會不一致」.如果系統不處理這種拜占庭故障,後果可能是不同的副飛行電腦根據不同的高度判斷做出不同的控制決策,飛機就完蛋了.

這也是為什麼普通的故障容錯不夠用,飛機必須要有 BFT

SAFEbus 怎麼解決這個問題

SAFEbus (正式名稱為 ARINC 659) 是 Honeywell 設計的一個匯流排,它把「四將軍解法」直接做進了硬體裡.關鍵設計如下:

  • 每個運算單元連到 SAFEbus 的介面 (Bus Interface Unit, BIU) 是雙重備援
  • SAFEbus 本身有四條完全獨立的資料通道
  • 當一個訊息要傳送出去時,它會被同時送到這四條通道上.
  • 接收端會收到四份副本,然後用多數決來決定真正的訊息內容.

回想一下我們前面講的「四將軍解法」:B 收到了司令、C、D 三個來源的訊息,用多數決排除說謊的那一個.SAFEbus 的設計理念完全一樣,只是把「將軍」換成了「資料通道」,把「叛徒」換成了「壞掉的硬體」.就算其中一條通道因為前面講的「半死不活」原因傳出怪訊息,剩下三條通道仍然會給出正確的結果,多數決就能把那條壞通道的影響排除掉.

而且 SAFEbus 厲害的地方是,這整個比對和投票的過程加起來只增加大約一微秒的延遲.對於要做即時飛行控制的系統來說,這是非常驚人的成就,這也是為什麼 BFT 能夠真的用在飛機這種對即時性要求極高的場景.有關以上 SAFEbus 的內容是簡化後的說明,我並不是航空方面的專家,所以無法解釋的太細節.

為什麼要這麼大費周章

可能你會想:飛機上裝這麼多備援是不是太誇張了? 答案藏在波音給 777 設計的目標裡.波音要求 777 的飛行控制系統「災難性故障」的機率要低於 10⁻¹⁰ (每飛行小時).這個數字翻成人話就是:每 100 億飛行小時才允許出一次嚴重故障.要達到這個等級,沒有 BFT 就辦不到.

所以下次你坐 777 的時候,可以稍微回想一下:你頭頂上的設備櫃裡,正有 9 個獨立的運算單元,透過 4 條獨立的資料通道,互相用拜占庭容錯協議在「投票」,確保飛機正常運作.Lamport 那篇 1982 年的論文,就在你的頭上即時運行著.

經典的 BFT 演算法:PBFT

除了像 SAFEbus 這種專為航空設計的硬體層 BFT 之外,BFT 在軟體層也有經典的演算法.最有名的當屬 1999 年由 Castro 和 Liskov 提出的 PBFT (Practical Byzantine Fault Tolerance).它把原本停留在理論階段的 BFT 概念,第一次做到了一個實用、可以真的部署的程度,這也是 "Practical" 這個字的由來.

PBFT 的核心想法簡化來說是:每一個指令都要經過多輪的投票確認,每個節點都要簽名表態自己看到了什麼,最後比對大家的簽名才能確認結果.這樣即使有惡意節點對不同的人傳不同的訊息,誠實的節點之間互相對照後,馬上就能發現不一致而排除這個叛徒.這個原理跟我們前面四將軍範例的想法是一致的.

PBFT 的特性大致如下:它仍然是一個有 Leader 的演算法 (在 PBFT 裡稱為 Primary),但所有的決議都需要 2f+1 個節點同意才算數.每一輪共識需要 O(n²) 次的訊息傳遞,因為每個節點都要把自己的表態廣播給所有其他節點.這個特性讓它適合節點數較少 (幾十個以內) 的封閉系統.也許未來可以再寫另一篇文章來說明 PBFT 演算法.

BFT 的代價

看到這裡,你可能會問:如果 BFT 這麼強,為什麼不每個分散式系統都用 BFT 就好? 答案是成本.我們把 Raft 和 BFT 做個簡單的比較:

項目 Raft (Crash Fault Tolerance) PBFT (Byzantine Fault Tolerance)
處理的故障 節點停止運作 節點停止運作 + 節點傳出錯誤訊息
容忍 f 個故障所需節點數 2f+1 3f+1
每輪訊息複雜度 O(n) O(n²)
典型應用 etcd, Consul, 一般分散式資料庫 飛行控制系統, 跨組織信任系統

BFT 需要的節點數比較多,訊息傳遞量也大很多.對於一般的應用 (例如公司內部的分散式資料庫),這樣的成本是浪費的,因為公司內部的伺服器不會故意說謊.但對於航空這類場景,BFT 的成本是必要的,因為失敗的代價遠遠高過運作的代價.

結語

Fault tolerance 是以前我在念書時相當感興趣的題目,不論是碩班學的分散式系統,或是博班時修的分散式演算法,我都是在這領域找題目來期末專題,甚至包含在選擇博班的研究題目時,也是在這出發尋找相關應用.從 Raft 到 PBFT,這個演進的脈絡其實反映了分散式系統設計者對「信任」這件事的不同假設.Raft 假設所有節點都是誠實的,只是可能會故障.PBFT 假設大多數節點是誠實的,但容許少數節點出現各種奇怪的故障,包含說謊.不同的假設,導致了完全不同的設計與成本.

每一種設計都有它適合的場景.如果你是在公司內部建一個分散式資料庫,Raft 就夠用了,沒必要花更高的成本去處理拜占庭問題.如果你是在做一個生命攸關的系統,那 BFT 就是必要的選擇.

工程的世界沒有銀彈,理解每個工具背後的假設與代價,比追求最 "潮" 的技術重要許多.這也是為什麼學分散式系統的人會說「先搞清楚你的故障模型」,因為一旦故障模型搞錯了,後面的所有設計都會出問題.

Hope it helps,

Share: