容錯不只關乎故障行程,也得考慮通訊失效。前面討論的失效模型大多同樣適用於通訊通道:通道可能出現當機、遺漏、時序與任意失效。實務上建構可靠通訊通道時,重點放在遮蔽當機與遺漏失效;任意失效則可能以重複訊息的形式出現——訊息在網路中被緩衝了很久,等原始傳送者早已重傳之後,才又被注入網路。

點對點通訊#

許多分散式系統以可靠傳輸協定(如 TCP)建立可靠的點對點通訊:

  • 遺漏失效(訊息遺失)由 TCP 以確認(acknowledgment)與重傳(retransmission)遮蔽,客戶端完全看不到。
  • 當機失效(連線突然中斷)則不會被遮蔽。多數情況下客戶端會收到例外通知;唯一的遮蔽辦法是讓分散式系統自動重新建立連線——重送連線請求,並假設對方仍在(或已恢復)回應。

失效存在下的 RPC 語意#

遠端程序呼叫(Remote Procedure Call, RPC)的目標是讓遠端呼叫看起來像本地呼叫。只要客戶端與伺服器都運作正常,RPC 大致做得到;一旦出錯,本地與遠端呼叫的差異就不容易掩蓋了。RPC 系統中可能發生五類失效,各需不同的處理:

  1. 客戶端找不到伺服器。
  2. 客戶端送往伺服器的請求訊息遺失。
  3. 伺服器收到請求後當機。
  4. 伺服器回覆客戶端的訊息遺失。
  5. 客戶端送出請求後當機。

客戶端找不到伺服器#

可能所有伺服器都掛了;也可能客戶端用舊版 client stub 編譯後放了很久,期間伺服器介面演進、換用新 stub,等舊的二進位檔終於執行時,繫結器(binder)已無法為它匹配到伺服器而回報失敗——這個機制保護客戶端不會誤連參數或功能不相容的伺服器,但「該如何處理這個失敗」的問題仍在。

一種解法是讓錯誤丟出例外:Java 之類的語言可寫例外處理常式,C 可用訊號處理常式(例如定義新訊號 SIGNOSERVER)。缺點有二:不是每種語言都有例外或訊號;更根本的是,這摧毀了我們苦心追求的透明性

想像老闆要你寫一個 sum 程序,你笑著說五分鐘內寫好、測完、附文件——接著她說你還得寫一個例外處理常式,以防「這個程序今天不在」。在單機系統裡為「找不到伺服器」寫例外處理是荒謬的要求,此刻已很難再維持「遠端程序與本地程序無異」的假象。

請求訊息遺失#

五類中最好處理的一種:作業系統或 client stub 在送出請求時啟動計時器,逾時未收到回覆或確認就重送。若訊息真的遺失了,伺服器分不出重傳與原始請求的差別,一切照常。但若遺失太多次,客戶端可能放棄並誤判伺服器已死,又回到「找不到伺服器」的情境。若請求其實沒丟,就得讓伺服器能偵測出這是重傳——這並不簡單,細節見「回覆遺失」的討論。

伺服器當機#

正常流程是:請求到達、執行、回覆。當機可能發生在兩個位置:

  • (b) 執行之後、回覆之前當機:系統必須向客戶端回報失敗(例如丟例外)。
  • (c) 執行之前當機:直接重傳請求即可。

圖 8-7:主從通訊中的伺服器。(a) 正常情況。(b) 執行之後當機。(c) 執行之前當機。

麻煩在於:客戶端的作業系統無法分辨 (b) 與 (c)——它只知道計時器逾時了。對此有三種哲學(Spector):

  • 至少一次語意(at least once semantics):等伺服器重開(或重新繫結到新伺服器)後不斷重試,直到收到回覆。保證 RPC 至少執行了一次,但可能更多次。
  • 至多一次語意(at-most-once semantics):立刻放棄並回報失敗。保證 RPC 至多執行了一次,但可能一次都沒有。
  • 不保證任何事:伺服器當機時客戶端得不到任何幫助與承諾,RPC 可能執行了零到很多次。唯一優點是容易實作。

大家真正想要的是恰好一次語意(exactly once semantics),但一般而言無法做到。

延伸案例:列印伺服器——為何恰好一次做不到

設遠端操作是列印一段文字,伺服器印完會送完成訊息給客戶端;客戶端送出請求時,會收到「請求已送達伺服器」的確認。伺服器有兩種策略:在真正叫印表機動工之前送完成訊息,或在印完之後才送。

假設伺服器當機後復原,並向所有客戶端宣告「我剛當過機,現在恢復了」。客戶端不知道自己的列印請求到底會不會被執行,有四種策略可選:

  1. 永不重發——冒著文字沒印出來的風險。
  2. 一律重發——可能印兩次。
  3. 只在尚未收到「請求已送達」確認時重發——賭伺服器是在請求送達前當掉的。
  4. 只在已經收到確認時重發。

伺服器兩種策略乘上客戶端四種策略,共八種組合。伺服器端有三個事件:送完成訊息(M)、列印(P)、當機(C),可能的順序有六種:M→P→C、M→C(→P)、P→M→C、P→C(→M)、C(→P→M)、C(→M→P)(括號表示因已當機而不再發生的事件)。逐一驗證可知:沒有任何一種客戶端/伺服器策略組合能在所有事件順序下都正確。癥結是客戶端永遠無法知道伺服器是在列印之前還是之後當掉的。

圖 8-8:伺服器當機情境下,客戶端策略與伺服器策略的各種組合。

伺服器可能當機這件事,徹底改變了 RPC 的本質,也清楚劃開單機系統與分散式系統:在單機上,伺服器當機必然意味著客戶端也當了,復原既不可能也無必要;在分散式系統中,採取行動既可能、也必要。

回覆遺失#

同樣靠客戶端計時器逾時重送,但客戶端並不確定為何沒有回音——是請求丟了、回覆丟了,還是伺服器只是慢?這差別可能很重要。

  • 有些操作可以安全地重複執行任意多次而無副作用,例如「讀取檔案前 1024 個位元組」,這種請求稱為冪等(idempotent)
  • 反例:向銀行伺服器請求「把一百萬元從甲帳戶轉到乙帳戶」。若請求已執行但回覆遺失,客戶端重傳,銀行伺服器會當成新請求再執行一次——兩百萬就轉出去了。轉帳不是冪等的。

解法方向:

  • 盡量把所有請求結構化成冪等。但很多請求(如轉帳)本質上就不冪等,需要別的辦法。
  • 讓客戶端為每個請求配序號(sequence number),伺服器記住每個客戶端最近收到的序號,即可分辨原始請求與重傳,拒絕重複執行——但仍得把回應再送一次。代價是伺服器必須為每個客戶端維護管理資訊,而且不清楚要維護多久。
  • 額外的保險:在訊息標頭加一個位元區分初始請求與重傳——初始請求永遠可以安全執行,重傳則需更謹慎處理。

客戶端當機#

客戶端送出請求後、伺服器回覆前當機:一個計算還在進行,卻沒有任何親代在等結果——這種無主的計算稱為孤兒(orphan)。孤兒至少浪費 CPU 週期,還可能鎖住檔案或占用寶貴資源;若客戶端重開後重做 RPC,孤兒的回覆又緊接著回來,還會造成混亂。

Nelson 提出四種解法:

  • 孤兒撲殺(orphan extermination):client stub 送出 RPC 前,先在可跨越當機的媒介(如磁碟)記下日誌;重開後檢查日誌,明確殺掉孤兒。缺點:每個 RPC 都寫一筆磁碟紀錄,代價高昂得嚇人;孤兒自己還可能發 RPC,產生難以定位的「孫孤兒」;網路若因閘道器故障而分割,即使找得到也殺不掉。整體而言不可行。
  • 輪迴(reincarnation):把時間切成依序編號的紀元(epoch)。客戶端重開時廣播「新紀元開始」,收到廣播的機器一律殺掉代表該客戶端執行的遠端計算。免寫磁碟紀錄;即使網路分割讓部分孤兒存活,它們回報時帶著過期的紀元編號,一眼就能識破。
  • 溫和輪迴(gentle reincarnation):收到紀元廣播時,每台機器檢查本地是否有遠端計算,若有則盡力尋找其擁有者,遍尋不著才殺。
  • 到期(expiration):每個 RPC 給定標準時限 T,做不完必須明確申請下一個配額(麻煩之處)。客戶端當機後只要等 T 再重開,孤兒必然已全數消失。難題在於面對需求差異極大的各種 RPC,如何選一個合理的 T。

實務上這些方法都粗糙且不理想。更糟的是,殺孤兒可能有不可預見的後果:孤兒可能已對檔案或資料庫紀錄取得鎖,被突然殺掉後這些鎖可能永遠留著;孤兒也可能已在遠端佇列排了未來要啟動的其他行程,殺掉它也清不掉所有痕跡——它甚至可能再度被啟動。