您想收到新內容的通知嗎?
自我們加進第二部伺服器的那一刻起,分散式系統便成為 Amazon 的運作日常。本人進 Amazon 的 1999 年當初,我們的伺服器數量還少到能夠取一些容易辨認的名稱,例如 “fishy” 或 “online-01”。然而,就算在 1999 年,分散式運算也並不簡單。如今,分散式系統面臨的挑戰包括延遲、擴展、瞭解聯網 API、封送和解除封送資料,以及諸如 Paxos 等演算法的複雜性。隨著系統迅速增長、同時也更為分散,原本屬於理論的邊界案例,也變成一般會發生的情形。
想要開發出分散式的公用程式運算服務,例如可靠的長途電話網路,或 Amazon Web Services (AWS) 服務,相當的困難。分散式運算也因為有兩個交織的問題存在,以致於比其他形式的運算更奇特且較不符合直覺。對分散式系統而言,影響最大的問題要屬獨立失敗和不確定性。不僅有工程師大多習以為常的典型運算失敗,分散式系統中還有許多方式可發生失敗。更糟的是,無法一直篤定某樣事物是否失敗。
我們藉由整個 Amazon Builders’ Library 來滿足 AWS 如何處理起於分散式系統, 屬於開發和操作方面的複雜問題。還沒有透過其他文章深入並且詳細地解說這些技術之前,值得先就之所以促成分散式運算如此奇特的概念作一番檢視。首先,我們來檢視分散式系統有哪些類型。
分散式系統的類型
硬式即時系統頗為奇特
超人漫畫中有一條情節主軸是,超人遇到名為比扎羅的另一個自我,他住在一切都是顛倒的星球 (比扎羅世界)。比扎羅貌似超人,實際卻是惡人。硬式即時分散式系統也是一樣。乍看挺像一般的運算,實際卻不相同,老實說還有點偏邪惡反派。
硬式即時分散式系統的開發之所以奇特,原因出在:請求/回覆聯網。我們指的不是 TCP/IP、DNS、通訊端或其他此等協定的基本細節。這些主題可能不易理解,總之也類似運算中的其他硬式難題。
硬式即時分散式系統難在:網路讓訊息在容錯網域之間傳送。傳送訊息看似無傷大雅。事實上,凡事從正常開始變得複雜,起點正是從傳送訊息開始。
舉個簡單的例子;請看取自 Pac-Man 實作的以下程式碼片段:因為是作為在單一機器上執行之用,所以不會在任何網路上傳送任何訊息。
board.move(pacman, user.joystickDirection())
ghosts = board.findAll(":ghost")
for (ghost in ghosts)
if board.overlaps(pacman, ghost)
user.slayBy(":ghost")
board.remove(pacman)
return
現在請試想開發此程式碼的網路版,其中板上物件的狀態另以伺服器維護。對板上物件的每一次呼叫,例如 findAll(),可導致兩部伺服器之間傳送和接收訊息。
每當兩部伺服器之間傳送請求/回覆訊息,勢必執行至少同一組的八個步驟。為了瞭解網路化的 Pac-Man 程式碼,我們先複習請求/回覆傳訊的基礎知識。
遍及網路的請求/回覆傳訊
一套來回的請求/回覆操作,一律要有相同的步驟。如下圖所示,用戶端機器 CLIENT 將請求 MESSAGE 透過網路 NETWORK 傳送至伺服器機器 SERVER,後者回覆以訊息 REPLY,這也是透過網路 NETWORK。
一切順利的案例中,會發生下列步驟:
1.POST REQUEST:CLIENT 將請求 MESSAGE 放到 NETWORK 上。
2.DELIVER REQUEST:NETWORK 將 MESSAGE 遞送至 SERVER。
3.VALIDATE REQUEST:SERVER 驗證 MESSAGE。
4.UPDATE SERVER STATE:SERVER 基於 MESSAGE,視需要更新其狀態。
5.POST REPLY:SERVER 將 REPLY 放到 NETWORK 上。
6.DELIVER REPLY:NETWORK 將 REPLY 遞送給 CLIENT。
7.VALIDATE REPLY:CLIENT 驗證 REPLY。
8.UPDATE CLIENT STATE:CLIENT 基於 REPLY,視需要更新其狀態。
僅只一趟來回,就有這麼多步驟! 可是,這些是網路上請求/回覆通訊的定義,無法略去任何一個。例如,步驟 1 不可能跳過。用戶端必須以某種方式將 MESSAGE 放到網路 NETWORK 上。實體上而言,這代表經由網路轉接器傳送封包,如此會造成電子訊號經由構成 CLIENT 與 SERVER 之間網路的一系列路由器,在電線上傳送。這與步驟 2 分立,因為步驟 2 可因獨立的原因失敗,例如 SERVER 突然斷電,無法接受送入的封包。同樣的邏輯可適用於其餘步驟。
可見得網路上的單一請求/回覆,能使一件事情 (呼叫一種方法) 暴增成八件事情。更糟的是如同上述,CLIENT、SERVER 和 NETWORK 可相互獨立各別失敗。先前描述的其中任何步驟失敗,工程師的程式碼皆必須能夠處理。這在典型的工程設計上來說鮮少為真。要瞭解原因,我們先來檢視取自此程式碼單機版的以下表達式。
board.find("pacman")
技術上來說,即使 board.find 的實作本身全無錯誤,此程式碼仍有幾種奇特的方式可在執行階段失敗。例如,CPU 可在執行階段之中自然過熱。機器的電源可能失效,這也是自然性質。核心可能錯誤。記憶體可能填滿,另外 board.find 嘗試建立的某個物件可能無法建立。或者是,所執行機器中的磁碟可能填滿,board.find 可能無法更新某些統計資料檔案,又即使可能不應該,卻傳回錯誤。可能有伽碼射線擊中伺服器,翻轉 RAM 中的一個位元。不過,多半而言,工程師不會為這類事情擔心。例如,單元測試永遠不會涵蓋「萬一 CPU 故障」的情況,也很少涵蓋超出記憶體的情境。
典型的工程設計中,這些類型的失敗僅發生在單機上,亦即為單一容錯網域。例如,若 board.find 方法失敗的原因是 CPU 自然燒壞,即可放心假設整部機器停擺。此錯誤甚至連概念上皆無法處理。對於以上所列的其他類型錯誤,亦可作出類似的假設。您可嘗試為其中某些案例撰寫測試,但典型工程設計上可著墨之處甚少。萬一真發生這些失敗,可放心假設其他一切也失敗。技術上,我們稱之為全部命運共享。命運共享使得工程師必須處理的各種失敗模式大幅減少。
處理硬式即時分散式系統中的失敗模式
由於伺服器和網路並非命運共享,因此處理硬式即時分散式系統的工程師必須測試網路失敗的所有面向。不像單機案例,如果網路失敗,用戶端機器會繼續運作。如果遠端機器失敗,用戶端機器會繼續運作,依此類推。
為詳盡測試前述請求/回覆步驟的失敗案例,工程師必須假設各步驟皆可能失敗。而且必須鑑於這些失敗,確保程式碼 (在用戶端和伺服器兩者上) 永遠行為正確。
以下請看有地方運作無效的一套請求/回覆來回操作:
1.POST REQUEST 失敗:若非網路 NETWORK 未能遞送訊息 (例如,中繼路由器不巧損毀),就是 SERVER 明確拒絕。
2.DELIVER REQUEST 失敗:NETWORK 將 MESSAGE 成功遞送至 SERVER,但 SERVER 一接收 MESSAGE 之後隨即損毀。
3. VALIDATE REQUEST 失敗:SERVER 認定 MESSAGE 無效。這情形幾乎一切原因都有可能。例如,封包損毀、軟體版本不相容,或是用戶端或伺服器有錯誤。
4.UPDATE SERVER STATE 失敗:SERVER 嘗試更新其狀態,但不起作用。
5.POST REPLY 失敗:無論是嘗試回覆以成功或失敗,SERVER 皆可能發佈回覆不成。例如,其網路卡可能不巧燒毀。
6.DELIVER REPLY 失敗:即使 NETWORK 在早先的步驟中能夠運作,NETWORK 仍可能如早先所述未能遞送 REPLY 給 CLIENT。
7.VALIDATE REPLY 失敗:CLIENT 認定 REPLY 無效。
8.UPDATE CLIENT STATE 失敗:CLIENT 能接收訊息 REPLY,但未能更新其本身的狀態;無法理解訊息 (由於不相容),或為了其他原因而失敗。
這些失敗模式就是分散式運算之所以如此困難的原因。我稱之為災難的八大失敗模式。鑑於這些失敗模式,請再次檢視取自 Pac-Man 程式碼的以下表達式。
board.find("pacman")
這個表達式可展開成下列用戶端活動:
1.發佈訊息,例如 {action: "find", name: "pacman", userId: "8765309"} 至網路上,定址到 Board 機器。
2.如果網路無法使用,或與 Board 機器的連線明確遭拒,即提出錯誤。這個案例有些特殊,因為用戶端確知伺服器機器不可能已接收請求。
3.等候回覆。
4.若收不到回覆,便告逾時。在此步驟中,逾時意指請求的結果 UNKNOWN。這可能會、也可能不會發生。用戶端必須正確地處理 UNKNOWN。
5.若收到回覆,判斷是回覆為成功、回覆為錯誤,還是無法理解/損懷的回覆。
6.若不是錯誤,將回應解除封送,轉成程式碼能理解的物件。
7.若是錯誤或無法理解的回覆,提出例外。
8.凡是處理此例外者皆必須判定是應該重試請求,還是應該放棄並停止運作。
這個表達式也從下列伺服器端活動開始:
1.接收請求 (可能完全不發生)。
2.驗證請求。
3.查看使用者,瞭解該使用者是否仍在。(伺服器可能由於過久全無收到使用者的訊息,恐怕已放棄該使用者。)
4.更新該使用者的保留表,讓伺服器知道其 (大概) 還在。
5.查看使用者的位置。
6.發佈回應,所含內容例如 {xPos: 23, yPos: 92, clock: 23481984134}。
7.如有任何進一步的伺服器邏輯,必須能正確處理用戶端未來的效應。例如,未能接收訊息、雖接受但無法理解、雖接收但損毀,或處理成功。
總之,一般程式碼內的一個表達式,在硬式即時分散式系統的程式碼中會變成額外十五個步驟。有此擴展,是由於用戶端和伺服器之間每次來回通訊,有八個不同的點可能失敗。任何代表網路上一趟來回的表達式,例如 board.find("pacman"),皆可導致以下情形。
(error, reply) = network.send(remote, actionData)
switch error
case POST_FAILED:
// handle case where you know server didn't get it
case RETRYABLE:
// handle case where server got it but reported transient failure
case FATAL:
// handle case where server got it and definitely doesn't like it
case UNKNOWN: // i.e., time out
// handle case where the *only* thing you know is that the server received
// the message; it may have been trying to report SUCCESS, FATAL, or RETRYABLE
case SUCCESS:
if validate(reply)
// do something with reply object
else
// handle case where reply is corrupt/incompatible
此複雜性無可避免。如果程式碼無法正確處理所有案例,服務最終會以怪異的方式失敗。請試想,假若為諸如 Pac-Man 這個範例的用戶端/伺服器系統可能進入的所有失敗模式嘗試撰寫測試,會是何等情景!
測試硬式即時分散式系統
為單機版 Pac-Man 程式碼片段進行測試,過程極為直接了當。建立幾個不同的板上物件,使其進入不同狀態,建立不同狀態下的幾個使用者物件,依此類推。工程師會對邊緣條件最用心思考,可能會運用衍生設計,或模糊器。
在 Pac-Man 程式碼中,用到板上物件的有四處。在分散式的 Pac-Man 中,在該程式碼中有四處有五種可能的結果,如早先所示 (POST_FAILED、RETRYABLE、FATAL、UNKNOWN 或 SUCCESS)。因而造成測試的狀態空間巨幅倍增。例如,硬式即時分散式系統的工程師必須處理許多排列。假設對 board.find() 的呼叫以 POST_FAILED 失敗。接著,您必須測試以 RETRYABLE 失敗時會發生什麼事,然後必須測試若以 FATAL 失敗時會發生什麼情形,依此類推。
但即使那樣測試,仍不足夠。在典型的程式碼中,工程師可假設若 board.find() 可運作,則對板的下一次呼叫 board.move() 也會有效。在硬式即時分散式系統的工程設計中,並無法做這樣的保證。伺服器機器隨時可能獨立失敗。結果,工程師必須為對板每一呼叫的所有五種案例撰寫測試。假設一位工程師為了測試單機版 Pac-Man,想出 10 種情況。但,對於分散式系統的版本,必須為其中每一種情況測試 20 次。亦即測試矩陣從 10 個膨脹為 200 個!
先等一下,不只如此。工程師也可能負責伺服器程式碼。無論發生什麼樣的用戶端、網路和伺服器端的錯誤組合,都必須測試到用戶端和伺服器不致於落入損毀狀態的結果。伺服器程式碼的形式類似如下。
handleFind(channel, message)
if !validate(message)
channel.send(INVALID_MESSAGE)
return
if !userThrottle.ok(message.user())
channel.send(RETRYABLE_ERROR)
return
location = database.lookup(message.user())
if location.error()
channel.send(USER_NOT_FOUND)
return
else
channel.send(SUCCESS, location)
handleMove(...)
...
handleFindAll(...)
...
handleRemove(...)
...
有四個伺服器端的功能待測試。假設單機上的每項功能各有五項測試。就有 20 項測試。因為用戶端會傳送多重訊息給同一部伺服器,所以測試應當模擬不同請求的序列,以確保伺服器維持強健。請求的範例包括 find、move、remove 和 findAll。
假設一組建構有 10 種不同的情況,每種情況平均有三個呼叫。這就多了 30 項測試。不過,一種情況也需要測試失敗案例。對於這些測試,您必須分別模擬如果用戶端收到四種失敗模式中的任一 (POST_FAILED、RETRYABLE、FATAL 和 UNKNOWN),接著以無效請求再度呼叫伺服器,會發生什麼情形。例如,用戶端可能呼叫 find 成功,但若呼叫 move,有時會得回 UNKNOWN。其可能因為某個理由再度呼叫 find。伺服器是否能正確處理這個案例? 有可能,但除非測試,否則無法得知。因此,如同用戶端程式碼,伺服器端的測試矩陣也複雜度暴增。
處理不明的未知
考量分散式系統所能遇到失敗的所有排列,這會相當驚人,涵蓋多個請求之下尤其如此。對分散式工程設計,我們發現一種因應之道:將一切分散。程式碼的每一行,除非不可能造成網路通訊,否則皆可能未如預期奏效。
也許最難處理的一件事,是早先章節中舉出的 UNKNOWN 錯誤類型。用戶端不見得永遠知道請求成功與否。可能其雖然確實移動 Pac-Man (或者在銀行服務中,從使用者的銀行戶頭提款),但也可能沒有。工程師該如何處理這種事情? 這相當困難,因為工程師是人,而人容易為真實的不確定性感到辛苦。人類習慣看見的程式碼像以下這樣。
bool isEven(number)
switch number % 2
case 0
return true
case 1
return false
人類能理解此程式碼,因為做的跟看起來做的一致。人類會對此程式碼的分散版感到辛苦,那會將部分工作分散給服務。
bool distributedIsEven(number)
switch mathServer.mod(number, 2)
case 0
return true
case 1
return false
case UNKNOWN
return WHAT_THE_FARG?
人幾乎不可能揣測得出該如何正確處理 UNKNOWN。UNKNOWN 究竟是指什麼? 程式碼該重試嗎? 如果是,應該幾次? 重試之間應該等待多久? 程式碼有副作用時,情況更糟。在單機上執行的預算應用程式內,自戶頭提款很容易,如以下範例所示。
class Teller
bool doWithdraw(account, amount)
switch account.withdraw(amount)
case SUCCESS
return true
case INSUFFICIENT_FUNDS
return false
然而,該應用程式的分散版卻因為 UNKNOWN 而變得奇特。
class DistributedTeller
bool doWithdraw(account, amount)
switch this.accountService.withdraw(account, amount)
case SUCCESS
return true
case INSUFFICIENT_FUNDS
return false
case UNKNOWN
return WHAT_THE_FARG?
想出如何處理 UNKNOWN 錯誤類型,是在分散式工程設計中,為何事物不見得合乎預期的原因之一。
硬式即時分散式系統的群集
災難的八大失敗模式可在分散式系統內的任何抽象層級發生。早先舉出的範例侷限在單一用戶端機器、網路和單一伺服器機器。即使在如此簡化的情況,失敗狀態矩陣的複雜性仍然暴增。真實的分散式系統比起單一用戶端機器的範例,有更複雜的失敗狀態矩陣。真實的分散式系統以多重機器所組成,可從多重抽象層級檢視:
1.個別機器
2.機器群組
3.機器群組的群組
4.依此類推 (潛在而言)
例如,AWS 上建置的服務可將專門處理特定「可用區域」內資源的機器合為一組。可能還有另外兩個機器群組處理其他兩個「可用區域」。接著,這些群組可併到「AWS 區域」群組。該「區域」群組可能 (邏輯上) 與其他「區域」群組通訊。可惜的是,即使到了這樣較高、更有邏輯的層級,仍適用所有相同的難題。
假設某項服務將一些伺服器組成一個邏輯群組 GROUP1。群組 GROUP1 可能偶爾會傳送訊息到另一個伺服器群組 GROUP2。這是遞迴分散式工程設計的範例。早先所述的相同聯網失敗模式,在此也全都適用。假設 GROUP1 想傳送請求給 GROUP2。如下圖所示,兩部機器的請求/回覆互動就像先前討論的單一機器。
無論哪種方式,GROUP1 內的某機器必須將訊息放到網路 NETWORK 中,並定址 (邏輯上) 到 GROUP2。GROUP2 以內的某機器必須處理請求,依此類推。GROUP1 和 GROUP2 是由機器群組所構成的事實不會改變基礎條件。GROUP1、GROUP2 和 NETWORK 仍能彼此分開,獨立失敗。
然而,那只是群組層級的觀點。各群組織之內也有機器之間層級的互動。例如,GROUP2 可能如下圖所示建構而成。
最初經由負載平衡器傳送訊息給 GROUP2,到該群組內的機器 (可能是 S20)。系統設計師知道,UPDATE STATE 階段中,S20 可能會故障。因此,S20 可能需要將訊息傳遞到至少其他另一部機器,可以是一部對等機器,或是其他群組中的一部機器。S20 實際上如何辦到? 藉由將請求/回覆訊息傳送給例如 S25,如下圖所示。
於是,S20 遞迴地執行聯網。同樣地,同樣八種失敗全部可能獨立發生。分散式工程設計發生兩回,而非一次。GROUP1 至 GROUP2 的訊息在邏輯層級上能以全部八種方式失敗。該訊息導致另一個訊息產生,其本身可獨立以早先所述的全部八種方式失敗。測試此情況至少需涉及下列幾點:
• GROUP1 至 GROUP2 群組層級傳訊所有八種可能失敗方式的測試。
• S20 至 S25 伺服器層級傳訊所有八種可能失敗方式的測試。
這個請求/回覆傳訊的範例顯示出,對於分散式系統,即使已累積 20 多年經驗,其測試仍為格外惱人難題的原因何在。依照邊緣案例之廣大,測試相當具有挑戰性,但這對這類系統而言尤其重要。系統部署之後,可能要花很長時間之後錯誤才會浮現。加上錯誤能對系統及其相鄰系統帶來無可預料的寬廣影響。
分散式的錯誤經常為隱含性
如果終將發生錯誤,按照一般智慧,早一點發生比較好些。例如,服務的擴展問題需要六個月來修復,因此最好能在服務必須達到此等規模的至少六個月之前發現。同樣道理,最好能在錯誤觸及正式作業之前發現。如果錯誤真的觸及正式作業,最好能迅速找到,以免影響許多客戶或形成其他不良效應。
出現分散式的錯誤,代表肇因於未能處理災難八大失敗模式的全部排列,往往相當嚴重。長時間下來的範例在大型分散式系統中相當豐富,從電信系統到核心網際網路系統皆包括在內。這類中斷不僅波及範圍廣泛又所費不貲,而且可由數月之前部署至正式作業的錯誤所造成。此外還需要一點時間,方能觸發實際導致這些錯誤發生 (以及蔓延至全系統) 的情況組合。
分散式的錯誤蔓延時如同疫病
在此說明屬於分散式錯誤根本的另一個難題:
1.分散式錯誤勢必涉及網路的使用。
2.因此,分散式錯誤更具散播至其他機器 (或機器群組) 的可能,因為依照定義即已牽連將機器連結在一起的唯一一件事物。
Amazon 也經歷過這類分散式錯誤。有個雖老但貼切的範例就是 www.amazon.com 的全站台失敗。該次失敗起因於遠端型錄服務內的單一伺服器,於磁碟填滿時失敗。
由於該錯誤條件的處理有誤,遠端的型錄伺服器開始就收到的每一請求傳回空白回應。並且也開始非常迅速地回傳,因為傳回無物,比有物來得快速許多 (至少以此案例而言)。同時,網站與遠端型錄服務之間的負載平衡器並未注意到那些回應的長度全為零。倒是有注意到,這裡比其餘的遠端型錄伺服器火速許多。於是便從 www.amazon.com 傳送龐大的流量至磁碟已滿的那一部遠端型錄伺服器。實際上,全網站之所以關閉,是因為一部遠端伺服器無法顯示任何產品資訊。
我們很快便找到故障的伺服器並自服務移除,將網站復原。接著我們以判斷根本原因的尋常程序加以追蹤,找出問題以預防情況再次發生。我們對全 Amazon 分享這些學習經驗,以利杜絕其他系統出現相同的問題。除了學習此失敗模式的具體相關課程之外,此事件也成為絕佳範例,說明分散式系統中,失敗模式如何地迅速傳播並且無以預料。
分散式系統問題摘要
簡而言之,分散式系統的工程設計之所以困難,其原因如下:
• 工程師無法將錯誤條件加以組合。而是必須考量許多失敗的排列。大多數的錯誤隨時都可能發生,與任何其他錯誤條件之間相互獨立 (因此有可能合併)。
• 任何網路操作的結果可以是 UNKNOWN,該情況下請求可能成功、失敗,或雖收到但未處理。
• 分散式問題能在分散式系統的所有邏輯層級發生,並非限於低層級的實體機器。
• 由於遞迴,分散式問題到了系統較高層級會更加惡化。
• 分散式錯誤經常在部署至系統已久之後才顯現。
• 分散式錯誤可散播遍及整個系統。
• 上述問題之中有許多是從聯網的物理法則所衍生,無法改變。
只因為分散式運算困難並且奇特,不代表這些問題無法解決。我們遍及 Amazon Builders’ Library 深入挖掘 AWS 如何管理分散式系統。希望我們習得的所知,在您為客戶進行建置時能發揮高價值助益。
作者簡介
Jacob Gabrielson 是 Amazon Web Services 的資深首席工程師。他已在 Amazon 服務 17 年,主要工作領域是內部微型服務平台。過去 8 年,他的工作項目為 EC2 和 ECS,包括軟體部署系統、控制平面服務、Spot 市場、Lightsail 和最近的容器專案。Jacob 熱衷於系統程式設計、程式設計語言和分散式運算。最不喜歡雙峰系統行為,尤其在失敗情況下。他擁有西雅圖華盛頓大學的電腦科學學士學位。