JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

分散式系統裡的時間:Lamport 時鐘

在一台機器裡,單一的硬體時鐘可以為每個事件蓋上時間戳,於是你永遠知道誰先發生。把這台機器攤開到網路上,那個共用的時鐘就消失了——而「誰先發生?」竟成了一個出奇深刻的問題。這篇導覽會說明為什麼跨機器時,牆上時鐘永遠不能完全信任,以及 Lamport 的邏輯時鐘如何只回復「剛剛好夠用」的順序,讓分散式系統保持清醒。

為什麼你不能就看一眼時鐘

在單一機器上,為事件排序是再簡單不過的事:只有一個 CPU 時鐘在嘀嗒作響,核心可以拿它的讀數為每個事件蓋上一個編號。當兩次寫入打到同一個檔案,作業系統知道誰先,因為它就在同一條時間軸上看著它們經過。本階段第一篇導覽就警告過,分散式系統恰恰放棄了這份舒適——既沒有共用記憶體,也沒有共用時鐘。每台機器都有自己的石英晶體,以自己那略微偏差的速率嘀嗒。問兩台機器「現在幾點?」,你會得到兩個不同的答案。

情況比「差一點點」還糟。晶體會隨溫度漂移,所以兩台機器時鐘之間的差距會隨時間擴大。你先前認識的、用來對抗這件事的協定——時鐘同步(NTP 就是日常的例子)——會把每台機器往一個參考時間推;但它頂多只能逼,永遠無法精確。那則承載著正確時間的訊息本身,要花一段無法預測的時間穿越網路,所以答案在抵達時就已經過期了。同步之後,兩台機器仍可能差個幾毫秒,而那就足以毀掉你:在快速網路上,一毫秒內可以發生非常多事件。

我們真正需要的:誰在誰之前,而不是幾點鐘

這裡有個 Leslie Lamport 在 1978 年得到的、令人豁然開朗的洞見。多數時候我們其實不需要知道一個事件發生的真實時間。我們需要知道的是它相對於其他事件的順序——哪次寫入勝出、哪個請求先來、回覆有沒有可能取決於那個提問。而在分散式系統裡,因果只透過兩條管道流動:同一台機器上的事件有確定的本地順序,而一則訊息必須先被送出才能被收到。除此之外的一切都是真正無序的,假裝它有序才是錯誤。

Lamport 用一個他取名為發生在先(happened-before)的關係捕捉了這件事,並以一個箭頭書寫。我們說事件 A 發生在事件 B 之先(A -> B),若下列任一成立:A 與 B 在同一個行程上,且 A 在該行程的本地序列中較早;或 A 是某則訊息的送出、而 B 是同一則訊息的收到;或存在一條鏈——A -> C 且 C -> B——於是這關係能穿過中間的事件(它具有遞移性)。若 A -> B 與 B -> A 皆不成立,這兩個事件就是並行的(concurrent):沒有任何途徑能讓一者影響另一者,所以系統可以自由地以任意順序對待它們。

請留意這謹慎的用詞:發生在先講的是潛在的因果,而非真實時間。A -> B 意指 A 有可能影響了 B,而不是它確實影響了。而並行也不代表「在字面上的同一瞬間」——它代表「因果上不相關,所以順序無所謂」。這呼應了你攀登路上更早的一個區分:行程之間的訊息傳遞是各自獨立的角色在不共享狀態下協調的方式,而在這裡,它是唯一把兩台機器的時間軸縫在一起的那條線。

Lamport 時鐘:一個計數器,兩條小規則

Lamport 時鐘便宜得令人吃驚:每個行程持有單一個整數計數器,從 0 起算。沒有硬體、沒有同步、不需要任何網路交談來維護它。這個計數器並不是一天裡的某個鐘點——它只是一個會長大的數字,設計成「若 A -> B,則蓋在 A 上的數字小於蓋在 B 上的數字」。兩條規則就統御著它,而這就是整個機制了。

  1. 在一個行程做任何它視為事件的動作之前(一個本地步驟、送出一則訊息、或收到一則),它先把自己的計數器加 1。
  2. 當一個行程「送出」一則訊息,它把自己當前的計數器值當作時間戳附在訊息上,隨資料一同捎去。
  3. 當一個行程「收到」一則帶著時間戳 t 的訊息,它把自己的計數器設為 max(本地計數器, t),然後再加 1——取兩者中較大的,並跨過它一步。

收到時那個「先取最大再加一」正是巧妙的轉軸。它逼迫接收者的時鐘躍過發送者的送出事件,於是「收到」保證帶著比「送出」更大的數字。與本地遞增合起來,這恰好足以讓時間戳尊重發生在先:沿著任何一條因果鏈走,數字都嚴格地往上爬。我們來看它跑一遍。

Three processes. Each event shows its Lamport timestamp.
Time flows left to right; arrows are messages.

P1:  a(1) ---- b(2) -------------
                  \ msg ts=2
                   v
P2:  ----------- c(3) ---- d(4) --
                              \ msg ts=4
                               v
P3:  e(1) ------------------- f(5)

Why these numbers:
  a,b : P1 ticks 1,2 locally
  c   : P2 receives ts=2  -> max(0,2)+1 = 3
  d   : P2 ticks locally  -> 4
  e   : P3 ticks locally  -> 1
  f   : P3 receives ts=4  -> max(1,4)+1 = 5

Check: b->c (a message) gives 2 < 3.  Good.
       d->f (a message) gives 4 < 5.  Good.
       a(1) and e(1) are CONCURRENT: equal numbers,
       no causal path either way.
一段三行程的軌跡。請注意收到規則把 P2 的時鐘從本地的 0 推上 3,以及兩個都被蓋上 1 的事件(a 與 e)是真正並行的。

它給你什麼——以及那個誠實的陷阱

Lamport 時鐘給的保證是一條單行道,而精確地陳述它很要緊:若 A -> B,則 timestamp(A) < timestamp(B)。永遠如此。這確實有用——它代表一個較小或相等的時間戳,絕不可能來自一個其實由較大者所引發的事件,於是你可以把所有事件排成單一條一致的線(用行程編號來打破平手),而永遠不會把果排到因之前。這正是像分散式互斥這類協定所需的原料:要決定誰先進入臨界區間,每個請求者廣播一則帶時間戳的請求,最小時間戳者勝出。沒有中央鎖、沒有共用時鐘——只有那個計數器。

但要對那個陷阱誠實,因為每個人第一次都會中招。這個蘊涵只朝一個方向成立。由 A -> B 你可以推出 timestamp(A) < timestamp(B);你不可以把箭頭倒著跑。看到 timestamp(A) < timestamp(B) 並告訴你 A 發生在 B 之先——它們可能完全並行,只是碰巧落在那些數字上(再看一次 P3 的事件 e 怎麼拿到 1、而 P1 的 a 也拿到 1;或想像兩個不相關的事件在不同機器上分別被編為 3 與 5)。Lamport 時鐘能認證因果,卻無法偵測因果的缺席。

這擺在哪裡,又往哪裡去

退一步看這個想法的形狀。在一台機器上,為事件排序是免費的,因為硬體遞給你一條共用的時間軸——這也正是單機記憶體一致性模型能談論「一次讀取看到哪次寫入」的原因。把共用時間軸拿走,你就必須製造一個順序,材料只有網路給你的那一點點因果線索:本地序列,以及訊息的「送出在收到之先」。Lamport 時鐘就是把那些線索變成一致數字的最小機具。這是分散式系統思維的一個漂亮範例——別再追逐你得不到的全域真相,轉而設計出你真正需要的那個較弱、但做得到的性質。

這套排序機具是仍在前方的那些更難問題的根基。本階段最後一篇導覽要處理共識——讓一群機器就單一的值、或單一的操作順序達成一致,即使其中有些當機、或訊息遺失。那些建立「已複製、已議定之記錄」的演算法(想想複製背後的複製狀態機)正是倚賴這個「一致的事件順序」的概念。Lamport 時鐘本身並不解決共識,但它把詞彙交到你手上——發生在先、並行、打過平手的全序——讓共識在這套詞彙中變得可以被言說。