故障與部分失效
單機的世界非黑即白,分散式系統卻活在一團灰色地帶——歡迎來到麻煩真正開始的地方
單機很乾脆:要嘛能跑,要嘛不能跑
在一台電腦上寫程式,行為通常很好預測。硬體正常時,同樣的操作永遠產生同樣的結果,這叫 確定性(deterministic)。 這其實是刻意設計出來的:一旦內部出錯,電腦寧可直接當掉(想想那個藍白當機畫面),也不要回傳一個看起來正常、其實是錯的結果——因為錯誤的結果遠比完全停擺更難處理。
但當軟體跑在多台用網路連起來的電腦上,這個理想模型就破滅了。你不再活在數學般完美的世界裡,必須面對物理現實的混亂:電力可能忽然中斷、機櫃可能被誤觸斷電、甚至真的發生過有人開車撞進機房的空調系統。
單機程式像你自己在房間裡做事:手伸出去拿杯子,拿到就是拿到,沒拿到就是沒拿到,一清二楚。分散式系統則像你寄一份重要文件給遠方隊友、請他簽完寄回——信可能根本沒送到、可能塞在倉庫延遲、隊友可能搬家聯絡不上、也可能簽好了但回信在路上弄丟。你坐在家裡,光憑「還沒收到回信」,完全分不出是哪一種情況。
當系統中有些部分用不可預期的方式壞掉、其他部分卻運作良好,這種現象就叫 部分失效(partial failure)。 最麻煩的地方在於它是非確定性的:只要牽涉到多個節點與網路,同一件事有時成功、有時莫名其妙失敗,你甚至無法確知它到底成功了沒——因為訊息在網路上跑的時間本身就無法預測。
直接讀原文:部分失效是什麼
這幾句話,是整章、甚至整個「分散式系統很難搞」主題的起點。原文很平實,但每個字都是重點。
在分散式系統裡,系統中某些部分很可能用一種無法預期的方式壞掉了,而其他部分卻運作良好。
這種現象就叫做「部分失效」。
難就難在部分失效是非確定性的:只要牽涉到多個節點與網路,同一件事有時能成功、有時莫名其妙就失敗。
你甚至可能連「這件事到底成功了沒」都不知道——因為訊息在網路上跑的時間,本身也是無法預測的!
正是這種非確定性、以及部分失效隨時可能發生的特質,讓分散式系統變得這麼難搞。
部分失效不是「會不會發生」的問題,而是「什麼時候發生」的問題——而且發生時,你連它有沒有發生都不一定確定。這正是本章接下來要一路拆解的核心難題。
兩種哲學:一壞就全停,還是撐著繼續跑?
面對「東西總會壞」這個事實,工程史上出現過兩種完全相反的應對哲學。書裡用超級電腦和雲端運算這兩個極端,把差異拉得很清楚。
用昂貴、可靠的專用硬體,定期把運算狀態存成 checkpoint(檢查點)。 只要有一個節點壞掉,就讓整個叢集停下來,修好之後從上一個 checkpoint 重新跑。它把「部分失效」直接升級成「整體失效」——本質上更像一台放大的單機,而不是真正的分散式系統。
用便宜、故障率較高的 商用機器(commodity machine) 組成叢集,假設任何時刻都有某些零件正在故障。系統從設計之初就要能撐過「一部分」壞掉,服務不能因為修東西而整體離線——因為使用者要求全天候低延遲。
天氣模擬這類離線批次工作可以停下重跑,衝擊不大;但線上交易系統、電商網站不能說「抱歉,我們正在修,請等一下」。規模愈大,「隨時有東西正在壞」的機率愈高,也愈不能採用「一壞就全停」的策略——這正是為什麼網路服務寧可把容錯寫進軟體,也要換來 滾動升級(rolling upgrade) 這種「一次只換一顆螺絲、機器照常運轉」的能力。
親眼看一次:一個節點默默壞掉,其他人完全不知道
「部分失效」聽起來抽象,看一次動畫就懂了。叢集裡有三個節點在正常服務使用者,其中一個忽然靜默故障——沒有錯誤訊息、沒有警報,就是不再回應了。按「下一步」看接下來發生什麼。
節點二沒有大聲宣告自己壞了——分散式系統裡的故障往往就是這麼安靜。它不是「萬一發生就處理一下」的邊角案例,而是任何時刻都可能存在的背景狀態。系統設計,從一開始就得把它當作理所當然會發生的事。
用不可靠的零件,蓋出可靠的系統
你可能直覺覺得,系統的可靠度頂多等於它最弱的那個零件——木桶理論,最短的那塊木板決定水位。但書裡告訴我們:這其實不對。從一個較不可靠的底層,建造出更可靠的系統,是計算領域一個古老且可行的想法。
無線傳輸偶爾會因電波干擾出現幾個位元錯誤,錯誤更正碼能在接收端偵測並修正這些少量錯誤,就像在吵雜咖啡廳講電話,靠語言的冗餘把被噪音蓋掉的字自動補回來。
IP 像一個馬虎的郵差:封包可能被丟棄、延遲、重複或亂序。TCP 建在它之上,扮演負責任的快遞公司——主動重傳遺失的封包、去除重複、把順序重組回來。
錯誤更正碼救不了被完全淹沒的訊號;TCP 能藏起丟包、重複、亂序,卻無法神奇地消除網路延遲——這部分故障會直接穿透,變成使用者感受到的卡頓。
你把馬虎的郵差(IP)包裝成負責任的快遞公司(TCP):包裹沒到就補寄、收到兩份就丟掉多的、順序亂了就幫你排好。但快遞公司沒辦法讓郵差騎得更快——路上塞車,包裹還是會晚到。即便如此,更高層的可靠系統仍然有用,因為它接手了棘手的低階故障,讓剩下的問題更容易被推理和處理。
就算只有幾個節點的小系統,也該認真想部分失效——多數時候多數零件都正常,但遲早某部分會壞,軟體必須有辦法應付。書裡的態度很直接:在分散式系統中,多疑、悲觀、偏執會帶來回報,最好的做法是在測試環境刻意製造故障,看系統實際怎麼反應(這正是 Chaos Monkey 的精神)。
小試身手
部分失效、兩種故障哲學、還有用不可靠零件蓋可靠系統——來檢查一下有沒有抓到重點。
節點會故障是一回事,更麻煩的是——你連「它是不是真的故障」都無法確定。往下捲,我們去看看網路本身能有多不可靠、又是怎麼把這團不確定性帶給你的系統。
不可靠的網路
你送出一個請求,然後——沉默。沉默背後藏著至少六種可能,而你永遠分不出是哪一種
除了網路,機器之間什麼都不共享
本書談的分散式系統,骨子裡是 Shared-nothing 系統 ——一堆機器靠網路連起來,每台有自己的記憶體與磁碟,一台機器無法直接存取另一台的記憶體或磁碟,只能透過網路發送請求。
而網際網路、大多數資料中心內網,都是 非同步封包網路 ——一個節點可以送封包給另一個節點,但網路不保證何時到達、甚至是否會到達。
當你送出請求並期待回應時,可能發生下面這些事:
也許有人拔了網路線。請求根本沒送到對方那裡。
網路或對方過載,請求塞在佇列裡,稍後才送達。
當機或被關機了,根本沒有人在處理你的請求。
也許正在長時間 GC 暫停,稍後才恢復回應。
對方確實處理了請求,但那個回應在網路上不見了。
對方也處理完了,回應只是還在路上,稍後就到。
你寫了一張明信片投進郵筒,希望對方回一張明信片給你。你坐在家裡等啊等,遲遲沒收到回信。明信片寄丟了?還塞在郵局倉庫?對方搬家失聯了?對方在度假?對方的回信弄丟了?回信還在路上龜速移動?——光靠「沒收到回信」這一個事實,你完全無法分辨是哪一種。
直接讀原文:這些情況「無法區分」
這是本章最核心的一段話——書用很平靜的語氣,講出一個讓人有點不安的事實。
如果你送出請求卻沒收到回應,你根本無法分辨:是請求遺失了、遠端節點掛了,還是回應弄丟了。
發送端甚至無法確認封包有沒有送達——唯一的辦法,是靠接收端主動送回一則回應訊息,而這則回應本身又可能遺失或延遲。
在非同步網路裡,這些情況彼此無法區分:你手上唯一的資訊,就是「還沒收到回應」。
你送出請求給另一個節點卻沒收到回應時,根本不可能知道原因是什麼。
處理這種狀況的常見手段是「timeout」:過了一段時間,你就放棄等待,認定回應不會來了。
但 timeout 發生時,你仍然不知道對方到底收到你的請求了沒。
你永遠無法區分「它沒收到」「它在處理」「它做完了但你沒收到回應」——這正是為什麼超時(timeout)判斷故障永遠是一種不完美的猜測。timeout 只是「你決定不等了」,它不會告訴你對方那邊到底發生了什麼事;請求甚至可能還排在某處,就算你已經放棄,它依然可能被送達並執行。
跑一遍看看:三種完全不同的命運,同一種等待
按「下一步」,看同一個請求送出去之後,可能走向哪三種不同結局——而對客戶端來說,這三種結局的「外部症狀」完全一樣:一直等,然後 timeout。
❌ 半路丟包、⏳ 對方還在忙、📭 回應寄丟——這三件事在系統內部完全不同,但客戶端手上的資訊只有一件:「還沒收到回應」。它們在非同步網路裡是不可區分的。
分不清,就要設計成「能容忍」
既然無法區分,工程上的策略不是「想辦法分清楚」,而是換一個問題:假設分不清,系統該怎麼設計才不會出事?
不能無限等待,否則對方沒回應時你會永遠卡住。timeout 是「我先不等了」的機制,不是真相判定機。
只有收到應用層明確的成功回應,才算成功。沒回應就要假設「可能成功、也可能失敗」,不能自己樂觀猜測。
timeout 後請求可能仍被執行,重試可能造成「執行兩次」,所以操作最好設計成 冪等(idempotent)。
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 全忙著,作業系統只好先把請求排進佇列,等應用程式有空再處理。
VM 被暫停數十毫秒讓別的 VM 用 CPU,這段時間進來的資料只能被緩衝排隊。
發送端為了不壓垮對方,主動限制自己的送出速率,資料在送出前就先排隊等待。
電話網路用的是電路交換——像買了高鐵對號座,系統幫你預留固定座位,車再滿你都準時到站,這叫 有界延遲; 但代價是你離座去廁所時,那個座位也空在那沒人能用。資料中心與網際網路用的是 封包交換——像高速公路不預留車道,大家見縫插針搶著開,順暢時飛快、車多就塞成一團,延遲變成 無界延遲(unbounded delay)。網頁、Email 這類突發流量不需要固定頻寬,只想盡快傳完,所以我們選了高利用率、低成本的封包交換——代價就是接受這種變動的延遲。
Timeout 該設多長?敲鄰居家的門告訴你答案
如果 timeout 是判斷故障唯一可靠的手段,那它該設多長,就是一個真正棘手的問題——太長、太短都有代價。
你去鄰居家敲門,沒人應。他可能出門了(節點死)、在睡覺(暫停)、在浴室聽不到(變慢)。等太久,你站在門口浪費一整天;等太快,他其實只是去廁所,你卻已經認定他不在、把他的工作攬過來——結果他出來發現工作被搶了,兩邊都做了一次。
想像一個本來就很忙的客服中心,某位客服只是接電話慢了點(負載尖峰),主管卻誤判他「陣亡」,把工作分給其他人;其他人本來就忙,多接工作後也開始變慢,於是又被誤判陣亡,工作再被轉移……最極端時,所有人互相宣告對方死亡,整個客服中心徹底癱瘓。這就是 連鎖失效。 所以書中建議:用實測決定 timeout,甚至讓系統持續測量回應時間與變異、自動調整門檻(例如 Akka、Cassandra 用的 Phi Accrual Failure Detector); 而系統已在高負載邊緣時,寧可保守一點再宣告死亡,避免引爆連鎖失效。
小試身手
這一路看下來,網路不可靠的核心邏輯有兩層:分不清原因、只能猜測。來檢查一下有沒有抓到重點。
網路會騙你,時鐘也一樣——而且錯得更隱晦,因為每台機器都以為自己的時間是對的。往下捲,看看「以為自己是對的」會捅出什麼婁子。
不可靠的時鐘
掛鐘會被人偷偷往回撥,碼錶只會往前走——分不清這兩種鐘,資料就會在你毫無所知的情況下悄悄消失
電腦裡其實住著兩種鐘
網路不可靠已經夠麻煩了,現在再加一個壞消息:每台機器量時間的方式,也不可靠。現代電腦至少有兩種鐘,雖然都叫「鐘」,用途卻完全不一樣——搞混,就會出大問題。
回報你直覺理解的「現在幾點」——牆上時間。用 NTP 同步,但如果本地時鐘超前太多,可能被強制往回撥。
適合量「經過了多久」。保證永遠往前走,但顯示的數字本身沒有日曆意義,兩台機器的單調鐘不能互相比較。
拿時間日曆鐘去量耗時,一旦鐘被 NTP 往回撥,你會算出「經過 -3 分鐘」這種荒謬結果。
時間日曆鐘像牆上的掛鐘:告訴你「現在下午三點」,方便你約時間、記事件。但掛鐘會被人調整——發現它快了五分鐘,你就把指針往回撥;如果那一刻你正用它「計時泡麵」,時間會突然倒退,泡麵明明泡了兩分鐘,掛鐘卻顯示只過了負三分鐘。單調鐘則像運動碼錶:按下開始、按下停止,告訴你中間經過多久,只會往前數、絕不倒退——但碼錶上的數字(例如「1357 秒」)本身沒有日曆意義,你的碼錶和我的碼錶起點不同,比數字毫無意義。
直接讀原文:兩種鐘的官方定義
書裡用一句話點出關鍵區分——量時間點,跟量時間長度,是兩件完全不同的事。
時間日曆鐘做的,正是你直覺理解的「鐘」該做的事:依日曆回報現在的日期與時間,也叫牆上時間。
關鍵是:如果本地時鐘跟 NTP 伺服器差太多,它可能被強制重設,看起來往回跳到過去某一刻。這種跳動讓時間日曆鐘不適合用來量經過的時間。
單調鐘則適合量一段持續時間(時間間隔),例如 timeout 或服務的回應時間。
它叫「單調」,是因為保證永遠往前走(時間日曆鐘卻可能往回跳)。
比較兩台不同電腦的單調鐘數值毫無意義,因為它們代表的根本不是同一件事。
錯誤示範:start = time.time()(可能被 NTP 往回撥,算出負的耗時)。正確示範:start = time.monotonic()(保證遞增,永不倒退)。書中也提醒:多 CPU socket 的機器上,各 CPU 未必共用同一個計時器,作業系統會盡力補償呈現單調視圖,但這個「保證」仍該帶著一分懷疑看待。
同步這件事,遠不如你以為的可靠
單調鐘不需同步;但時間日曆鐘得靠外部來源校正才有用,最常見的是 NTP。 可惜這套機制一堆地方會出錯:
電腦的石英時鐘會跑快或跑慢,且隨溫度變化,時間拉長偏差就累積起來。
本地時鐘跟 NTP 差太多時可能被強制重設,應用一夕之間看到時間倒退或暴跳。
節點若不小心被擋在 NTP 伺服器外,誤差可能長期不被發現,越漂越遠。
公共網際網路上 NTP 最佳精度約數十毫秒,網路壅塞時誤差可能飆到超過一秒。
就算你能讀到奈秒級解析度,也不代表它真的準到那個精度。把時鐘讀數想成一個
信心區間
才對——例如系統可能只有 95% 確信「現在介於 10.3 到 10.5 秒之間」。可惜大多數系統不會告訴你這個不確定性:呼叫 clock_gettime(),回傳值不會說誤差是 5 毫秒還是 5 年。一個誠實的例外是 Google Spanner 的
TrueTime API
,它會直接告訴你「現在大約在 earliest 到 latest 之間」,而不是假裝自己很精確。最危險的不是時鐘不準,而是你以為它很準——時鐘悄悄漂移時系統看似一切正常,結果往往不是戲劇性當機,而是沉默而微妙的資料遺失。
親眼看一次:時鐘偏差怎麼默默吃掉一筆寫入
現在把前兩格的教訓兌現成一個真實會發生的悲劇。節點 A 的時鐘比較快、節點 B 的時鐘比較慢——B 上「比較晚實際發生」的寫入,因為時間戳反而比 A 那筆「比較早發生」的寫入更小,用 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 默默丟資料的陷阱,來檢查一下有沒有記牢。
故障可能是假的、時鐘可能在騙你——分散式系統裡,一個節點要怎麼知道自己現在說的話到底還算不算數?往下捲,我們要拆穿更深一層的難題。
知識、真相與謊言
一個節點不能只相信自己——真相要靠投票決定,而更糟的是,隊友裡可能還藏著會說謊的節點
一個節點說了不算
想像一個節點正經歷一分鐘的 stop-the-world GC 暫停。其他節點等了又等、越等越不耐煩,最終宣告它死亡、把它「抬上靈車」。等 GC 結束,這個節點的執行緒像什麼都沒發生一樣繼續跑——它壓根不知道自己被宣告死亡,從它的角度,幾乎沒過多少時間。
這些故事的寓意只有一句話:一個節點無法完全信任自己對情勢的判斷。分散式系統不能只依賴單一節點,因為任何一個節點都可能隨時失效,讓系統卡住、無法恢復。
正因如此,許多分散式演算法都依賴 quorum(法定人數) ——也就是節點間的投票:決策需要幾個節點的最少票數才算數,藉此降低對任何單一節點的依賴。這也包括「宣告某節點死亡」這件事本身——如果一個 quorum 宣告某節點死亡,它就必須被當作死的,即使它自己覺得還很健康,也得乖乖退位。
超過半數節點同意才算數。三個節點可容忍一個故障,五個節點可容忍兩個。
系統裡只可能存在一個「超過半數」的群體,不會同時出現兩個互相衝突的多數決定。
一個分區只能有一個 leader、一個資源只能有一個鎖的持有者——但節點自認是「那個被選中的」,不代表 quorum 同意。
一個人說「被告有罪」不算數,得多數陪審員投票同意才算數。就算被告聲嘶力竭喊冤(自認還是 leader 的節點),只要多數裁定,他就得接受判決——而且陪審團只可能形成一個多數,不會同時出現兩個互相矛盾的判決。這正是 quorum 安全的關鍵。
直接讀原文:Fencing Token 到底怎麼運作
「自認是老大」跟「真的是老大」中間,隔著一個 quorum。書裡用一個具體機制,把這句話落實成程式碼可以檢查的東西。
假設鎖伺服器每次授予鎖或租約時,都會附上一個遞增的號碼——這就是 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 完全不是故意作惡——它是真心以為自己還是領導者。如果靠 A 自己「乖乖檢查」是否還持有租約,這招注定失敗,因為暫停後復活的節點壓根不知道自己已過期。所以檢查責任必須放在資源端:儲存系統本身主動拒絕比它見過的最大 token 還舊的請求,這才擋得住「假死後又活過來的舊領導者」。
如果節點不只是慢,而是會「說謊」呢?
Fencing token 能擋下「不小心」犯錯的節點(還沒發現自己租約已過期)。但整本書到目前為止,其實一直悄悄假設著一件事:節點是 不可靠但誠實(unreliable but honest) 的——它們可能很慢、不回應、狀態過時,但只要回應,就是說真話。
但如果節點可能故意送出任意錯誤或損毀的回應(例如謊稱自己收到了某訊息,其實根本沒收到)呢?這種行為稱為 拜占庭故障(Byzantine fault) ,而在這種互不信任的環境下要達成共識,就是知名的「拜占庭將軍問題」——n 位將軍要達成共識,但其中有些叛徒會送假訊息企圖誤導他人,而且事先不知道誰是叛徒。能在這種條件下仍正確運作的系統,稱為 拜占庭容錯(Byzantine fault-tolerant) 。
航太環境——輻射可能損毀記憶體,讓節點以任意方式回應,飛控系統必須容錯。
像比特幣這類區塊鏈,讓互不信任的各方在沒有中央權威的情況下達成共識。
節點都由你的組織控制、輻射也低,通常可以安全假設沒有拜占庭故障——這類協定太複雜、部署成本太高,多數伺服器端系統不切實際。
會塞車遲到、手機沒電聯絡不上的隊友,不會故意騙你——只要他開口,講的就是他真心相信的事實。這種隊友,用 timeout、quorum、fencing token 就能對付。但拜占庭故障像團隊裡藏著臥底:他故意對 A 說一套、對 B 說另一套,而你事先不知道誰是臥底。對付臥底困難得多,通常要求超過三分之二的節點是好人。而且要注意:同一份軟體部署到所有節點,拜占庭容錯救不了 bug——因為 bug 會同時出現在所有節點;它也擋不住安全入侵,攻破一個節點多半就能攻破全部。真正防線仍是認證、存取控制、加密與防火牆,再加上應用層 checksum、輸入合理性檢查這類便宜的「弱形式說謊」防護。
系統模型:先講清楚規則,才能談對錯
分散式演算法不該過度依賴特定硬體與軟體的細節,所以我們要先形式化「預期會發生哪些故障」——這就是 系統模型(system model) 。時序假設上有三種:同步模型假設網路延遲、行程暫停、時鐘誤差都有固定上限(不切實際); 部分同步模型 假設系統多數時候良好、但偶爾會超出界限(最貼近現實);非同步模型則完全不能做任何時序假設,甚至沒有時鐘、不能用 timeout。書中認為,部分同步模型加上 crash-recovery 故障通常最有用、最貼近現實。
有了系統模型,我們才能定義演算法「正確」是什麼意思——透過區分兩種性質:
安全性像「金庫永遠不會被兩個人同時打開」——一旦違反,你能指出確切的時間點,東西已經被偷了,覆水難收。活性像「你按了求救鈕,保全終究會趕到」——它現在可能還沒滿足(保全還在路上),但未來仍有希望達成,定義裡常出現「eventually(終將)」這個字。
crash-recovery 模型假設穩定儲存能跨當機存活,但如果磁碟資料損毀或被誤刪呢?quorum 演算法會因節點「失憶」而失效。系統模型幫我們把混亂的現實蒸餾成可推理的少數故障,但別忘了它終究是抽象——理論分析與經驗測試缺一不可。
小試身手
Quorum、fencing token、安全性與活性——來檢查一下有沒有記牢。
第 8 章一路走來,講的都是分散式系統會怎麼讓你的直覺失靈:部分失效——某些節點壞了、某些沒壞,你不會馬上知道是哪些;不可靠的網路——訊息會遺失、延遲、重複,你分不清是「訊息沒送到」還是「回應沒送回」;不可靠的時鐘——每台機器的時間認知都可能悄悄跑掉。這一節則補上最後一塊:既然節點連自己的處境都可能判斷錯誤,真相就不能由單一節點說了算,得靠 quorum 多數決;如果連「誠實」都不能假設,就得付出高昂代價換來拜占庭容錯;而要清楚地談論「什麼叫正確」,就要把性質拆成安全性(絕不容許)與活性(終將達成)分開處理。
第 8 章列出了所有會出錯的方式——節點會斷線、網路會騙你、時鐘會漂移、真相要靠投票才能確定。第 9 章要回答的問題是:就算這樣,我們還能不能讓分散式系統,表現得像一台單機一樣可靠?