1. 程式人生 > 實用技巧 >分散式系統(三)(譯)

分散式系統(三)(譯)

3. 時間和順序

分散式系統中的順序是什麼?為什麼它重要?

正如前文描述過的,分散式程式設計就是將在多伺服器上解決單機問題的藝術。

事實上,這就是順序問題的核心。如果一個系統一個時間點上只能專注一件事,那麼所有操作之間就是全序的。就像人們相繼通過一個門一樣,每個操作都有一個確定的前驅者和一個確定的後繼者。基本上,這就是我們努力想要保持的程式設計模型。

傳統程式設計模型是:一個程序獨佔記憶體地執行在主機的一個CPU上。作業系統抽象掉了事實:主機是多CPU,多程式的並且記憶體被多個程式共享。這並不是否認多執行緒程式設計和事件驅。它們只是建立在傳統模型的進一步抽象。程式都是順序執行的:他們從程式開頭,執行到程式尾。

順序之所以收到這麼多的關注,是因為最簡單的定義程式安全的方法就是程式的執行與其在單主機上的執行無異。這通常意味著a)我們執行相同的命令並且b)命令執行的順序不變--即使系統中存在多個主機。

維持命令執行順序的分散式系統好處是通用性。你不需要關注命令本身,因為分散式執行命令與在單機系統的效果一致。這樣的好處是無論使用什麼命令都可以使用同一套系統。

(譯者注,如果有這麼一個命令流let a=10; let b=a+10;。流中有兩個命令。但命令有可能是兩個時間段發出的。在不穩定的網路下,後一個命令很可能先於前一個命令到達其他節點。這樣其他節點計算結果就會出現錯誤。維持命令順序的分散式系統則可以幫開發者避免這個問題,不用過多拘泥於這些命令本身,如有關聯的命令之間的順序等。這就是通用性。)

事實上,一個分散式程式執行在多個節點上:使用著複數的CPU,接受多個命令流。你可以設定一個全序的命令流,但它要麼需要準確的時鐘要麼需要某種形式的通訊。可以通過為每個操作都設定一個準確時鐘下的時間戳來指定全序。或者在某個通訊體系下,按照全域性順序來分配序列號。

全序和偏序

分散式系統命令之間天然是偏序的。網路和相互獨立的節點都無法為命令執行順序順序做出任何保證。但在每個節點上,可以觀察本地執行順序。

全序是指,集合中任兩個元素都可以排序的關係。

兩個不同元素,當一個大於另一個,它們就是可比較的。在偏序集中,部分元素之間是不可比較的,因此偏序無法為集合中所有元素排序。

全序和偏序都是可傳遞和反對稱的。兩個性質的描述如下(適用於全序和偏序集合中任兩個元素):

假設集合X中有a,b,c

If a ≤ b and b ≤ a then a = b (反對稱性);
If a ≤ b and b ≤ c then a ≤ c (傳遞性);

全序的兩個性質通用所有元素,因此

a ≤ b or b ≤ a (totality) for all a, b in X

偏序則只通用單元素(自反性)

a ≤ a (reflexivity) for all a in X

上述可以推測,全序也含自反性。所以可以認為偏序是全序的一個弱化。對於偏序集中的一些元素來說,它們不具有全序性。換句話說,一些元素不可比較。

git的分支也屬於偏序集。如果你有了解,git版本控制系統允許我們從單個基礎分支建立多個其他分支。例如,從一個master分支建立其他slave分支。每一個分支代表著自己的一系列對源於同一份程式碼的修改歷史。

[ branch A (1,2,0)]  [ master (3,0,0) ]  [ branch B (1,0,2) ]
[ branch A (1,1,0)]  [ master (2,0,0) ]  [ branch B (1,0,1) ]
                  \  [ master (1,0,0) ]  /

分支A和分支B都派生於同一個祖分支。但他們之間沒有明確的順序。他們代表不同的修改歷史。如果沒有額外操作(如程式碼合併),他們無法聚合成單個分支。當然,我們可以將所有提交任意排序,但這會損失部分程式碼資訊。

在單節點系統裡,出現全序是必然的:指令在單個程式裡按照特定的順序執行和處理訊息。基於這個指令全序,我們使程式的執行具有可預見性。這個順序也可以在分散式系統中繼續維持,但需要付出代價:通訊是昂貴的,並且時間同步困難且脆弱。

時間的定義

時間是順序的根源。它允許我們去決定指令的順序。巧合的是,時間有一個人類都可以理解的解釋(秒,分鐘,天等等)。

某種意義上,時間跟整數計數器沒什麼區別。它如此重要,所以大多數電腦都有各自的時間感測器,也稱為時鐘。更重要的是,我們明白瞭如何在不完美的物理系統上合成出同個計數器的相似值。通過合成,我們可以通過某些物理屬性得到一個遠方的整數計數器的相似值,而不是直接跟它通訊。

時間戳可以看做從宇宙大爆炸到現在的真實世界狀態的縮影。如果某事發生在特定的時間戳上,它可能受到之前發生的事件的影響。這個想法可以延伸為一個因果時鐘。這個時鐘可以追溯原因(依賴)而不是簡單的假設之前發生的一切都是相關的。當然,我們通常關注的是特定系統的狀態而不是整個世界。

假設各地時間流速都是一樣的。這是個很強的假設。稍後會進行解釋。程式開發中,時間和時間戳有多個解釋。三個解釋分別是:

  • 順序
  • 持續時間
  • 確切時間點

順序。如前面所屬,時間是順序的根源。這裡的含義是

  • 我們可以給未排序的時間加上時間戳進行排序
  • 我們可以通過時間戳來強制一個特定的指令順序或者資訊傳送順序(例如,如果操作亂序抵達,則延遲執行)
  • 我們可以根據時間戳的值來判斷某事時間上是否發生在其他事之前

確切時間點。時間通常是可比較的。時間戳的絕對值可以被理解為一個日期。這有助於人們理解。給定一個從log檔案獲取的故障時間戳,人們可以知道是上個週六。那時正有一場雷暴。

持續時間。持續時間與現實世界具有一些相關性。演算法一般不關心時間戳絕對值或者它的日期解釋,但它可以使用持續時間來做一些判斷。特別地,花費在等待上時間的多少可以提供線索,判斷系統是否分割槽或者僅僅是高延遲。

就其性質而言,分散式系統元件表現都無法預期。它們不會保證確切的順序,執行速率,無延遲。每個節點都有自己的本地順序。命令執行雖然是線性,但這些本地順序都是相互獨立的。

施加(或假設)一個執行順序是縮小執行和結果可能性的方法。人類難以分析無序的事物。因為那有太多的情況需要考慮。

各地的時間流速是否一致?

根據我們個人經驗,我們都有一個直觀的時間概念。不幸的是,這種直觀的時間觀念更有助於描繪全序而不是偏序。理解接連發生的順序比並發更加簡單。根據訊息按單個順序進行推理,比訊息亂序且延遲不定更加簡單。

然而,在實現分散式系統時,我們會避免對時間和順序做出強假設。因為假設越強,系統面對時間感測器問題時越加脆弱。進一步地說,施加順序也會帶來成本。我們能容忍越多的短暫時間內不確定,能利用的分散式算力就越多。

對於“各地時間流速是否一致”問題,一般有三個回答,分別是:

  • “全域性時鐘”:是的
  • “本地時鐘”:不是,但可以一致
  • “無時鐘”:不是

這三個大致與第二章描述的三個時間假設相對應:同步系統模型具有全域性時鐘。部分同步模型具有本地時鐘。在非同步系統中,節點不使用時鐘來確定順序。接下來我們會進一步描述他們。

全域性時鐘下的時間

全域性時鐘假設是指存在一個精度完美的全域性時鐘並且每個人都能訪問它。這也是我們傾向的思考時間方式,因為在人與人的互動中,時間的微小差異無關緊要。

全域性時鐘是全序的根源(所有節點都有正確的命令執行順序即使它們之間沒有進行通訊)。

然而,這是一個理想的世界觀:實際中,時鐘同步僅能做到有限精度。它受限於:商用電腦時鐘精度,使用時鐘同步協議(如NTP)的延遲以及時空性質

假設分散式節點上的時鐘完全同步的,意味著時鐘都從同一時間點開始並永不漂移(譯者注,電腦的物理時鐘每一秒都可能有所偏移,有快有慢,並不完美。經年累月後,會造成時間上的差距。這就是漂移。)。這是一個很好的假設因為你可以自由地使用時間戳來定義全域性所有命令順序——受限於時鐘漂移而不是延遲——但是一個重要 挑戰並且是潛在的異常來源。在很多不同的場景,一個簡單的錯誤——如使用者不小心修改機器的本地時間,或者機器延期加入節點,或者時鐘同步細微的漂移等等,都會造成難以追蹤的異常。

然而,現實中仍然有基於這種假設的系統。Facebook的Cassandra就是假設時鐘同步的示例。它使用時間戳來解決寫衝突-帶有最新時間戳的寫會覆蓋其他。這意味著如果時鐘漂移,新資料可能被忽略或被舊資料覆蓋;再者,這是一個運營挑戰(據我所知,人們已經敏銳意識到)。另一個有趣的例子是Google的 Spanner。相關論文描述它的TrueTime API。API可以同步時間,也可以估量最壞情況下的時間漂移。

本地時鐘下的時間

第二個時間假設,也許更加可信的假設是每臺機器都有自己的時鐘,但沒有全域性時鐘。這意味著你不能使用本地時鐘去確定遠端的時間戳是否在本地時間戳之前或之後;換句話說,無法比較兩臺不同機器上的時間戳,這毫無意義。

本地時鐘假設更加接近真實世界。它指定命令為偏序:每個系統上的事件都是有序的,但是跨系統事件無法使用時鐘進行排序。

然而,單臺機器上,你可以使用時間戳來排序事件;只要小心時鐘不漂移,你也可以指定時間點,用超時多少來排序。當然,在被終端控制的機器上,這個假設依然過強:例如,使用者在使用作業系統日期控制檢視日期時可能失誤地修改日期為其他值。

無時鐘下的時間

最後,是邏輯時間概念。這裡,我們不再使用時鐘,相反用其他方法追蹤因果關係。記住,時間戳只是那個時間點下世界狀態的簡寫。所以我們可以使用計數器和通訊來確定一個事件是發生在其他事件之前,之後還是併發的。

通過這種方法,我們可以確定不同機器上事件的順序,但無法理解事件間隔和使用超時(因為我們假設不存在“時間感測器”)。事件這裡是偏序的:無通訊下,事件在各自系統上通過計數器排序。但是可以通過資訊交換來跨系統排列事件。

分佈系統引用最多的論文之一就是Lamport關於time, clocks and the ordering of events.的論文。向量時鐘就是基於這種概念(我將在後面進一步講述)。它是一種無需借用時鐘便可以追溯緣由的方法。Cassandra的堂兄弟Riak (Basho)和Voldemort (Linkedin)(譯者注,三者都是分散式資料庫)使用了向量時鐘而不是假設節點都能訪問一個完美精度的全域性時鐘。這允許系統避免前面提到的時間精度問題。

當時鐘沒有被使用,跨遠節點的事件排序最大精度受限於通訊延遲。

分散式系統如何使用時間

時間有什麼好處?

  1. 時間可以跨系統定義順序(不用通訊)。
  2. 時間可以定義演算法的邊界條件,

事件順序在分散式系統是很重要的,因為分散式系統許多屬性根據操作/事件順序定義的。

  • 正確性依賴於事件的正確排序,例如分散式資料庫的序列化
  • 出現資源競爭時,順序可以作為仲裁依據,如對於同個部件的兩個訂單,滿足第一個取消第二個。

全域性時鐘允許排序兩臺不同機器上的操作而不用兩臺機器進行直接通訊。如果沒有全域性時鐘,我們需要通訊才能確定順序。

時間可以被用來定義演算法的邊界條件。具體來說,可以用來區分“高延遲”和“伺服器或網路故障”。這是一個非常重要的用例。實際中大部分超時時間都用來確定遠端計算機是否故障,或者僅僅是正在經歷網路高延遲。能夠做出這種判斷的演算法被稱為故障檢測。接下來,我們會討論它們。

向量時鐘(因果排序的時間)

早前,我們討論了分散式系統關於時間流速的不同假設。假設我們不能實現高精度時鐘同步,或者一開始就想要系統在時間問題上不敏感,我們該如何排序事件?

Lamport時鐘和向量時鐘是物理時鐘的替代。他們依賴計數器和通訊來確定整個分散式系統的事件順序。這些時鐘提供了一個各節點之間可比較的計數器。

Lamport時鐘很簡單。每個程序都維護一個計數器,遵守以下規則:

  • 每當程序工作,計數器+1
  • 每當程序傳送一個訊息,計數器+1
  • 每當程序收到訊息,計數器數值為max(local_counter,receiver_counter)+1

程式碼如下

function LamportClock() {
  this.value = 1;
}

LamportClock.prototype.get = function() {
  return this.value;
}

LamportClock.prototype.increment = function() {
  this.value++;
}

LamportClock.prototype.merge = function(other) {
  this.value = Math.max(this.value, other.value) + 1;
}

Lamport時鐘允許計數器在系統中跨節點可比較。Lamport定義了一個偏序。如果timestamp(a)<timestamp(b)

  • a可能發生在b前,或者
  • a與b之間無法比較

這也被稱為時鐘一致性條件:如果一個事件發生在另一個事件之前,那麼事件的邏輯時鐘小於另一個事件。如果a和b是位於同個因果歷史,如兩個事件都是程序產生的;或者b是對a發出的訊息的響應,那麼我們知道a發生在b之前。

可以很直觀看到,上述能排序的原因是Lamport時鐘能攜帶時間線/歷史的資訊;因此,比較兩個從未通訊的系統的Lamport時間戳可能錯誤排序原本併發的事件。

想象一個系統,它度過了初始期後分成兩個從不通訊的子系統。

對於各個互相獨立的系統中所有事件,如果a發生在b前,那麼ts(a)<ts(b);但如果你將兩個來自不同的事件(例如,二者可能毫無關聯)那麼你就從它們的排序得到任何有意義的資訊。儘管系統每個部分都賦予事件時間戳,但時間戳之間毫無關聯。兩個事件可能可以進行排序,即便它們之間毫無關聯。

然而-這依然是一個有用的屬性-從單伺服器的視角來看,任何一個帶有ts(a)的訊息傳送出去,都會有一個ts(b)的相應,而ts(a)<ts(b)

向量時鐘是Lamport時鐘的擴充套件。它維持了一個[t1,t2,...]陣列,包含了N個邏輯時鐘-對應各個節點。不是遞增一個公共的計數器,每個節點都遞增數組裡自己的邏輯時鐘。更新規則如下:

  • 每當程序工作,遞增陣列中屬於自己節點的邏輯時鐘
  • 每當程序傳送一條資訊,帶上自己維護的陣列
  • 每當程序收到一條資訊:
    • 更新自己陣列中每個元素:max(local,received)
    • 遞增自己當前節點的邏輯時鐘

程式碼如下

function VectorClock(value) {
  // expressed as a hash keyed by node id: e.g. { node1: 1, node2: 3 }
  this.value = value || {};
}

VectorClock.prototype.get = function() {
  return this.value;
};

VectorClock.prototype.increment = function(nodeId) {
  if(typeof this.value[nodeId] == 'undefined') {
    this.value[nodeId] = 1;
  } else {
    this.value[nodeId]++;
  }
};

VectorClock.prototype.merge = function(other) {
  var result = {}, last,
      a = this.value,
      b = other.value;
  // This filters out duplicate keys in the hash
  (Object.keys(a)
    .concat(b))
    .sort()
    .filter(function(key) {
      var isDuplicate = (key == last);
      last = key;
      return !isDuplicate;
    }).forEach(function(key) {
      result[key] = Math.max(a[key] || 0, b[key] || 0);
    });
  this.value = result;
};

This illustration (source) shows a vector clock:

三個節點(A,B,C)的每個節點都維護著自己的向量時鐘,

邏輯時鐘的問題主要是每個節點都需要一個條目。這意味著對於大型系統,陣列可能變得非常巨大。大量的技術被應用於減小向量時鐘的大小(通過執行時垃圾回收,或限制大小,降低精度)。

我們已經明白了物理時鐘如何排序和追蹤時間原因。現在,讓我們看看時間

故障檢測

正如此前所說,花費在等待上的時間足以提供線索,用來判斷系統是否分割槽還是僅僅在經歷高延遲。在這個用例下,我們不需要假設一個完美精度的全域性時鐘-一個可依賴的本地時鐘便足夠了。

給定一個程式,執行在單節點上,它怎麼區分遠端節點是否故障?在缺失精確資訊下,我們可以推斷一個在合理時間後仍不相應的節點出現了故障。

但這個“合理時間”是多久?這依賴於本地節點與遠端節點之間的延遲。最好是使用一個合適的抽象進行計算,而不是特定演算法輸入特定值得到的(在某些場景下必然出錯)。

故障檢測是一種抽象出精確時序假設的方法。通過心跳報文和計時器來實現故障檢測。程序之間交換心跳包文。如果報文響應超時未到,程序就會懷疑其他程序。

基於超時檢測的故障檢測會引入風險。節點要麼會過於具有侵略性(魯莽裁斷節點故障),呀麼過度保守(花費太多時間探測節點是否崩潰)。故障檢測精細到怎樣才能具有可用性?

[Chandra等人](http://www.google.com/search?q=Unreliable Failure Detectors for Reliable Distributed Systems) (1996) 在解決共識問題的背景下討論過故障檢測-這是一個密切相關的問題,因為它是大多數複製問題的基礎。資料複製需要副本們在延遲和網路分割槽的環境中達成一致。

他們通過兩個屬性來描述故障檢測:Completeness(完整性)和accuracy(準確性)

Strong completeness Every crashed process is eventually suspected by every correct process.

Weak completeness Every crashed process is eventually suspected by some correct process.

Strong accuracy No correct process is suspected ever.

Weak accuracy Some correct process is never suspected.

completeness比accuracy更容易實現;所有重要的故障檢測都實現了它-你所需要做的不是因為懷疑而永久等待。Charndra等人講到,一個弱完整性的故障檢測可以轉化為強完整性(通過廣播懷疑程序的資訊),允許我們把精力集中到準確性上。

避免不必要地懷疑無故障程序是難以實現的,除非你能假設訊息延遲有其上限。這個假設成立於同步系統模型-因此故障檢測在這樣一個系統裡是強準確性的。在沒限制訊息延遲的系統模型裡,故障檢測最好情況就是最終準確性。

Chandra等人展示了即使一個非常弱的故障檢測-最終性弱錯誤檢測⋄W (最終弱準確性+弱完整性)-也能解決共識問題。下圖(取自論文)闡述了系統模型與問題之間的關係:

正如上圖所示,沒有故障檢測,非同步系統中的問題是無法解決的。因為沒有故障檢測(或者關於時間邊界的強假設 如同步系統模型),確定遠端節點是崩潰還是僅僅經歷高延遲是不可能的。這個區別對於旨在單版本一致性的系統很重要:故障節點可以被忽略因為它們造成了資料分歧,但分割槽節點不能被安全忽略。

如何實現錯誤檢測?概念上說,一個簡單的故障檢測-超時即為故障-沒什麼好實現的。最令人感興趣部分是如何判斷遠端節點是否故障。

理想情況下,我們更希望錯誤檢測能夠根據適應不斷變化的網路和避免把超時值硬編碼進來。例如,Cassandra使用一個應計故障檢測。它能輸出可疑級別(介於0和1)而不是一個二元判斷。這允許應用在權衡精確探查和早起檢測之間決定,

時間,排序和效能

此前,我暗示排序必頂增加成本?這意味著什麼?

如果你正在寫分散式系統,你大概擁有超過一臺計算機。自然狀態它們之間是偏序的,而不是全序。你可以轉化偏序為全序,但這需要節點通訊,等待和嚴格限制在某些時間點工作節點的數量。

所有時鐘僅僅是受限於網路延遲(邏輯時間)或物理條件下的近似值而已。即使維持一個能在各節點之間同步的簡單的計數器也是一個挑戰。

雖然時間和排序經常放在一起討論,但是時間本身並不是很重要。演算法通常不專注於時間,而是更關注許多抽象屬性:

  • 相互之間有因果關係的事件的排序
  • 故障檢測(如,預估訊息延遲的上限)
  • 一致性鎖(如,能夠檢測某些時間點下系統的狀態,這裡不會多加討論)

施加全域性是可能的,但昂貴的。它要求所有程序執行在相同(最低)的速度。通過,保證事件按照定義的順序進行分發最簡單的方法就是指定一個單節點來作為中心,接受所有命令並排序。

時間/排序/同步是否真的必要?需要因地制宜。在一些場景下,我們需要每個操作都能將系統從一個一致性狀態過渡到另一個。例如,在許多用例中,我們想要資料庫的響應都代表著有用資訊並且避免需要去處理系統的不一致問題。

但在另一些用例裡,我們對時間/排序/同步幾乎沒什麼要求。例如,你想要執行一個長計算並且中途無需關注系統。那麼只要答案確保是對的,無需對同步多加關注。

當最終結果僅收系統部分資料影響時,同步都不算有力的工具。排序何時才需要去確保準確性?我們會在最後一章CALM定理中回答這個問題。

在其他用例下,

在接下來的兩個章節,我們會了解強一致容錯系統的資料複製。該系統雖然能夠彈性處理故障,但同時具有強一致性。這個系統為了第一個情形提供瞭解決方法:當你需要確保正確性並願意為其付出代價。然後,我們會討論弱保證

擴充套件閱讀

Lamport clocks, vector clocks

Failure detection

Snapshots

Causality

123