《Designing Data-Intensive Applications》ch 8— The Trouble with Distributed Systems

一個沒那麼肥的肥宅
今天的天空,有點藍
15 min readMar 4, 2021

前言:《Designing Data-Intensive Applications》這一系列的文章是想分享我閱讀《Designing Data-Intensive Applications》這本書的筆記。希望可以給自己的學習歷程留下一些什麼,也希望對想了解這方面知識的人有一些幫助。

前面幾章節討論了關於系統面對異常狀況該如何處理,例如 replica failover、replication lag 以及 transaction 的 concurrency control 等。實際上前面提到的那些問題只是開胃小菜而已,真實的問題會令人更加手足無措。在這章我們將舉出非常極端的例子,探討當可能出現問題的系統都出現問題時的情形。

在單一一台電腦中,軟體運行得正常與否往往只有成功與失敗兩種結果。這是由於電腦一開始就被刻意設計成如此:在電腦產生錯誤的時候,我們傾向直接讓軟體崩潰,而不是傳回一個錯誤的回應,這是因為錯誤的結果會更令人困惑。然而在分散式系統中,工程師是在許多不同台的電腦上撰寫軟體,機器彼此透過網路溝通。這可能產生 partial failure,意即系統中的某些部分運作正常,而另一部分卻表現得不正常。當我們嘗試與這樣的系統互動時,可能因為這種不穩定的狀態產生,系統的回應變得 nondeterministic。Nondeterministic 與 partial failures 存在的可能性正是讓分散式系統如此困難的原因。

大部分分散式系統中使用的是網路來做為溝通的媒介,但由於網路是 asynchronous 的,這會使得當訊息在傳遞時,無法知道發生異常時的情形究竟是怎麼回事:需求遺失、需求還在佇列等待中、遠端的 node 無法運作、回應遺失等等 ( 圖8–1 )。更糟的是發送訊息者甚至無法確定封包是否有傳送出去,僅剩唯一的選擇是等待接收者的回應。人們通常使用 timeout 來應對這個情形,只是即使當 timeout 發生時,還是無法確定接收者到底有沒有收到請求。

Figure 8–1. If you send a request and don’t get a response, it’s not possible to distinguish whether (a) the request was lost, (b) the remote node is down, or (c) the response was lost(DDIA)

電腦網路已經發展了好幾十年,這讓大家認為人們應該已經找出了讓電腦網路可靠的方法了,可惜的是,事實並非如此。研究顯示一個中型的 datacenter 每個月平均有 12 個網路問題;EC2 經常會有暫時性的服務異常;甚至連鯊魚也會咬斷海底電纜等等。因此,工程師必須要了解,當網路發生問題的時候軟體是如何應對,以及系統該如何從中恢復。

在很多時候,我們都需要正確地判斷系統中的 nodes 是否正常運作:Load balancer 需要停止對已經不能運作的 nodes 發送請求、在 single-leader replication 中,如果 leader 出現問題時,需要在 followers 中選出新的 leader。那麼我們該如何斷定某個 node 是異常的呢?

由於網路的不確定性,使這個問題更加困難。運氣好一點的話,我們可能可以透過系統中 nodes 如預期地回報錯誤來作為判斷。但考慮一般情形,必須要假設他們完全不會回應。這時候可以做的是嘗試多發送幾次訊息並且等待一段時間,假如還是沒有接受到回應再將其視為運作不正常。

如果 timeout 是唯一個偵測錯誤發生時可以做的事情,那麼 timeout 究竟該設定為何?長的 timeout 指的是要讓使用者等較久的時間才能夠知道 node 異常;短的 timeout 卻可能會造成讓使用者誤認正常運作中的 node 是有問題的,假如某個 node 實際上正常,只是在執行發送郵件等需要較長時間的行為時,由於 timeout 短而被認為異常,其工作內容被另一個 node 接管之後,或許會產生重複寄送郵件的錯誤。更糟的情況是,如果某一部分的系統已經忙不過來,由於不正確的 timeout 所產生的轉移工作內容將使其更無法負擔。在最極端的情形下,所有 nodes 互相認定彼此異常,那麼造成全部系統停止運作。

理想情形下,我們可以假設網路中傳遞封包的最大延遲 d,而 node 需要花上 r 的時間處理封包,那麼 timeout 定為 2d+r 似乎是個合理的設定。不幸地,大部分的系統都無法提供這樣的保證:asynchronous 的網路延遲沒有上限、服務器也無法確保他們處理封包時間的上限。

當我們在開車的時候,經過路網所需的時間通常會與交通是否壅塞息息相關。網路也有類似的道理,封包的延遲也跟 queueing 有關連。如果不同 nodes 同時將封包發送到同一個位址,網路交換器會先將他們 queue 起來而一一傳送到該位址 ( 如圖 8–2 )。當網路連接十分忙碌時 ( 稱為 network congestion ),封包必須要不斷等待直到有空位。如果這個時候 queue 太滿了而無法再承受,那麼該封包就有遺失的風險。網路的延遲性還有跟 TCP 的 flow control ( congestion avoidancebackpressure )、作業系統的忙碌與否、甚至是同個網路中的其他用戶的使用情形等等許多因素有關。

考量到種種上述的原因,要決定 timeout 的大小只能根據實驗了。根據多台機器與多次來回的傳輸量測出其時間分布,取得期望值之後再搭配自身應用程式的特性來選定。當然更好一些的情形,就像 Akka 與 Cassandra 所用的 Phi Accrual failure detector,系統可以持續地量測回應的時間與其變化而自動調整 timeout 的設定。

Figure 8–2. If several machines send network traffic to the same destination, its switch queue can fill up. Here, ports 1, 2, and 4 are all trying to send packets to port 3(DDIA)

那麼,我們是否可以讓網路在硬體層級就擁有更穩定的延遲以及更可靠的保證,讓軟體不用擔心這些問題呢?比方說在傳統實體電話的線路中,電話擁有相當低的延遲以及足夠的頻寬來傳送聲音的訊號,我們是否能從中得到啟發?

電話網路實際上建立了 curcuit:發送與接收電話的兩端之間有個固定的、保證的頻寬,這樣的頻寬會維持到電話結束。…。這種網路是 synchronous的,不需要 queue 儲存資料,因此最大的延遲就被固定住了,被稱為 bounded delay

電話網路與 TCP 連線有著巨大的差異:當固定頻寬的 circuit 建立之後沒有其他的人能夠使用;而 TCP 的封包卻會在有機會時使用更多網路的頻寬。TCP 連線可以根據資料的大小使用頻寬。當沒有資料要傳輸時,就不會消耗任何頻寬。那為什麼網路要使用這種方式呢?這是為了應付突發的流量 ( bursty traffic )。Circuit 適合在聲音或影像等的訊號擁有比較固定的 bits/s 使用,而造訪網頁、傳送郵件或檔案等沒有特定的頻寬要求,因此我們希望他們能夠越快完成越好。

我們或許可以將延遲的可變性想成是動態資源分配的結果。當資源是靜態時就分配好,那麼對於可用資源的利用就沒有那麼充分;反之,當資源分配比較動態的時候,好處是資源使用率較高,但會有延遲的可變性產生。因此與其將網路中出現的可變延遲視為與生俱來,不如視為一種 trade-off 吧!

時鐘是另一個值得討論的議題。應用程式經常需要依賴時鐘,像是某個需求是否 timeout、使用者花了多久造訪網站、文章什麼時候被發布、在 log 檔裡面的某個錯誤訊息是什麼時候發生的等等。在分散式系統中,由時鐘所產生的問題尤其困難,除了有網路中延遲的可變性,還要考慮不同機器上各自擁有自己所認知的時間。以下將簡介現代電腦中常見有兩種的時鐘:time-of-day clock monotonic clock

Time-of-day clock 就跟大家印象中掛在牆壁上的時鐘的概念類似,所代表的是某個日期與時間點,比方說 Linux 的 clock_gettime(CLOCK_REALTIME) 與 Java 的 System.currentTimeMillis() 指的是 UTC 從 1970 年 1 月 1 號凌晨所經過的時間。這種時鐘可以用 Network Time Protocol ( NTP ) 來協助校正,不過如果與 NTP 差異過大,可能導致校準時被重設而 “跳” 到另一個時間點,因此不太適合用在量測程序執行過程所花費時間。

Monotonic clock 的名稱來自於他們顯示的數字只會單調地遞增 ( 相比於 time-of-day clock 可能會跳回較小的數字 ),因此比較適合用來量測如 timeout 或是某個服務的回應時間等的時間間隔,在 Linux 的 clock_gettime(CLOCK_MONOTONIC) 與 Java 的 System.nanoTime() 可以看到這樣的例子。如果 NTP 發現到某個電腦裡面的石英鐘跑的太快或太慢,可以透過調整其 monotonic clock 的頻率來修正,比方說加快或是變慢 0.05% 之類的。

時鐘的不準確性所延伸出的問題可能比我們想像的都還複雜,尤其是在當軟體相當依賴時鐘的時候。圖 8–3 顯示的是在 multi-leader replication 中使用 time-of-day clock 的情形,即使在這個例子中時鐘已經校正的相當好了,node 1 與 node 3 只差不到 3ms,但在這裡如果使用 LWW 來解決衝突,那麼 node 2 會以為 x = 1 是最新的值而將來自 client B 的 x += 1 捨棄。

Figure 8–3. The write by client B is causally later than the write by client A, but B’s write has an earlier timestamp(DDIA)

再來考慮另一個在分散式系統中的時鐘可能會造成的問題。還記得在 single leader 的 database 中,只允許 leader 操作 write,那麼 leader 要怎麼知道自己仍然是個正常的 leader 而沒有被其他 nodes 認為異常呢?我們可以讓 leader 取得某種其他 nodes 所給的租約 ( lease )。這個 lease 無論何時都只會有一個 node 擁有,而擁有這個 lease 的 node 在租約到期前都仍然是leader。為了要維持leader的身分,該 node 必須定期更新租約,如果停止更新則其他 nodes 就會將它視為異常。程式碼範例如下:

while (true) 
{
request = getIncomingRequest();

// Ensure that the lease always has at least 10 seconds
//remaining
if (lease.expiryTimeMillis — System.currentTimeMillis() < 10000)
{
lease = lease.renew();
}

if (lease.isValid())
{
process(request);
}
}

這短短的程式碼可能有什麼問題?第一個是必須確保時鐘是同步的,如果不同步,那麼設定租約的機器的時間會與本地系統的時間有落差。第二是其假設了在檢查時間 ( System.currentTimeMillis() ) 與處理需求 ( process(request) ) 之間的時間極短,一般情況下這樣子是合理的。然而在遇到像是某個執行緒剛好意外地在那個時候暫停了 15 秒、或是 JVM 的 garbage collector 偶然地停止了所有的執行緒等的特殊情況中,卻會導致不可預期的錯誤。

截至目前為止我們討論了許多分散式系統與單一電腦上執行程序的差異:沒有共享記憶體而是透過不可靠且延遲相當不穩定的網路溝通、系統會有 partial failures、不可靠的時鐘以及程序不定時的暫停。這些種種的原因都會導致分散式系統的所具有的複雜性。這似乎開始衍伸出了哲學性的問題:我們到底對於系統的了解有多少?在多個 nodes 中傳輸的訊息中,哪些訊息才是可以相信的知識呢?如果量測的對象都不可靠,那我們要依照什麼原則才能知道訊息的真確性?又或者說,到底什麼才是真相而什麼是謊言呢?

系統中經常會需要某些事情具有唯一性,也就是只有某個對象能夠做某些事情。比方說為了避免 split brain 只能允許某一個 node 可以成為 leader;為了避免同時 write 某個資源,只能允許某個 transaction 或是 client 能有該物件的 lock;一個使用者名稱只能唯一對應到某個使用者。不過即使某個 node 已經是某個 ”唯一” 了,只要多數的 nodes 不這麼認為,那麼他就不值得被大家所信任了,這似乎代表了某種程度上,所謂的真相實際上是透過多數決來產生的。舉例來說,在分散式系統的例子中,如果在多數的 nodes 認為某個 node 已經無法運作了,那麼即使該 node 認為自己仍然是正常的而繼續原本身分所該做的事情,將會讓系統系統產生問題。圖 8–4 就是這樣的例子,

Figure 8–4. Incorrect implementation of a distributed lock: client 1 believes that it still has a valid lease, even though it has expired, and thus corrupts a file in storage(DDIA)

當遇到這種情形時,我們必須確保整個系統不會因為某個 node 自身錯誤的認知而影響到系統。在上面的例子中,我們可以在 lock server 給予 lock 的時候也附帶了一個單調遞增的 fencing token。透過這樣的 fencing token 來達成如圖 8–5 的結果。

Figure 8–5. Making access to storage safe by allowing writes only in the order of increasing fencing tokens(DDIA)

Fencing tokens 可以用來偵測並防止某個 node 非刻意地發生錯誤。但當某個 node 故意地想要欺騙系統中的其他 nodes 時 ( ex:發送假的 fencing token ),就是所謂的 Byzantine fault,而關於在這樣的情況中達成全體一致的共識的問題被叫做 Byzantine Generals Problem,在第九章會有更多討論是關於解決該問題的共識演算法。

如果某個系統可以承受這種錯誤,我們稱其 Byzantine fault-tolerant。在這本書中我們可以暫時假設沒有這種錯誤存在,一方面是在自己所維護的 datacenter 中,所有的 nodes 理論上都是操之在自己手中;另一方面,要能夠達成這樣目標的系統,其代價之高讓它相當難以在現實世界中被廣泛使用。

在第九章將會介紹一些企圖解決分散式系統問題的演算法。但回過頭來,我們首先需要更進一步釐清問題的種類以及設法將其更正式化的表述,在此可以藉由 system model 這種抽象的形式來定義這種問題。如果是根據時間點的問題,我們可以大致分類成 Synchronous model、Partially synchronous model 與 Asynchronous model;如果是根據 node 的異常來分類,常見的也有 Crash-stop faults、Crash-recovery faults 與 Byzantine (arbitrary) faults 這幾種。

為了要確保某個演算法的正確性,我們還需要描述其性質。舉例來說,在排序演算法中排序完成的串列中,任一個元素都不會小於其左邊的元素。類似地,在 fencing tokens 的演算法中,我們要確保其結果具有唯一性、單調遞增以及可用性等等。

定義清楚代表問題的抽象模型以及演算法所應具備的性質之後,對於驗證演算法的正確性有相當高的好處。不過,當要真正應用這樣的演算法時,真實世界的複雜性往往會又賞了我們一巴掌,這當然會讓人覺得建立並描述前面這種抽象的模型是徒勞無功的。不過其實這種過程可以讓我們更了解將真實世界的複雜性,並將問題更清楚地分類,而且至少是個解決困難問題的第一步。

--

--