01

故障與部分失效

單機的世界非黑即白,分散式系統卻活在一團灰色地帶——歡迎來到麻煩真正開始的地方

單機很乾脆:要嘛能跑,要嘛不能跑

在一台電腦上寫程式,行為通常很好預測。硬體正常時,同樣的操作永遠產生同樣的結果,這叫 確定性(deterministic)。 這其實是刻意設計出來的:一旦內部出錯,電腦寧可直接當掉(想想那個藍白當機畫面),也不要回傳一個看起來正常、其實是錯的結果——因為錯誤的結果遠比完全停擺更難處理

但當軟體跑在多台用網路連起來的電腦上,這個理想模型就破滅了。你不再活在數學般完美的世界裡,必須面對物理現實的混亂:電力可能忽然中斷、機櫃可能被誤觸斷電、甚至真的發生過有人開車撞進機房的空調系統。

📦
把它想成「寄包裹給遠方的隊友」

單機程式像你自己在房間裡做事:手伸出去拿杯子,拿到就是拿到,沒拿到就是沒拿到,一清二楚。分散式系統則像你寄一份重要文件給遠方隊友、請他簽完寄回——信可能根本沒送到、可能塞在倉庫延遲、隊友可能搬家聯絡不上、也可能簽好了但回信在路上弄丟。你坐在家裡,光憑「還沒收到回信」,完全分不出是哪一種情況。

當系統中有些部分用不可預期的方式壞掉、其他部分卻運作良好,這種現象就叫 部分失效(partial failure)。 最麻煩的地方在於它是非確定性的:只要牽涉到多個節點與網路,同一件事有時成功、有時莫名其妙失敗,你甚至無法確知它到底成功了沒——因為訊息在網路上跑的時間本身就無法預測。

直接讀原文:部分失效是什麼

這幾句話,是整章、甚至整個「分散式系統很難搞」主題的起點。原文很平實,但每個字都是重點。

原文 · DDIA Ch.8 In a distributed system, there may well be some parts of the system that are broken in some unpredictable way, even though other parts of the system are working fine. This is known as a partial failure. The difficulty is that partial failures are nondeterministic: if you try to do anything involving multiple nodes and the network, it may sometimes work and sometimes unpredictably fail. You may not even know whether something succeeded or not, as the time it takes for a message to travel across a network is also nondeterministic! This nondeterminism and possibility of partial failures is what makes distributed systems hard to work with.
白話翻譯

在分散式系統裡,系統中某些部分很可能用一種無法預期的方式壞掉了,而其他部分卻運作良好。

這種現象就叫做「部分失效」。

難就難在部分失效是非確定性的:只要牽涉到多個節點與網路,同一件事有時能成功、有時莫名其妙就失敗。

你甚至可能連「這件事到底成功了沒」都不知道——因為訊息在網路上跑的時間,本身也是無法預測的!

正是這種非確定性、以及部分失效隨時可能發生的特質,讓分散式系統變得這麼難搞。

🎯
記住這句金句

部分失效不是「會不會發生」的問題,而是「什麼時候發生」的問題——而且發生時,你連它有沒有發生都不一定確定。這正是本章接下來要一路拆解的核心難題。

兩種哲學:一壞就全停,還是撐著繼續跑?

面對「東西總會壞」這個事實,工程史上出現過兩種完全相反的應對哲學。書裡用超級電腦和雲端運算這兩個極端,把差異拉得很清楚。

🖥️
超級電腦:一壞就全停

用昂貴、可靠的專用硬體,定期把運算狀態存成 checkpoint(檢查點)。 只要有一個節點壞掉,就讓整個叢集停下來,修好之後從上一個 checkpoint 重新跑。它把「部分失效」直接升級成「整體失效」——本質上更像一台放大的單機,而不是真正的分散式系統。

☁️
雲端運算:撐著繼續跑

用便宜、故障率較高的 商用機器(commodity machine) 組成叢集,假設任何時刻都有某些零件正在故障。系統從設計之初就要能撐過「一部分」壞掉,服務不能因為修東西而整體離線——因為使用者要求全天候低延遲。

⚖️
怎麼選?看你能不能停機

天氣模擬這類離線批次工作可以停下重跑,衝擊不大;但線上交易系統、電商網站不能說「抱歉,我們正在修,請等一下」。規模愈大,「隨時有東西正在壞」的機率愈高,也愈不能採用「一壞就全停」的策略——這正是為什麼網路服務寧可把容錯寫進軟體,也要換來 滾動升級(rolling upgrade) 這種「一次只換一顆螺絲、機器照常運轉」的能力。

親眼看一次:一個節點默默壞掉,其他人完全不知道

「部分失效」聽起來抽象,看一次動畫就懂了。叢集裡有三個節點在正常服務使用者,其中一個忽然靜默故障——沒有錯誤訊息、沒有警報,就是不再回應了。按「下一步」看接下來發生什麼。

🙋
使用者
🖥️
節點一
💀
節點二(靜默故障)
🖥️
節點三
按「下一步」開始
💀
「部分失效」不是特例,是常態

節點二沒有大聲宣告自己壞了——分散式系統裡的故障往往就是這麼安靜。它不是「萬一發生就處理一下」的邊角案例,而是任何時刻都可能存在的背景狀態。系統設計,從一開始就得把它當作理所當然會發生的事。

用不可靠的零件,蓋出可靠的系統

你可能直覺覺得,系統的可靠度頂多等於它最弱的那個零件——木桶理論,最短的那塊木板決定水位。但書裡告訴我們:這其實不對。從一個較不可靠的底層,建造出更可靠的系統,是計算領域一個古老且可行的想法。

1
錯誤更正碼

無線傳輸偶爾會因電波干擾出現幾個位元錯誤,錯誤更正碼能在接收端偵測並修正這些少量錯誤,就像在吵雜咖啡廳講電話,靠語言的冗餘把被噪音蓋掉的字自動補回來。

2
IP 之上蓋 TCP

IP 像一個馬虎的郵差:封包可能被丟棄、延遲、重複或亂序。TCP 建在它之上,扮演負責任的快遞公司——主動重傳遺失的封包、去除重複、把順序重組回來。

3
但提升幅度有極限

錯誤更正碼救不了被完全淹沒的訊號;TCP 能藏起丟包、重複、亂序,卻無法神奇地消除網路延遲——這部分故障會直接穿透,變成使用者感受到的卡頓。

🚚
快遞公司救不了塞車

你把馬虎的郵差(IP)包裝成負責任的快遞公司(TCP):包裹沒到就補寄、收到兩份就丟掉多的、順序亂了就幫你排好。但快遞公司沒辦法讓郵差騎得更快——路上塞車,包裹還是會晚到。即便如此,更高層的可靠系統仍然有用,因為它接手了棘手的低階故障,讓剩下的問題更容易被推理和處理。

🙈
別假設故障很罕見就算了

就算只有幾個節點的小系統,也該認真想部分失效——多數時候多數零件都正常,但遲早某部分會壞,軟體必須有辦法應付。書裡的態度很直接:在分散式系統中,多疑、悲觀、偏執會帶來回報,最好的做法是在測試環境刻意製造故障,看系統實際怎麼反應(這正是 Chaos Monkey 的精神)。

小試身手

部分失效、兩種故障哲學、還有用不可靠零件蓋可靠系統——來檢查一下有沒有抓到重點。

你的服務送出一個請求給遠端節點後,等了很久都沒收到回應。從你這端來看,下列哪個結論最正確?
為什麼書中說超級電腦「更像一台單機,而不是分散式系統」?
📡
下一站:不可靠的網路

節點會故障是一回事,更麻煩的是——你連「它是不是真的故障」都無法確定。往下捲,我們去看看網路本身能有多不可靠、又是怎麼把這團不確定性帶給你的系統。

02

不可靠的網路

你送出一個請求,然後——沉默。沉默背後藏著至少六種可能,而你永遠分不出是哪一種

除了網路,機器之間什麼都不共享

本書談的分散式系統,骨子裡是 Shared-nothing 系統 ——一堆機器靠網路連起來,每台有自己的記憶體與磁碟,一台機器無法直接存取另一台的記憶體或磁碟,只能透過網路發送請求。

而網際網路、大多數資料中心內網,都是 非同步封包網路 ——一個節點可以送封包給另一個節點,但網路不保證何時到達、甚至是否會到達。

當你送出請求並期待回應時,可能發生下面這些事:

1
請求遺失

也許有人拔了網路線。請求根本沒送到對方那裡。

2
請求在排隊

網路或對方過載,請求塞在佇列裡,稍後才送達。

3
遠端節點失效

當機或被關機了,根本沒有人在處理你的請求。

4
遠端節點暫停

也許正在長時間 GC 暫停,稍後才恢復回應。

5
回應在路上遺失

對方確實處理了請求,但那個回應在網路上不見了。

6
回應被延遲

對方也處理完了,回應只是還在路上,稍後就到。

📮
把網路想成一個沉默的郵筒

你寫了一張明信片投進郵筒,希望對方回一張明信片給你。你坐在家裡等啊等,遲遲沒收到回信。明信片寄丟了?還塞在郵局倉庫?對方搬家失聯了?對方在度假?對方的回信弄丟了?回信還在路上龜速移動?——光靠「沒收到回信」這一個事實,你完全無法分辨是哪一種。

直接讀原文:這些情況「無法區分」

這是本章最核心的一段話——書用很平靜的語氣,講出一個讓人有點不安的事實。

原文 · DDIA Ch.8 If you send a request and don't get a response, it's not possible to distinguish whether the request was lost, the remote node is down, or the response was lost. The sender can't even tell whether the packet was delivered: the only option is for the recipient to send a response message, which may in turn be lost or delayed. These issues are indistinguishable in an asynchronous network: the only information you have is that you haven't received a response yet. If you send a request to another node and don't receive a response, it is impossible to tell why. The usual way of handling this issue is a timeout: after some time you give up waiting and assume that the response is not going to arrive. However, when a timeout occurs, you still don't know whether the remote node got your request or not.
白話翻譯

如果你送出請求卻沒收到回應,你根本無法分辨:是請求遺失了、遠端節點掛了,還是回應弄丟了。

發送端甚至無法確認封包有沒有送達——唯一的辦法,是靠接收端主動送回一則回應訊息,而這則回應本身又可能遺失或延遲。

在非同步網路裡,這些情況彼此無法區分:你手上唯一的資訊,就是「還沒收到回應」。

你送出請求給另一個節點卻沒收到回應時,根本不可能知道原因是什麼。

處理這種狀況的常見手段是「timeout」:過了一段時間,你就放棄等待,認定回應不會來了。

但 timeout 發生時,你仍然不知道對方到底收到你的請求了沒。

🌫️
核心論點:沉默無法解讀

你永遠無法區分「它沒收到」「它在處理」「它做完了但你沒收到回應」——這正是為什麼超時(timeout)判斷故障永遠是一種不完美的猜測。timeout 只是「你決定不等了」,它不會告訴你對方那邊到底發生了什麼事;請求甚至可能還排在某處,就算你已經放棄,它依然可能被送達並執行。

跑一遍看看:三種完全不同的命運,同一種等待

按「下一步」,看同一個請求送出去之後,可能走向哪三種不同結局——而對客戶端來說,這三種結局的「外部症狀」完全一樣:一直等,然後 timeout。

🙂
客戶端
🌐
網路
🖥️
伺服器
按「下一步」開始
🎭
三種命運,一種症狀

❌ 半路丟包、⏳ 對方還在忙、📭 回應寄丟——這三件事在系統內部完全不同,但客戶端手上的資訊只有一件:「還沒收到回應」。它們在非同步網路裡是不可區分的。

分不清,就要設計成「能容忍」

既然無法區分,工程上的策略不是「想辦法分清楚」,而是換一個問題:假設分不清,系統該怎麼設計才不會出事?

⏱️
永遠設定 Timeout

不能無限等待,否則對方沒回應時你會永遠卡住。timeout 是「我先不等了」的機制,不是真相判定機。

只信應用層的正面回應

只有收到應用層明確的成功回應,才算成功。沒回應就要假設「可能成功、也可能失敗」,不能自己樂觀猜測。

🔁
設計成可重試且安全

timeout 後請求可能仍被執行,重試可能造成「執行兩次」,所以操作最好設計成 冪等(idempotent)

CODE · 冪等設計示意
def transfer(request_id, from_acct, to_acct, amount):
    if already_processed(request_id):
        return "OK(已處理過,直接回成功)"
    do_transfer(from_acct, to_acct, amount)
    mark_processed(request_id)
    return "OK"
白話翻譯

用一個唯一的 request_id 記住「這筆轉帳有沒有做過」。

如果已經處理過,直接回成功,不要再轉一次。

沒處理過才真的去做轉帳這個動作。

做完之後標記這個 request_id 已經處理過。

這樣一來,就算 timeout 後客戶端重試好幾次,也只會真的扣款一次。

延遲為什麼會忽快忽慢?答案是「排隊」

就像開車塞車,網路封包延遲的變異多半來自 排隊(queueing)

📶
交換器佇列

多個節點同時送封包到同一個目的地,交換器得排隊一個個送;佇列塞滿就丟包、需要重送——這叫 網路壅塞(congestion)

🖥️
目的端 CPU 忙碌

封包到了,但 CPU 全忙著,作業系統只好先把請求排進佇列,等應用程式有空再處理。

💤
虛擬化暫停

VM 被暫停數十毫秒讓別的 VM 用 CPU,這段時間進來的資料只能被緩衝排隊。

🚦
TCP 流量控制

發送端為了不壓垮對方,主動限制自己的送出速率,資料在送出前就先排隊等待。

🛣️
高鐵對號座 vs 高速公路

電話網路用的是電路交換——像買了高鐵對號座,系統幫你預留固定座位,車再滿你都準時到站,這叫 有界延遲; 但代價是你離座去廁所時,那個座位也空在那沒人能用。資料中心與網際網路用的是 封包交換——像高速公路不預留車道,大家見縫插針搶著開,順暢時飛快、車多就塞成一團,延遲變成 無界延遲(unbounded delay)。網頁、Email 這類突發流量不需要固定頻寬,只想盡快傳完,所以我們選了高利用率、低成本的封包交換——代價就是接受這種變動的延遲。

Timeout 該設多長?敲鄰居家的門告訴你答案

如果 timeout 是判斷故障唯一可靠的手段,那它該設多長,就是一個真正棘手的問題——太長、太短都有代價。

🐢 Timeout 設太長
⚡ Timeout 設太短
⚠️ 代價:宣告死亡前要等很久,使用者得等待,或看到轉圈圈的錯誤畫面。偵測慢,但比較不容易誤判。
🚪
敲鄰居家的門

你去鄰居家敲門,沒人應。他可能出門了(節點死)、在睡覺(暫停)、在浴室聽不到(變慢)。等太久,你站在門口浪費一整天;等太快,他其實只是去廁所,你卻已經認定他不在、把他的工作攬過來——結果他出來發現工作被搶了,兩邊都做了一次。

🌊
誤判死亡的連鎖災難:Cascading Failure

想像一個本來就很忙的客服中心,某位客服只是接電話慢了點(負載尖峰),主管卻誤判他「陣亡」,把工作分給其他人;其他人本來就忙,多接工作後也開始變慢,於是又被誤判陣亡,工作再被轉移……最極端時,所有人互相宣告對方死亡,整個客服中心徹底癱瘓。這就是 連鎖失效。 所以書中建議:用實測決定 timeout,甚至讓系統持續測量回應時間與變異、自動調整門檻(例如 Akka、Cassandra 用的 Phi Accrual Failure Detector); 而系統已在高負載邊緣時,寧可保守一點再宣告死亡,避免引爆連鎖失效。

小試身手

這一路看下來,網路不可靠的核心邏輯有兩層:分不清原因、只能猜測。來檢查一下有沒有抓到重點。

你的服務送出請求後 5 秒內沒收到回應。下列哪個推論最符合非同步網路的現實?
某叢集在流量尖峰時,把幾個只是暫時變慢的健康節點誤判為死亡並轉移其負載,結果整個系統雪崩式癱瘓。這個現象稱為?
🕰️
下一站:不可靠的時鐘

網路會騙你,時鐘也一樣——而且錯得更隱晦,因為每台機器都以為自己的時間是對的。往下捲,看看「以為自己是對的」會捅出什麼婁子。

03

不可靠的時鐘

掛鐘會被人偷偷往回撥,碼錶只會往前走——分不清這兩種鐘,資料就會在你毫無所知的情況下悄悄消失

電腦裡其實住著兩種鐘

網路不可靠已經夠麻煩了,現在再加一個壞消息:每台機器量時間的方式,也不可靠。現代電腦至少有兩種鐘,雖然都叫「鐘」,用途卻完全不一樣——搞混,就會出大問題。

📅
時間日曆鐘 Time-of-day clock

回報你直覺理解的「現在幾點」——牆上時間。用 NTP 同步,但如果本地時鐘超前太多,可能被強制往回撥。

⏱️
單調鐘 Monotonic clock

適合量「經過了多久」。保證永遠往前走,但顯示的數字本身沒有日曆意義,兩台機器的單調鐘不能互相比較。

⚠️
用錯鐘,會算出負數

拿時間日曆鐘去量耗時,一旦鐘被 NTP 往回撥,你會算出「經過 -3 分鐘」這種荒謬結果。

🧭
掛鐘 vs 碼錶

時間日曆鐘像牆上的掛鐘:告訴你「現在下午三點」,方便你約時間、記事件。但掛鐘會被人調整——發現它快了五分鐘,你就把指針往回撥;如果那一刻你正用它「計時泡麵」,時間會突然倒退,泡麵明明泡了兩分鐘,掛鐘卻顯示只過了負三分鐘。單調鐘則像運動碼錶:按下開始、按下停止,告訴你中間經過多久,只會往前數、絕不倒退——但碼錶上的數字(例如「1357 秒」)本身沒有日曆意義,你的碼錶和我的碼錶起點不同,比數字毫無意義。

直接讀原文:兩種鐘的官方定義

書裡用一句話點出關鍵區分——量時間點,跟量時間長度,是兩件完全不同的事。

原文 · DDIA Ch.8 A time-of-day clock does what you intuitively expect of a clock: it returns the current date and time according to some calendar (also known as wall-clock time). In particular, if the local clock is too far ahead of the NTP server, it may be forcibly reset and appear to jump back to a previous point in time. These jumps make time-of-day clocks unsuitable for measuring elapsed time. A monotonic clock is suitable for measuring a duration (time interval), such as a timeout or a service's response time. The name comes from the fact that they are guaranteed to always move forward (whereas a time-of-day clock may jump back in time). It makes no sense to compare monotonic clock values from two different computers, because they don't mean the same thing.
白話翻譯

時間日曆鐘做的,正是你直覺理解的「鐘」該做的事:依日曆回報現在的日期與時間,也叫牆上時間。

關鍵是:如果本地時鐘跟 NTP 伺服器差太多,它可能被強制重設,看起來往回跳到過去某一刻。這種跳動讓時間日曆鐘不適合用來量經過的時間。

單調鐘則適合量一段持續時間(時間間隔),例如 timeout 或服務的回應時間。

它叫「單調」,是因為保證永遠往前走(時間日曆鐘卻可能往回跳)。

比較兩台不同電腦的單調鐘數值毫無意義,因為它們代表的根本不是同一件事。

💡
黃金法則:量間隔一律用單調鐘

錯誤示範:start = time.time()(可能被 NTP 往回撥,算出負的耗時)。正確示範:start = time.monotonic()(保證遞增,永不倒退)。書中也提醒:多 CPU socket 的機器上,各 CPU 未必共用同一個計時器,作業系統會盡力補償呈現單調視圖,但這個「保證」仍該帶著一分懷疑看待。

同步這件事,遠不如你以為的可靠

單調鐘不需同步;但時間日曆鐘得靠外部來源校正才有用,最常見的是 NTP。 可惜這套機制一堆地方會出錯:

🌡️
石英漂移 Drift

電腦的石英時鐘會跑快或跑慢,且隨溫度變化,時間拉長偏差就累積起來。

強制重設

本地時鐘跟 NTP 差太多時可能被強制重設,應用一夕之間看到時間倒退或暴跳。

🧱
被防火牆擋住

節點若不小心被擋在 NTP 伺服器外,誤差可能長期不被發現,越漂越遠。

🌐
網路延遲限制精度

公共網際網路上 NTP 最佳精度約數十毫秒,網路壅塞時誤差可能飆到超過一秒。

⚠️
時鐘讀數其實是一個「範圍」,不是一個點

就算你能讀到奈秒級解析度,也不代表它真的準到那個精度。把時鐘讀數想成一個 信心區間 才對——例如系統可能只有 95% 確信「現在介於 10.3 到 10.5 秒之間」。可惜大多數系統不會告訴你這個不確定性:呼叫 clock_gettime(),回傳值不會說誤差是 5 毫秒還是 5 年。一個誠實的例外是 Google Spanner 的 TrueTime API ,它會直接告訴你「現在大約在 earliest 到 latest 之間」,而不是假裝自己很精確。最危險的不是時鐘不準,而是你以為它很準——時鐘悄悄漂移時系統看似一切正常,結果往往不是戲劇性當機,而是沉默而微妙的資料遺失。

親眼看一次:時鐘偏差怎麼默默吃掉一筆寫入

現在把前兩格的教訓兌現成一個真實會發生的悲劇。節點 A 的時鐘比較快、節點 B 的時鐘比較慢——B 上「比較晚實際發生」的寫入,因為時間戳反而比 A 那筆「比較早發生」的寫入更小,用 LWW 比較時間戳時就會被誤判成舊資料,直接被丟棄——而且沒有任何錯誤訊息。按「下一步」看它怎麼發生。

🖥️🕐
節點 A(時鐘快)
🖥️🕐
節點 B(時鐘慢)
🗄️
被覆蓋的值
🗑️
默默丟棄
按「下一步」看時鐘怎麼騙過 LWW
📮
生活比喻:兩封信,各自蓋著不準的手錶時戳

小明先寫信(手錶顯示 10:04),小華看了小明的信、回了一封更新的信,但小華的手錶比較慢,顯示 10:03。收件人的規則是「以時戳較晚者為準」——結果收件人把小華那封真正較新的信丟進垃圾桶,因為它蓋的時間比小明那封還早。明明小華是後寫的,資料卻被默默丟掉,收件人完全不知道自己丟錯了。這就是 LWW 的陷阱:「新」是由各自不準的手錶定義的。LWW 也無法區分「真正並行的寫入」跟「快速接續發生的寫入」——要分清楚,得靠 version vector 之類額外的因果追蹤機制。書裡給的建議是:改用 邏輯時鐘 排序事件——它只關心「誰先誰後」,不去量「現在幾點」,自然不怕時鐘偏差。

更陰險的陷阱:程式可能突然被按下暫停鍵

時鐘偏差已經夠煩,還有一種更隱形的敵人——你的程式可能在執行到一半時,被整個世界按下暫停鍵,睡了幾秒甚至幾分鐘,醒來卻完全不知道自己睡了多久。

😴
打瞌睡的守夜人,拿著過期的通行證開金庫

守夜人領了一張「你值班到午夜」的通行證(租約 lease)。他看了一眼「還有十分鐘才到期,安啦」,正要去巡邏——結果原地打瞌睡睡了一小時。醒來後他渾然不知睡了多久,以為才剛看過通行證,繼續拿著早已過期的證去開金庫——這段時間警衛長早已換了新守夜人。兩個守夜人同時動金庫,後果不堪設想。守夜人不會知道自己睡了多久,這正是 行程暫停 process pause 可怕的地方:暫停期間世界繼續走,它醒來卻以為只過了一瞬。

會讓行程突然暫停的原因,多到嚇人:程式語言執行環境(如 JVM)的 垃圾回收 garbage collection 有時需要「stop-the-world」暫停所有執行緒,曾經長達數分鐘;虛擬機可能在任意時刻被掛起或搬移;作業系統的 context switch、磁碟 I/O、換頁,都可能讓執行緒卡住一段時間。

if (lease.expiryTimeMillis - System.currentTimeMillis() < 10000) {
    lease = lease.renew();
}
if (lease.isValid()) {
    process(request);   // 若這之前被暫停 15 秒,租約恐已過期!
}
🔓
單機的鎖工具,在分散式系統救不了你

這段程式碼有兩個站不住腳的假設:依賴同步時鐘、以及假設兩行程式碼之間幾乎不花時間。節點必須假設自己隨時可能在任意一行被暫停數秒到數分鐘。而 mutex、semaphore 這類單機同步工具,靠的是共享記憶體——分散式系統裡節點各自獨立,只能透過不可靠的網路傳訊息,這些工具根本無法直接套用。

小試身手

時鐘的兩種身分、還有 LWW 默默丟資料的陷阱,來檢查一下有沒有記牢。

你要量測一段程式碼的執行耗時,下列哪種時鐘最合適、且能避免算出負數時間?
在 last write wins(LWW)策略下,一個因果上較新的寫入為什麼可能被默默丟棄?
🎭
下一站:知識、真相與謊言

故障可能是假的、時鐘可能在騙你——分散式系統裡,一個節點要怎麼知道自己現在說的話到底還算不算數?往下捲,我們要拆穿更深一層的難題。

04

知識、真相與謊言

一個節點不能只相信自己——真相要靠投票決定,而更糟的是,隊友裡可能還藏著會說謊的節點

一個節點說了不算

想像一個節點正經歷一分鐘的 stop-the-world GC 暫停。其他節點等了又等、越等越不耐煩,最終宣告它死亡、把它「抬上靈車」。等 GC 結束,這個節點的執行緒像什麼都沒發生一樣繼續跑——它壓根不知道自己被宣告死亡,從它的角度,幾乎沒過多少時間。

這些故事的寓意只有一句話:一個節點無法完全信任自己對情勢的判斷。分散式系統不能只依賴單一節點,因為任何一個節點都可能隨時失效,讓系統卡住、無法恢復。

正因如此,許多分散式演算法都依賴 quorum(法定人數) ——也就是節點間的投票:決策需要幾個節點的最少票數才算數,藉此降低對任何單一節點的依賴。這也包括「宣告某節點死亡」這件事本身——如果一個 quorum 宣告某節點死亡,它就必須被當作死的,即使它自己覺得還很健康,也得乖乖退位。

最常見的 quorum:絕對多數

超過半數節點同意才算數。三個節點可容忍一個故障,五個節點可容忍兩個。

為什麼絕對多數是安全的

系統裡只可能存在一個「超過半數」的群體,不會同時出現兩個互相衝突的多數決定。

老大與鎖:常需要「只能有一個」

一個分區只能有一個 leader、一個資源只能有一個鎖的持有者——但節點自認是「那個被選中的」,不代表 quorum 同意。

⚖️
像陪審團的多數決

一個人說「被告有罪」不算數,得多數陪審員投票同意才算數。就算被告聲嘶力竭喊冤(自認還是 leader 的節點),只要多數裁定,他就得接受判決——而且陪審團只可能形成一個多數,不會同時出現兩個互相矛盾的判決。這正是 quorum 安全的關鍵。

直接讀原文:Fencing Token 到底怎麼運作

「自認是老大」跟「真的是老大」中間,隔著一個 quorum。書裡用一個具體機制,把這句話落實成程式碼可以檢查的東西。

原文 · DDIA Ch.8 Let's assume that every time the lock server grants a lock or lease, it also returns a fencing token, which is a number that increases every time a lock is granted. We can then require that every time a client sends a write request to the storage service, it must include its current fencing token. Client 1 acquires the lease with a token of 33, but then it goes into a long pause and the lease expires. Client 2 acquires the lease with a token of 34, and then sends its write request to the storage service, including the token of 34. The storage server remembers that it has already processed a write with a higher token number (34), and so it rejects the request with token 33. This mechanism requires the resource itself to take an active role in checking tokens by rejecting any writes with an older token than one that has already been processed — it is not sufficient to rely on clients checking their lock status themselves.
白話翻譯

假設鎖伺服器每次授予鎖或租約時,都會附上一個遞增的號碼——這就是 fencing token。

於是我們可以要求:客戶端每次寫入儲存服務時,都必須帶著它手上目前的 token。

客戶端 1 拿到 33 號的租約,但接著陷入一段長時間暫停,租約過期了。

客戶端 2 拿到 34 號租約,並帶著 34 這個 token 送出寫入請求。

儲存伺服器記得自己已經處理過更大的號碼(34),所以拒絕帶著 33 號 token 的這次請求。

重點是:這個機制要靠資源本身主動檢查 token、拒絕任何比已處理過的號碼還舊的寫入——只靠客戶端自己乖乖檢查鎖的狀態是不夠的。

🎫
像銀行櫃台的號碼牌

想像一個共享保險箱,規定「一次只能一個人操作」。你領了 33 號牌正要去開箱卻打瞌睡了(行程暫停),號碼牌過期;櫃台叫了下一位,他領了 34 號牌開了箱。你睡醒後渾然不知,拿著早已過期的 33 號牌也跑去開箱——如果保險箱不檢查號碼,兩個人同時動手,東西就全毀了。解法是:保險箱本身記得「我最近處理過的最大號碼是 34」,看到 33 就直接拒絕。

跑一遍:殭屍領導者 A 想寫入,會發生什麼事

下面把書中 Figure 8-5 的情境變成一個可以互動的小動畫:舊領導者 A 拿了 token 33,暫停後醒來還想寫入——看共享儲存怎麼擋下它。按「下一步」開始。

🧟
舊領導者 A(殭屍)
👑
新領導者 B
🗄️
共享儲存
按「下一步」開始
⚠️
關鍵不是「A 有沒有自覺」,是「誰在把關」

A 完全不是故意作惡——它是真心以為自己還是領導者。如果靠 A 自己「乖乖檢查」是否還持有租約,這招注定失敗,因為暫停後復活的節點壓根不知道自己已過期。所以檢查責任必須放在資源端:儲存系統本身主動拒絕比它見過的最大 token 還舊的請求,這才擋得住「假死後又活過來的舊領導者」。

如果節點不只是慢,而是會「說謊」呢?

Fencing token 能擋下「不小心」犯錯的節點(還沒發現自己租約已過期)。但整本書到目前為止,其實一直悄悄假設著一件事:節點是 不可靠但誠實(unreliable but honest) 的——它們可能很慢、不回應、狀態過時,但只要回應,就是說真話。

但如果節點可能故意送出任意錯誤或損毀的回應(例如謊稱自己收到了某訊息,其實根本沒收到)呢?這種行為稱為 拜占庭故障(Byzantine fault) ,而在這種互不信任的環境下要達成共識,就是知名的「拜占庭將軍問題」——n 位將軍要達成共識,但其中有些叛徒會送假訊息企圖誤導他人,而且事先不知道誰是叛徒。能在這種條件下仍正確運作的系統,稱為 拜占庭容錯(Byzantine fault-tolerant)

🚀
什麼時候真的需要拜占庭容錯?

航太環境——輻射可能損毀記憶體,讓節點以任意方式回應,飛控系統必須容錯。

⛓️
多組織、互不信任的系統

像比特幣這類區塊鏈,讓互不信任的各方在沒有中央權威的情況下達成共識。

🏢
但你自己的資料中心通常不需要

節點都由你的組織控制、輻射也低,通常可以安全假設沒有拜占庭故障——這類協定太複雜、部署成本太高,多數伺服器端系統不切實際。

🕴️
誠實但不可靠的隊友,vs. 混在團隊裡的臥底

會塞車遲到、手機沒電聯絡不上的隊友,不會故意騙你——只要他開口,講的就是他真心相信的事實。這種隊友,用 timeout、quorum、fencing token 就能對付。但拜占庭故障像團隊裡藏著臥底:他故意對 A 說一套、對 B 說另一套,而你事先不知道誰是臥底。對付臥底困難得多,通常要求超過三分之二的節點是好人。而且要注意:同一份軟體部署到所有節點,拜占庭容錯救不了 bug——因為 bug 會同時出現在所有節點;它也擋不住安全入侵,攻破一個節點多半就能攻破全部。真正防線仍是認證、存取控制、加密與防火牆,再加上應用層 checksum、輸入合理性檢查這類便宜的「弱形式說謊」防護。

系統模型:先講清楚規則,才能談對錯

分散式演算法不該過度依賴特定硬體與軟體的細節,所以我們要先形式化「預期會發生哪些故障」——這就是 系統模型(system model) 。時序假設上有三種:同步模型假設網路延遲、行程暫停、時鐘誤差都有固定上限(不切實際); 部分同步模型 假設系統多數時候良好、但偶爾會超出界限(最貼近現實);非同步模型則完全不能做任何時序假設,甚至沒有時鐘、不能用 timeout。書中認為,部分同步模型加上 crash-recovery 故障通常最有用、最貼近現實。

有了系統模型,我們才能定義演算法「正確」是什麼意思——透過區分兩種性質:

演算法正確性的兩種性質
🔒 安全性 Safety
🌅 活性 Liveness
安全性(Safety)=壞事絕不能發生。例如 fencing token 的『唯一性』與『單調遞增』:沒有兩個請求拿到相同 token,且較晚的請求拿到的 token 一定較大。一旦違反,可以指出確切的時間點,而且覆水難收——傷害已經造成。要求:在系統模型的所有情況下都必須永遠成立,即使全部節點當機、整個網路失效,也不能回傳錯誤結果。
🏦
保全公司的兩種承諾

安全性像「金庫永遠不會被兩個人同時打開」——一旦違反,你能指出確切的時間點,東西已經被偷了,覆水難收。活性像「你按了求救鈕,保全終究會趕到」——它現在可能還沒滿足(保全還在路上),但未來仍有希望達成,定義裡常出現「eventually(終將)」這個字。

🧩
模型終究只是簡化,現實會反咬你一口

crash-recovery 模型假設穩定儲存能跨當機存活,但如果磁碟資料損毀或被誤刪呢?quorum 演算法會因節點「失憶」而失效。系統模型幫我們把混亂的現實蒸餾成可推理的少數故障,但別忘了它終究是抽象——理論分析與經驗測試缺一不可。

小試身手

Quorum、fencing token、安全性與活性——來檢查一下有沒有記牢。

為什麼用「絕對多數(超過半數)」作為 quorum,能保證不會做出互相衝突的決定?
在 fencing token 機制中,為什麼「由資源端(儲存系統)主動檢查 token」比「由客戶端自己檢查鎖狀態」更可靠?
📖
全章總結:三個麻煩,一句話收尾

第 8 章一路走來,講的都是分散式系統會怎麼讓你的直覺失靈:部分失效——某些節點壞了、某些沒壞,你不會馬上知道是哪些;不可靠的網路——訊息會遺失、延遲、重複,你分不清是「訊息沒送到」還是「回應沒送回」;不可靠的時鐘——每台機器的時間認知都可能悄悄跑掉。這一節則補上最後一塊:既然節點連自己的處境都可能判斷錯誤,真相就不能由單一節點說了算,得靠 quorum 多數決;如果連「誠實」都不能假設,就得付出高昂代價換來拜占庭容錯;而要清楚地談論「什麼叫正確」,就要把性質拆成安全性(絕不容許)與活性(終將達成)分開處理。

🤝
下一站:一致性與共識

第 8 章列出了所有會出錯的方式——節點會斷線、網路會騙你、時鐘會漂移、真相要靠投票才能確定。第 9 章要回答的問題是:就算這樣,我們還能不能讓分散式系統,表現得像一台單機一樣可靠?