分布式系統解決了傳統單體架構的單點問題和性能容量問題,另一方面也帶來了很多的問題,其中一個問題就是多節點的時間同步問題:不同機器上的物理時鍾難以同步,導致無法區分在分布式系統中多個節點的事件時序。1978年Lamport在《Time, Clocks and the Ordering of Events in a Distributed System》中提出了邏輯時鍾的概念,來解決分布式系統中區分事件發生的時序問題。
什么是邏輯時鍾
邏輯時鍾是為了區分現實中的物理時鍾提出來的概念,一般情況下我們提到的時間都是指物理時間,但實際上很多應用中,只要所有機器有相同的時間就夠了,這個時間不一定要跟實際時間相同。更進一步,如果兩個節點之間不進行交互,那么它們的時間甚至都不需要同步。因此問題的關鍵點在於節點間的交互要在事件的發生順序上達成一致,而不是對於時間達成一致。
綜上,邏輯時鍾指的是分布式系統中用於區分事件的發生順序的時間機制。從某種意義上講,現實世界中的物理時間其實是邏輯時鍾的特例。
為什么需要邏輯時鍾
時間是在現實生活中是很重要的概念,有了時間我們就能比較事情發生的先后順序。如果是單個計算機內執行的事務,由於它們共享一個計時器,所以能夠很容易通過時間戳來區分先后。同理在分布式系統中也通過時間戳的方式來區分先后行不行?
答案是NO,因為在分布式系統中的不同節點間保持它們的時鍾一致是一件不容易的事情。因為每個節點的CPU都有自己的計時器,而不同計時器之間會產生時間偏移,最終導致不同節點上面的時間不一致。也就是說如果A節點的時鍾走的比B節點的要快1分鍾,那么即使B先發出的消息(附帶B的時間戳),A的消息(附帶A的時間戳)在后一秒發出,A的消息也會被認為先於B發生。
那么是否可以通過某種方式來同步不同節點的物理時鍾呢?答案是有的,NTP就是常用的時間同步算法,但是即使通過算法進行同步,總會有誤差,這種誤差在某些場景下(金融分布式事務)是不能接受的。
因此,Lamport提出邏輯時鍾就是為了解決分布式系統中的時序問題,即如何定義a在b之前發生。值得注意的是,並不是說分布式系統只能用邏輯時鍾來解決這個問題,如果以后有某種技術能夠讓不同節點的時鍾完全保持一致,那么使用物理時鍾來區分先后是一個更簡單有效的方式。
如何實現邏輯時鍾
時序關系與相對論
通過前面的討論我們知道通過物理時鍾(即絕對參考系)來區分先后順序的前提是所有節點的時鍾完全同步,但目前並不現實。因此,在沒有絕對參考系的情況下,在一個分布式系統中,你無法判斷事件A是否發生在事件B之前,除非A和B存在某種依賴關系,即分布式系統中的事件僅僅是部分有序的。
上面的結論跟狹義相對論有異曲同工之妙,在狹義相對論中,不同觀察者在同一參考系中觀察到的事件先后順序是一致的,但是在不同的觀察者在不同的參考系中對兩個事件誰先發生可能具有不同的看法。當且僅當事件A是由事件B引起的時候,事件A和B之間才存在一個先后關系。兩個事件可以建立因果關系的前提是:兩個事件之間可以用等於或小於光速的速度傳遞信息。 值得注意的是這里的因果關系指的是時序關系,即時間的前后,並不是邏輯上的原因和結果。
那么是否我們可以參考狹義相對論來定義分布式系統中兩個事件的時序呢?在分布式系統中,網絡是不可靠的,所以我們去掉可以和速度的約束,可以得到兩個事件可以建立因果(時序)關系的前提是:兩個事件之間是否發生過信息傳遞。在分布式系統中,進程間通信的手段(共享內存、消息發送等)都屬於信息傳遞,如果兩個進程間沒有任何交互,實際上他們之間內部事件的時序也無關緊要。但是有交互的情況下,特別是多個節點的要保持同一副本的情況下,事件的時序非常重要。
Lamport 邏輯時鍾
分布式系統中按是否存在節點交互可分為三類事件,一類發生於節點內部,二是發送事件,三是接收事件。注意:以下文章中提及的時間戳如無特別說明,都指的是Lamport 邏輯時鍾的時間戳,不是物理時鍾的時間戳
邏輯時鍾定義
Clock Condition.對於任意事件[Math Processing Error]a, [Math Processing Error]b:如果[Math Processing Error]a→b([Math Processing Error]→表示a先於b發生),那么[Math Processing Error]C(a)<C(b), 反之不然, 因為有可能是並發事件
C1.如果[Math Processing Error]a和[Math Processing Error]b都是進程[Math Processing Error]Pi里的事件,並且[Math Processing Error]a在[Math Processing Error]b之前,那么[Math Processing Error]Ci(a)<Ci(b)
C2.如果[Math Processing Error]a是進程[Math Processing Error]Pi里關於某消息的發送事件,[Math Processing Error]b是另一進程[Math Processing Error]Pj里關於該消息的接收事件,那么[Math Processing Error]Ci(a)<Cj(b)
Lamport 邏輯時鍾原理如下:
- 每個事件對應一個Lamport時間戳,初始值為0
- 如果事件在節點內發生,本地進程中的時間戳加1
- 如果事件屬於發送事件,本地進程中的時間戳加1並在消息中帶上該時間戳
- 如果事件屬於接收事件,本地進程中的時間戳 = Max(本地時間戳,消息中的時間戳) + 1
假設有事件[Math Processing Error]a、b,C(a)、C(b)分別表示事件[Math Processing Error]a、b對應的Lamport時間戳,如果[Math Processing Error]a發生在[Math Processing Error]b之前(happened before),記作 [Math Processing Error]a→b,則有[Math Processing Error]C(a)<C(b),例如圖1中有 [Math Processing Error]C1→B1,那么 [Math Processing Error]C(C1)<C(B1)。通過該定義,事件集中Lamport時間戳不等的事件可進行比較,我們獲得事件的偏序關系(partial order)。注意:如果[Math Processing Error]C(a)<C(b),並不能說明[Math Processing Error]a→b,也就是說[Math Processing Error]C(a)<C(b)是[Math Processing Error]a→b的必要不充分條件
如果[Math Processing Error]C(a)=C(b),那[Math Processing Error]a、b事件的順序又是怎樣的?值得注意的是當[Math Processing Error]C(a)=C(b)的時候,它們肯定不是因果關系,所以它們之間的先后其實並不會影響結果,我們這里只需要給出一種確定的方式來定義它們之間的先后就能得到全序關系。注意:Lamport邏輯時鍾只保證因果關系(偏序)的正確性,不保證絕對時序的正確性。
一種可行的方式是利用給進程編號,利用進程編號的大小來排序。假設[Math Processing Error]a、b分別在節點[Math Processing Error]P、Q上發生,[Math Processing Error]Pi、Qj分別表示我們給[Math Processing Error]P、Q的編號,如果 [Math Processing Error]C(a)=C(b) 並且 [Math Processing Error]Pi<Qj,同樣定義為[Math Processing Error]a發生在[Math Processing Error]b之前,記作 [Math Processing Error]a⇒b(全序關系)。假如我們對圖1的[Math Processing Error]A、B、C分別編號[Math Processing Error]Ai=1、Bj=2、Ck=3,因 [Math Processing Error]C(B4)=C(C3) 並且 [Math Processing Error]Bj<Ck,則 [Math Processing Error]B4⇒C3。
通過以上定義,我們可以對所有事件排序,獲得事件的全序關系(total order)。上圖例子,我們可以進行排序:[Math Processing Error]C1⇒B1⇒B2⇒A1⇒B3⇒A2⇒C2⇒B4⇒C3⇒A3⇒B5⇒C4⇒C5⇒A4
觀察上面的全序關系你可以發現,從時間軸來看[Math Processing Error]B5是早於[Math Processing Error]A3發生的,但是在全序關系里面我們根據上面的定義給出的卻是[Math Processing Error]A3早於[Math Processing Error]B5,可以發現Lamport邏輯時鍾是一個正確的算法,即有因果關系的事件時序不會錯,但並不是一個公平的算法,即沒有因果關系的事件時序不一定符合實際情況。
如何使用邏輯時鍾解決分布式鎖問題
上面的分析過於理論,下面我們來嘗試使用邏輯時鍾來解決分布式鎖問題。
分布式鎖問題本質上是對於共享資源的搶占問題,我們先對問題進行定義:
- 已經獲得資源授權的進程,必須在資源分配給其他進程之前釋放掉它;
- 資源請求必須按照請求發生的順序進行授權;
- 在獲得資源授權的所有進程最終釋放資源后,所有的資源請求必須都已經被授權了。
首先我們假設,對於任意的兩個進程[Math Processing Error]Pi和[Math Processing Error]Pj,它們之間傳遞的消息是按照發送順序被接收到的, 並且所有的消息最終都會被接收到。
每個進程會維護一個它自己的對其他所有進程都不可見的請求隊列。我們假設該請求隊列初始時刻只有一個消息[Math Processing Error](T0:P0)資源請求,[Math Processing Error]P0代表初始時刻獲得資源授權的那個進程,[Math Processing Error]T0小於任意時鍾初始值
- 為請求該項資源,進程[Math Processing Error]Pi發送一個[Math Processing Error](Tm:Pi)資源請求(請求鎖)消息給其他所有進程,並將該消息放入自己的請求隊列,在這里[Math Processing Error]Tm代表了消息的時間戳
- 當進程[Math Processing Error]Pj收到[Math Processing Error](Tm:Pi)資源請求消息后,將它放到自己的請求隊列中,並發送一個帶時間戳的確認消息給[Math Processing Error]Pi。(注:如果[Math Processing Error]Pj已經發送了一個時間戳大於[Math Processing Error]Tm的消息,那就可以不發送)
- 釋放該項資源(釋放鎖)時,進程[Math Processing Error]Pi從自己的消息隊列中刪除所有的[Math Processing Error](Tm:Pi)資源請求,同時給其他所有進程發送一個帶有時間戳的[Math Processing Error]Pi資源釋放消息
- 當進程[Math Processing Error]Pj收到[Math Processing Error]Pi資源釋放消息后,它就從自己的消息隊列中刪除所有的[Math Processing Error](Tm:Pi)資源請求
- 當同時滿足如下兩個條件時,就將資源分配(鎖占用)給進程[Math Processing Error]Pi:
- 按照全序關系排序后,[Math Processing Error](Tm:Pi)資源請求排在它的請求隊列的最前面
- [Math Processing Error]i已經從所有其他進程都收到了時間戳>[Math Processing Error]Tm的消息、
下面我會用圖例來說明上面算法運作的過程,假設我們有3個進程,根據算法說明,初始化狀態各個進程隊列里面都是(0:0)狀態,此時鎖屬於P0。
接下來P1會發出請求資源的消息給所有其他進程,並且放到自己的請求隊列里面,根據邏輯時鍾算法,P1的時鍾走到1,而接受消息的P0和P2的時鍾為消息時間戳+1。
收到P1的請求之后,P0和P2要發送確認消息給P1表示自己收到了。注意,由於目前請求隊列里面第一個不是P1發出的請求,所以此時鎖仍屬於P0。但是由於收到了確認消息,此時P1已經滿足了獲取資源的第一個條件:P1已經收到了其他所有進程時間戳大於1的消息。
假設P0此時釋放了鎖(這里為了方便演示做了這個假設,實際上P0什么時候釋放資源都可以,算法都是正確的,讀者可自行推導),發送釋放資源的消息給P1和P2,P1和P2收到消息之后把請求(0:0)從隊列里面刪除。
當P0釋放了資源之后,我們發現P1滿足了獲取資源的兩個條件:它的請求在隊列最前面;P1已經收到了其他所有進程時間戳大於1的消息。也就是說此時P1就獲取到了鎖。
值得注意的是,這個算法並不是容錯的,有一個進程掛了整個系統就掛了,因為需要等待所有其他進程的響應,同時對網絡的要求也很高。
總結
如果你之前看過2PC,Paxos之類的算法,相信你看到最后一定會有一種似曾相識的感覺。實際上,Lamport提出的邏輯時鍾可以說是分布式一致性算法的開山鼻祖,后續的所有分布式算法都有它的影子。我們不能想象現實世界中沒有時間,而邏輯時鍾定義了分布式系統里面的時間概念,解決了分布式系統中區分事件發生的時序問題。
