分散式系統的硬核:時間時鐘問題和演算法

banq發表於2019-05-18

分散式系統中時間是核心概念,依靠時間多個機器才能協同互動。分散式資料庫 微服務互動都逃不過這個硬核。本文概括了物理時鐘和邏輯時鐘等概念。
作為軟體工程師,我們都依賴於時間概念:確保我們程式中的事件遵循時間順序的關鍵概念。然而,呼叫“獲取當前時間”的簡單呼叫可能會產生意外結果,如果使用不當會導致無法預料的後果。此外,我們在本地開發機器上觀察到的關於時間的不變性可能並不一定存在於雲中或任何分散式系統中。先上結論:
  • 使用System.nanoTime()用於測量時間間隔
  • 使用System.currentTimeMillis()獲得的掛鐘時間
  • 使用Clock.systemUTC().instant()用於獲取掛鐘時間與NS 精度
  • 即使它的精度很高,也不是每個時鐘都能為您提供所需的解析度
  • 掛鐘時間可以關閉幾十毫秒(或更多,或更少)
  • 如果時間同步很重要,請使用雲提供商提供的NTP
  • 邏輯時鐘可能比實際時鐘更合適,但它們具有相關的成本

時間屬性:

1. 單調性
單調遞增函式意味著對於這種函式的每次後續呼叫,所產生的值永遠不會小於任何先前值。因此,單調時鐘是永不倒退的時鐘。可悲的是,令人驚訝的是,這個屬性不是許多時鐘的特徵。

2. 解析度Resolution
解析度是第二個屬性。它是兩個時鐘週期之間最小的可觀察差異。帶有秒針的簡單機械錶的解析度為1秒。當你盯著手錶時,手錶位置可以是12秒或13秒,但不會是12點半秒。

3.延遲Latency
當我們談論時鐘時,通常會忽略延遲,但是當我們考慮其他屬性(如解析度)時,它非常重要。例如,如果你手上有最精確的原子手錶,皮秒picosecond解析度也沒關係 - 如果我問你現在是什麼時間,你需要大約一秒,有時更少,有時甚至更多,看看回應,所有這些精度都消失了。

那麼,Java時鐘有哪些屬性,它們如何應用於我們在開始時看到的問題?

掛鐘
讓我們從System.currentTimeMillis()開始吧,通常,開始探索的最佳位置是用Javadoc編寫的文件,並且有很多內容可供參考。下面是對我們現在最重要的內容的摘錄。

/**
 * Returns the current time in milliseconds. Note that
 * while the unit of time of the return value is a millisecond,
 * the granularity of the value depends on the underlying
 * operating system and may be larger.  For example, many
 * operating systems measure time in units of tens of
 * milliseconds.
 *
 * ...
 *
 * @return  the difference, measured in milliseconds, between
 *          the current time and midnight, January 1, 1970 UTC.
 */
public static native long currentTimeMillis();


我們可以看到,時鐘為我們提供了毫秒級的精度值,但實際的解析度取決於作業系統。此外,如果我們透過測量執行時間來測量延遲,它將會低於1毫秒。
但java的時鐘是可以倒退嗎?Javadoc沒有提到任何關於單調性的東西,所以我們需要深入挖掘,並看一下實現。
本文僅探討Linux和MacOS的本機實現。但是,類似的技術也可以應用於其他作業系統。

該方法是原生的,因此實現取決於底層作業系統。Linux和MacOS的本機實現看起來幾乎完全相同。

Linux

jlong os::javaTimeMillis() {
  timeval time;
  int status = gettimeofday(&time, NULL);
  assert(status != -1, "linux error");
  return jlong(time.tv_sec) * 1000  +  jlong(time.tv_usec / 1000);
}

蘋果系統

jlong os::javaTimeMillis() {
  timeval time;
  int status = gettimeofday(&time, NULL);
  assert(status != -1, "bsd error");
  return jlong(time.tv_sec) * 1000  +  jlong(time.tv_usec / 1000);
}


這些函式呼叫完全相同的gettimeofday系統呼叫。手冊頁可以為我們提供更多資訊,但更重要的是提供一些有價值的說明:手冊頁

NAME
       gettimeofday, settimeofday - get / set time

NOTES
       The time returned by gettimeofday() is affected by discontinuous
       jumps in the system time (e.g., if the system administrator manually
       changes the system time).  If you need a monotonically increasing
       clock, see clock_gettime(2).


如上所述,時間受到系統時間的不連續跳躍的影響,這可能是向後的,因此時鐘不是單調的。第三個問題的答案是肯定的,這是有道理的:如果我們將當前時間改為一小時,我們仍然希望currentTimeMillis返回當前時間,即使當前時間的定義已經改變。這就是為什麼它通常被稱為掛鐘時間,如果我們調整它,掛鐘也可以及時跳回來

當前時間的納秒nanos 
可以採用相同的路徑探索研究System.nanoTime()。讓我們從Javadoc開始,它比前一個更具有吸引力的細節; 這是一段摘錄
顯然,這個時鐘返回的時間與任何現實世界的時間無關; 它只能用於比較同一個JVM例項中的時間戳,它相對於將來可能存在的任意“原點”,因此它可能是負數。類似地currentTimeMillis,該方法提供納秒級精度,但不一定是納秒級解析度。
System.nanoTime()納秒時間只能用於測量時間間隔,所以它應該是單調的,對吧?不幸的是,Javadoc沒有說出單調性,所以下一步就是具體實現。

Linux

jlong os::javaTimeNanos() {
  if (os::supports_monotonic_clock()) {
    struct timespec tp;
    int status = Linux::clock_gettime(CLOCK_MONOTONIC, &tp);
    assert(status == 0, "gettime error");
    jlong result = jlong(tp.tv_sec) * (1000 * 1000 * 1000) + jlong(tp.tv_nsec);
    return result;
  } else {
    timeval time;
    int status = gettimeofday(&time, NULL);
    assert(status != -1, "linux error");
    jlong usecs = jlong(time.tv_sec) * (1000 * 1000) + jlong(time.tv_usec);
    return 1000 * usecs;
  }
}


這是第一個驚喜:奈米時間確實是單調的,但只有底層作業系統支援它。公平地說,任何現代Linux伺服器都支援CLOCK_MONOTONIC; 

蘋果系統

jlong os::javaTimeNanos() {
  const uint64_t tm = mach_absolute_time();
  const uint64_t now = (tm * Bsd::_timebase_info.numer) / Bsd::_timebase_info.denom;
  const uint64_t prev = Bsd::_max_abstime;
  if (now <= prev) {
    return prev;   // same or retrograde time;
  }
  const uint64_t obsv = Atomic::cmpxchg(now, &Bsd::_max_abstime, prev);
  assert(obsv >= prev, "invariant");   // Monotonicity
  // If the CAS succeeded then we're done and return "now".
  // If the CAS failed and the observed value "obsv" is >= now then
  // we should return "obsv".  If the CAS failed and now > obsv > prv then
  // some other thread raced this thread and installed a new value, in which case
  // we could either (a) retry the entire operation, (b) retry trying to install now
  // or (c) just return obsv.  We use (c).   No loop is required although in some cases
  // we might discard a higher "now" value in deference to a slightly lower but freshly
  // installed obsv value.   That's entirely benign -- it admits no new orderings compared
  // to (a) or (b) -- and greatly reduces coherence traffic.
  // We might also condition (c) on the magnitude of the delta between obsv and now.
  // Avoiding excessive CAS operations to hot RW locations is critical.
  // See https://blogs.oracle.com/dave/entry/cas_and_cache_trivia_invalidate
  return (prev == obsv) ? now : obsv;
}


突出的第一件事是巨大的註解說明塊。作為軟體工程師,我們知道如果有很長的評論,那麼必須要進行一些狡猾的事情。
實際上,評論非常有趣。呼叫mach_absolute_time使用下面的RDTSC指令可能會導致在具有多個CPU插槽的機器上出現非單調行為,類似機械同情郵件列表上另一個發人深省的討論。

所以,至少,我們可以確信在MacOS上奈米時間總是單調的,對吧?實際上,它取決於JVM版本。

上面列出的程式碼是在JDK-8040140的 JDK9中引入的,並且後向相容到JDK8。

當毫秒不夠時
在微秒精度的情況下gettimeofday遠遠超過System.currentTimeMillis(),但在轉換過程中精度丟失。

jlong os::javaTimeMillis() {
  timeval time;
  int status = gettimeofday(&time, NULL);
  assert(status != -1, "linux error");
  return jlong(time.tv_sec) * 1000  +  jlong(time.tv_usec / 1000);
                                                      // ^^ precision loss
}


作業系統可以為我們提供額外的資訊,我們會將其暴力丟棄,以便將其整合到一個jlong內。

如果我們真的想知道這些微觀怎麼辦?在JDK 8中,新的JSR 310到達,這使得有可能獲得一個Instant類的例項,其中包含自紀元以來的秒數和自上一秒開始以來的納秒數。

JSR 310:日期和時間API

Instant instant = Clock.systemUTC().instant();
long epochSecond = instant.getEpochSecond();
int nanoSinceSecond = instant.getNano();


最後,所有Java開發人員都可以高精度地訪問掛鐘時間,對吧?不是那麼快,如果我們看看JDK8中的實現,我們會發現它只是直接代表System.currentTimeMillis()。

JDK8時鐘

@Override
public long millis() {
    return System.currentTimeMillis();
}
@Override
public Instant instant() {
    return Instant.ofEpochMilli(millis());
}


顯然,這不是最優的,並且相應的問題JDK-8068730已經解決,因此精度提高了。它需要對JDK9 +進行更新,其中該方法在Linux上使用以下實現委託給本機呼叫。假設您的作業系統可以提供微秒解析度,這個時鐘就是具有納秒精度的時鐘的一個很好的例子,但只有微秒的解析度

JDK9 +時鐘

void os::javaTimeSystemUTC(jlong &seconds, jlong &nanos) {
  timeval time;
  int status = gettimeofday(&time, NULL);
  assert(status != -1, "linux error");
  seconds = jlong(time.tv_sec);
  nanos = jlong(time.tv_usec) * 1000;
}


時間交換
以微秒解析度獲得當前掛鐘時間的可能性很大,但經常需要嗎?使用掛鐘時間的原因之一是能夠將在一臺機器上發生的事件與在不同機器上發生的另一事件相關聯,或者更準確地說,決定這些事件的順序

這些事件的性質可能非常不同。其中一些可能不是非常關鍵,例如日誌行上的時間戳,但其中一些必須是正確的,例如由於同時寫入兩個值而資料庫中存在衝突,並且時間戳用於確定哪個事件是持續。這種策略稱為Last Write Wins,或簡稱為LWW。

兩個客戶端Alice和Bob正在嘗試同時寫入有兩個節點的最終一致的webscale資料庫。當Alice寫的第一個值成功同步時,Alice的第二次寫入恰好與Bob的同時發生。在這種情況下,資料庫必須解決衝突,以便所有節點之間的資料保持一致。在LWW的情況下,將透過比較每次寫入的時間戳來選擇最新的寫入。如果時鐘完全同步,LWW可以完美地工作,但是,如果時鐘同步性差並且第一個節點的時鐘漂移到第二個節點之前,則LWW變為Lucky Write Wins - 連線到幸運節點的客戶端總是贏得衝突

NTP
確保群集中不同節點上的時鐘同步的標準方法是使用網路時間協議(NTP)。NTP不僅有助於同步時鐘,還有助於傳播閏秒標誌。
處理閏秒的傳統方法是“飛躍塗抹”。負責飛躍模糊的NTP伺服器可以在引入第二個之前的12小時之前和之後的12小時內分配額外的秒。在這24小時內的掛鐘時間越來越慢,每秒鐘的時間延長了1/86400,這可能會令人驚訝,但不會比跳回時間更令人驚訝。問題是沒有多少NTP伺服器支援跳躍模糊,公共NTP伺服器絕對不會。
主要的雲提供商GoogleAWS都為NTP服務提供了飛躍塗抹支援。如果您的應用程式託管在提供NTP服務的平臺上並且您關心時鐘同步,那麼檢查NTP同步是否與提供商的NTP服務一起設定是值得的。它不僅可以幫助避免應用閏秒的惡劣後果,而且還可以顯著降低同步錯誤,因為在單個資料中心內網路延遲通常要低得多。

在最好的情況下,使用本地NTP伺服器可以將時鐘漂移降低到毫秒甚至微秒,但最壞的情況是什麼?關於這個主題的研究不多,但Google Spanner論文中提到了一些值得注意的結果。

在同步之間,守護程式宣告緩慢增加的時間不確定性。ε來自保守應用的最壞情況本地時鐘漂移。ε還取決於時間主站的不確定性和時間主站的通訊延遲。在我們的生產環境中,ε通常是時間的鋸齒函式,在每個輪詢間隔內從大約1到7毫秒變化。ε?因此大部分時間都是4毫秒。守護程式的輪詢間隔當前為30秒,當前應用的漂移率設定為200微秒/秒,它們共同佔據了0到6毫秒的鋸齒邊界。

- Spanner:Google的全球分散式資料庫


邏輯時鐘
即使我們叢集中的監控顯示時鐘與微秒精度同步,我們也需要謹慎,如果這種假設的失敗是不可接受的,我們的軟體就不應該依賴它。因此,如果失敗是不可接受的,我們需要知道分散式系統中事件的順序,那麼我們能做些什麼呢?與往常一樣,學術界提出了許多解決方案。

1.Lamport時鐘
我們需要的是對系統時鐘的可靠替代,因此對於每兩個事件A和B,我們可以說A發生在B之前,或B發生在A之前。事件之間的這種順序稱為總順序。
“時間,時鐘和分散式系統中事件的排序”一文中,Leslie Lamport描述了“之前發生”關係和邏輯時鐘,可用於使用以下演算法定義一組事件的總順序。

傳送訊息:

time = time + 1;
send(message, time);



收到訊息:

(message, ts) = receive();
time = max(ts, time) + 1;


在這種情況下,每個參與者Alice和Bob每次傳送訊息時將透過維護增加的time計數器來維持當前時間的共享檢視,並且當接收到訊息時,time總是大於上次接受訊息時最後觀察到的計數器。這樣,如果Alice更新資料庫的值為2,並告訴Bob關於最後一個已知狀態,Bob的最終寫入是先檢視Alice計數器,因此它被選為資料庫的最終狀態。

只要我們需要定義系統中捕獲因果關係的事件的總順序,這就完美地工作。重要的是要注意,具有總順序意味著併發事件將以某種方式排序,而不一定是最合乎現實邏輯的方式。向量時鐘解決這個問題。

向量時鐘/向量時鐘
為了處理真正的併發事件,我們需要一個新的定義或命令,它能夠表達事件可以同時發生的情況。這種順序稱為部分順序。這意味著對於任何兩個事件A和B,可以說A是否發生在B之前,B發生在A或A和B同時發生之前。
為了確定部分順序,可以使用以下演算法,其中每個actor都有一個單獨的時間計數器,並跟蹤系統中任何其他actor的最新時間戳。

傳送訊息:

V[myId] = V[myId] + 1
send(message, V);


收到訊息:

(message, Vr) = receive();
for (i, v) in Vr {
    V[i] = max(V[i], v);
}
V[myId] = V[myId] + 1;[/i][/i]


該演算法在1988年描述,後來在Dynamo論文中描述了使用向量時鐘在資料庫中進行衝突解決。

(與前面邏輯時鐘計數器不同的是,向量時鐘是邏輯時鐘的列表,比如Alice[0,0],前面一個0是Alice的時間,後面的是Bob時間),Alice透過這種列表跟蹤她自己的時間計數器以及Bob的最後已知時間計數器。這樣,當Alice向Bob傳送訊息時,他更新了他的計數器Alice[1,0],並且在衝突解決期間選擇傳送到資料庫的下一個訊息,而Bob的時間向量的每個分量都大於前一個向量的相應分量。

當存在真正的衝突時,向量時鐘可以幫助確定事件是否真正併發。
比如兩個節點都收到[0, 1] 和[0, 1],這兩個事件不能排序,在這種情況下,資料庫可以保留這兩個值,並在下次讀取時返回它們,讓Alice或Bob決定保留哪個值,以便資料不會丟失。
但是,這些屬性並非免費提供。需要與每條訊息交換後設資料,並且需要儲存多個版本。畢竟,像Cassandra這樣的一些資料庫不會出於某種原因使用向量時鐘。

 

相關文章