分散式系統的麻煩:不可靠的網路、不可信的時鐘,與半死不活的節點

· tech

#distributed-systems#book-notes#reliability

📑 目錄

上一篇結尾埋了個鉤子:當系統跨到多台機器,連「鎖」和「驗證」本身都變得不可靠。這章就是那個「不可靠」的總清算——也是我認為全書最該精讀的一章。核心一句話:單機世界是確定性的,要嘛全好、要嘛全壞;分散式世界的常態,是「部分失效(partial failure)」——一部分壞了、其他還在跑,而且你常常分不清誰是誰。 三個不可靠,層層遞進:網路、時鐘、然後是節點自己。

不可靠的網路:「沒有回應」有四種原因,而你分不出來

你送一個請求出去,遲遲沒有回應——發生了什麼? 這章最重要的一張圖,就是這個問題的答案:你不知道,而且原則上無法知道。

你的節點 送出請求,等待… ① 請求在網路上丟了對方根本沒收到 ② 對方當掉了真的死了 ③ 對方只是很慢(過載、GC 暫停中)等一下它會處理——甚至正在處理 ④ 對方做完了,但「回應」在路上丟了動作已經發生,你卻以為沒有 在你這端: 四種一模一樣 = 沒有回應 timeout 到了,只代表「你決定不等了」,不代表「你知道發生了什麼」
請求沒有回應的四種原因——請求丟了、對方死了、回應丟了、或對方只是慢——在你這端看起來完全一樣。你唯一的工具是 timeout,但它是個殘酷的妥協:設太短,把只是慢的節點誤判成死的(然後你重送請求,可能把事情做兩遍——這正是 exactly-once 難題的根源);設太長,真死了你乾等。最陰的是 ④:動作已經發生了,你卻以為沒有。這張圖是分散式所有麻煩的起點

這個「不可區分」不是工程沒做好,是非同步網路的本質——沒有任何機制能保證訊息在多久內送達。所以 Sentinel 才要分主觀下線(我覺得它掛了=我的 timeout 到了)與客觀下線(過半都覺得它掛了),所以重試一定要搭配冪等——因為你重送的那個請求,可能第一次已經成功了

不可信的時鐘:想要順序,用序號,不要用時間

第二個不可靠更陰,因為它平時看起來很正常。先分清機器上的兩種鐘,再看誤用的災難:

time-of-day 鐘:「現在幾點?」 會被 NTP 校正、可能「往回跳」 只能拿來「標記時刻」,別拿來排序、計時 monotonic 鐘:「經過多久?」 只保證單調往前,絕不回跳 量 timeout、量耗時,一律用它 誤用的災難:LWW 用時間戳決定誰贏 節點 A(鐘快了 3 秒):寫 x=1 timestamp = 10:00:05(先發生) 節點 B(鐘是準的):寫 x=2 timestamp = 10:00:03(後發生) LWW 比時間戳:x=2(較新的寫入)被默默丟棄 💥 想要順序 → 用單調遞增的「序號」(log offset、fencing token),不要用牆上的鐘
上半是基本功:time-of-day 鐘會被 NTP 校正、可能往回跳——拿它量耗時,你會量出負的;量 timeout、量間隔一律用monotonic 鐘。下半是真正的災難:多主複製常用 LWW(last write wins)拿時間戳決定衝突誰贏,但節點之間的鐘永遠有時差——鐘快 3 秒的節點,先發生的寫入蓋著「較新」的戳,於是真正較新的寫入被默默丟棄,沒有錯誤、沒有告警。結論刻進骨子:想要事件的順序,用單調遞增的序號(Kafka 的 offset、fencing token),永遠不要信牆上的鐘

半死不活的節點:你連「自己還活著」都不能確定

第三層最哲學:連節點自己的判斷都不可信。 一個行程可能在任何一行程式碼之間被暫停——GC stop-the-world、VM 被 suspend、還不完的 page fault——暫停幾秒甚至幾分鐘,而它自己毫無知覺。醒來的那一刻,它以為自己還是 leader、以為鎖還在手上,但世界早就變了。這正是我在分散式鎖畫過的那場戲:GC 暫停超過 TTL,兩個 client 同時「持有」鎖——而解法(fencing token,單調遞增的序號、由資源端把關)也在那篇講透了,這裡不重畫。

把三層合起來,Ch8 的結論就浮出來了:任何單一節點的判斷——包括它對自己的判斷——都不可信;所以「真相」在分散式系統裡,只能是多數節點投票的結果(quorum)。 一個節點就算自認活著,只要過半宣告它死了,它就「死」了,必須讓位。這就是 Sentinel 的過半Cluster 的過半一路埋的伏筆——而「多數怎麼安全地達成一個決定」,正是下一章共識的主題。(至於「節點會說謊」的拜占庭故障:除非你在做區塊鏈或航太,一般自家機房假設節點誠實但會壞就夠了——別為用不到的威脅模型付設計稅。)

反思

timeout 不是「知識」,是「決定」——想通這句,重試與冪等就成了信仰

那張「四種原因不可區分」的圖,是我認為整本 DDIA 最值得裱框的一張。它戳破一個工程師普遍的錯覺:timeout 到了 = 對方掛了。不,timeout 到了只代表「你決定不再等」——你依然不知道請求是沒到、做了一半、還是做完了但回應丟了。這個「不知道」推出兩條我奉為紀律的實務規則:第一,重試是必須的,所以冪等不是選項(可靠性那篇的地基,原來根在這);第二,任何單點的死活判斷都只是猜測,要行動就得湊多數(SDOWN→ODOWN 的設計,原來理論在這)。一章書,把我散落在 Airflow、Redis、SRE 的三個實務習慣,收攏成同一條公理的推論。

「想要順序,用序號,不要用時鐘」——資料工程的每一天都在用這句話

LWW 靠時間戳丟資料那一幕,對做資料的人應該格外刺痛,因為我們天天在跟它的變體搏鬥:event time vs processing time、遲到的事件、跨機房的日誌合併排序。這章給了我一個統一的答案:牆上的鐘只能拿來「大概標記」,凡是正確性依賴順序的地方,一律用單調遞增的序號——Kafka 的 offset、資料庫的 LSN、fencing token,全是這個原則的化身。我現在設計任何 pipeline,看到「用 timestamp 判斷誰新誰舊」就會停下來問:這兩個 timestamp 來自同一個鐘嗎? 不是的話,就換序號、或接受近似。這句話便宜、好記、能擋住一整類無聲的資料損毀。

部分失效不是要修的 bug,是要接受的世界觀

讀完這章,我對「分散式」三個字的敬畏又深了一層:單機是「要嘛全好、要嘛全壞」的確定性世界,分散式是「永遠有一部分半死不活」的機率世界——而後者不是工程不夠好,是本質。這給我兩個層次的啟示。往下,它解釋了為什麼 K8s 的 reconcile loopSRE 的一切設計都圍繞「預期失敗」——在這個世界觀裡,可靠性不是「不出事」,是出事時系統還能收斂。往上,它再次替 先確認痛點背書:每跨出一台機器,你就把自己從確定性世界搬進機率世界,那是一整套認知稅——網路、時鐘、quorum、fencing 全要補課。單機扛得住,就別急著分散;真要分散,就把這章當入場券,一字一字讀完再上路。