分佈式事務中的時間戳,老大難了…

本文作者: Eric Fu

本文鏈接: https://ericfu.me/timestamp-in-distributed-trans/

時間戳(timestamp)是分佈式事務中繞不開的重要概念,有意思的是,現在主流的幾個分佈式數據庫對它的實現都不盡相同,甚至是主要區分點之一。

本文聊一聊時間戳的前世今生,爲了把討論集中在主題上,假設讀者已經對數據庫的 MVCC、2PC、一致性、隔離級別等概念有個基本的瞭解。

爲什麼需要時間戳?

自從 MVCC 被髮明出來之後,那個時代的幾乎所有數據庫都拋棄(或部分拋棄)了兩階段鎖的併發控制方法,原因無它——性能太差了。當分佈式數據庫逐漸興起時,設計者們幾乎都選擇 MVCC 作爲併發控制方案。

併發控制的幾種方法

MVCC 的全稱是多版本併發控制(Multi-Version Concurrency Control),這個名字似乎暗示我們一定會有個版本號(時間戳)存在。然而事實上,時間戳還真不是必須的。MySQL 的 ReadView 實現就是基於事務 ID 大小以及活躍事務列表進行可見性判斷。

事務 ID 在事務開啓時分配,體現了事務 begin 的順序;提交時間戳 commit_ts 在事務提交時分配,體現了事務 commit 的順序。

分佈式數據庫 Postgres-XL 也用了同樣的方案,只是將這套邏輯放在全局事務管理器(GTM)中,由 GTM 集中式地維護集羣中所有事務狀態,併爲各個事務生成它們的 Snapshot。這種中心化的設計很容易出現性能瓶頸,制約了集羣的擴展性。

另一套方案就是引入時間戳,只要比較數據的寫入時間戳(即寫入該數據的事務的提交時間戳)和 Snapshot 的讀時間戳,即可判斷出可見性。在單機數據庫中產生時間戳很簡單,用原子自增的整數就能以很高的性能分配時間戳。Oracle 用的就是這個方案。

MVCC 原理示意:比較 Snapshot 讀取時間戳和數據上的寫入時間戳,其中最大但不超過讀時間戳的版本,即爲可見的版本

而在分佈式數據庫中,最直接的替代方案是引入一個集中式的分配器,稱爲 TSO(Timestamp Oracle,此 Oracle 非彼 Oracle),由 TSO 提供單調遞增的時間戳。TSO 看似還是個單點,但是考慮到各個節點取時間戳可以批量(一次取 K 個),即便集羣的負載很高,對 TSO 也不會造成很大的壓力。TiDB 用的就是這套方案。

MVCC 和 Snapshot Isolation 有什麼區別?前者是側重於描述數據庫的併發控制實現,後者從隔離級別的角度定義了一種語義。本文中我們不區分這兩個概念。

可線性化

可線性化(linearizable)或線性一致性意味着操作的時序和(外部觀察者所看到的)物理時間一致,因此有時也稱爲外部一致性。具體來說,可線性化假設讀寫操作都需要執行一段時間,但是在這段時間內必然能找出一個時間點,對應操作真正“發生”的時刻。

線性一致性的解釋。其中 (a)、(b) 滿足線性一致性,因爲如圖所示的時間軸即能解釋線程 A、B 的行爲;(c) 是不允許的,無論如何 A 都應當看到 B 的寫入

注意不要把一致性和隔離級別混爲一談,這完全是不同維度的概念。理想情況下的數據庫應該滿足 strict serializability,即隔離級別做到 serializable、一致性做到 linearizabile。本文主要關注一致性。

隔離性(Isolation)與一致性(Consistency)

TSO 時間戳能夠提供線性一致性保證。完整的證明超出了本文的範疇,這裏只說說直覺的解釋:用於判斷可見性的 snapshot_ts 和 commit_ts 都是來自於集羣中唯一的 TSO,而 TSO 作爲一個單點,能夠確保時間戳的順序關係與分配時間戳的物理時序一致。

可線性化是一個極好的特性,用戶完全不用考慮一致性方面的問題,但是代價是必須引入一箇中心化的 TSO。我們後邊會看到,想在去中心化的情況下保持可線性化是極爲困難的。

TrueTime

Google Spanner 是一個定位於全球部署的數據庫。如果用 TSO 方案則需要橫跨半個地球拿時間戳,這個延遲可能就奔着秒級去了。但是 Google 的工程師認爲 linearizable 是必不可少的,這就有了 TrueTime。

TrueTime 利用原子鐘和 GPS 實現了時間戳的去中心化。但是原子鐘和 GPS 提供的時間也是有誤差的,在 Spanner 中這個誤差範圍 εε 被設定爲 7ms。換句話說,如果兩個時間戳相差小於 2ε2ε ,我們就無法確定它們的物理先後順序,稱之爲“不確定性窗口”。

Commit Wait in TrueTime

Spanner 對此的處理方法也很簡單——等待不確定性窗口時間過去

在事務提交過程中 Spanner 會做額外的等待,直到滿足 TT.now()−Tstart>2εTT.now()−Tstart>2ε,然後纔將提交成功返回給客戶端。在此之後,無論從哪裏發起的讀請求必然會拿到一個更大的時間戳,因而必然能讀到剛剛的寫入。

Lamport 時鐘與 HLC

Lamport 時鐘是最簡單的邏輯時鐘(Logical Clock)實現,它用一個整數表示時間,記錄事件的先後/因果關係(causality):如果 A 事件導致了 B 事件,那麼 A 的時間戳一定小於 B。

當分佈式系統的節點間傳遞消息時,消息會附帶發送者的時間戳,而接收方總是用消息中的時間戳“推高”本地時間戳:Tlocal=max(Tmsg,Tlocal)+1Tlocal=max(Tmsg,Tlocal)+1。

Lamport 時鐘

Lamport Clock 只是個從 0 開始增長的整數,爲了讓它更有意義,我們可以在它的高位存放物理時間戳、低位存放邏輯時間戳,當物理時間戳增加時邏輯位清零,這就是 HLC(Hybrid Logical Clock)。很顯然,從大小關係的角度看,HLC 和 LC 並沒有什麼不同。

HLC Timestamp

HLC/LC 也可以用在分佈式事務中,我們將時間戳附加到所有事務相關的 RPC 中,也就是 Begin、Prepare 和 Commit 這幾個消息中:

  • Begin:取本地時間戳 local_ts 作爲事務讀時間戳 snapshot_ts
  • Snapshot Read: 用 snapshot_ts 讀取其他節點數據(MVCC)
  • Prepare:收集所有事務參與者的當前時間戳,記作 prepare_ts
  • Commit:計算推高後的本地時間戳,即 commit_ts = max{ prepare_ts } + 1

HLC/LC 並不滿足線性一致性。我們可以構造出這樣的場景,事務 A 和事務 B 發生在不相交的節點上,比如事務 TATA 位於節點 1、事務 TBTB 位於節點 2,那麼這種情況下 TATA、TBTB 的時間戳是彼此獨立產生的,二者之前沒有任何先後關係保證。具體來說,假設 TATA 物理上先於 TBTB 提交,但是節點 2 上發起的 TBTB 的 snapshot_ts 可能滯後(偏小),因此無法讀到 TATA 寫入的數據。

T1: w(C1)
T1: commit
T2: r(C2)   (not visible! assuming T2.snapshot_ts < T1.commit_ts)

HLC/LC 滿足因果一致性(Causal Consistency)或 Session 一致性,然而對於數據庫來說這並不足以滿足用戶需求。想象一個場景:應用程序中使用了連接池,它有可能先用 Session A 提交事務 TATA(用戶註冊),再用 Session B 進行事務 TBTB(下訂單),但是 TBTB 卻查不到下單用戶的記錄。

如果連接池的例子不能說服你,可以想象一下:微服務節點 A 負責用戶註冊,之後它向微服務節點 B 發送消息,通知節點 B 進行下訂單,此時 B 卻查不到這條用戶的記錄。根本問題在於應用無法感知數據庫的時間戳,如果應用也能向數據庫一樣在 RPC 調用時傳遞時間戳,或許因果一致性就夠用了。

有限誤差的 HLC

上個小節中介紹的 HLC 物理時間戳部分僅供觀賞,並沒有發揮實質性的作用。CockroachDB 創造性地引入了 NTP 對時協議。NTP 的精度當然遠遠不如原子鐘,誤差大約在 100ms 到 250ms 之間,如此大的誤差下如果再套用 TrueTime 的做法,事務延遲會高到無法接受。

CockroachDB 要求所有數據庫節點間的時鐘偏移不能超過 250ms,後臺線程會不斷探測節點間的時鐘偏移量,一旦超過閾值立即自殺。通過這種方式,節點間的時鐘偏移量被限制在一個有限的範圍內,即所謂的半同步時鐘(semi-synchronized clocks)。

下面是最關鍵的部分:進行 Snapshot Read 的過程中,一旦遇到 commit_ts 位於不確定性窗口 [snapshot_ts, snapshot_ts + max_clock_shift] 內的數據,則意味着無法確定這條記錄到底是否可見,這時將會重啓整個事務(並等待 max_clock_shift 過去),取一個新的 snapshot_ts 進行讀取。

CockroachDB 的 Read Restart 機制

有了這套額外的機制,上一節中的“寫後讀”場景下,可以保證讀事務 TBTB 一定能讀到 TATA 的寫入。具體來說,由於 TATA 提交先於 TBTB 發起,TATA 的寫入時間戳一定小於 B.snapshot_ts + max_clock_shift,因此要麼讀到可見的結果(A.commit_ts < B.snapshot_ts),要麼事務重啓、用新的時間戳讀到可見的結果。

那麼,CockroachDB 是否滿足可線性化呢?答案是否定的。Jepsen 的一篇測試報告中提到以下這個“雙寫”場景(其中,數據 C1、C2 位於不同節點上):

                        T3: r(C1)      (not found)
T1: w(C1)
T1: commit
            T2: w(C2)
            T2: commit                 (assuming T2.commit_ts < T3.snapshot_ts due to clock shift)
                        T3: r(C2)      (found)
                        T3: commit

雖然 T1 先於 T2 寫入,但是 T3 卻看到了 T2 而沒有看到 T1,此時事務的表現等價於這樣的串行執行序列:T2 -> T3 -> T1(因此符合可串行化),與物理順序 T1 -> T2 不同,違反了可線性化。歸根結底是因爲 T1、T2 兩個事務的時間戳由各自的節點獨立產生,無法保證先後關係,而 Read Restart 機制只能防止數據存在的情況,對於這種尚不存在的數據(C1)就無能爲力了。

Jepsen 對此總結爲:CockroachDB 僅對單行事務保證可線性化,對於涉及多行的事務則無法保證。這樣的一致性級別是否能滿足業務需要呢?這個問題就留給讀者判斷吧。

結合 TSO 與 HLC

最近看到 TiDB 的 Async Commit 設計文檔 引起了我的興趣。Async Commit 的設計動機是爲了降低提交延遲,在 TiDB 原本的 Percolator 2PC 實現中,需要經過以下 4 個步驟:

  1. Prewrite:將 buffer 的修改寫入 TiKV 中
  2. 從 TSO 獲取提交時間戳 commit_ts
  3. Commit Primary Key
  4. Commit 其他 Key(異步進行)

爲了降低提交延遲,我們希望將第 3 步也異步化。但是第 2 步中獲取的 commit_ts 需要由第 3 步來保證持久化,否則一旦協調者在 2、3 步之間宕機,事務恢復時就不知道用什麼 commit_ts 繼續提交(roll forward)。爲了避開這個麻煩的問題,設計文檔對 TSO 時間戳模型的事務提交部分做了修改,引入 HLC 的提交方法:

  • Prewrite

    1. TiDB 向各參與事務的 TiKV 節點發出 Prewrite 請求
    2. TiKV 持久化 Prewrite 的數據以及 min_commit_ts,其中 min_commit_ts = 本地最大時間戳 max_ts
    3. TiKV 返回 Prewrite 成功消息,包含剛剛的 min_commit_ts
  • Finalize

    (異步):計算 commit_ts = max{ min_commit_ts },用該時間戳進行提交

    1. Commit Primary Key
    2. Commit 其他 Key

上述流程和 HLC 提交流程基本是一樣的。注意,事務開始時仍然是從 TSO 獲取 snapshot_ts,這一點保持原狀。

我們嘗試代入上一節的“雙寫”場景發現:由於依賴 TSO 提供的 snapshot_ts,T1、T2 的時間戳依然能保證正確的先後關係,但是隻要稍作修改,即可構造出失敗場景(這裏假設 snapshot_ts 在事務 begin 時獲取):

T1: begin   T2: begin   T3: begin       (concurrently)
T1: w(C1)
T1: commit                              (assuming commit_ts = 105)
            T2: w(C2)
            T2: commit                  (assuming commit_ts = 103)
                        T3: r(C1)       (not found)
                        T3: r(C2)       (found)
                        T3: commit

雖然 T1 先於 T2 寫入,但 T2 的提交時間戳卻小於 T1,於是,併發的讀事務 T3 看到了 T2 而沒有看到 T1,違反了可線性化。根本原因和 CockroachDB 一樣:T1、T2 兩個事務的提交時間戳由各自節點計算得出,無法確保先後關係。

Async Commit Done Right

上個小節給出的 Async Commit 方案破壞了原本 TSO 時間戳的線性一致性(雖然僅僅是個非常邊緣的場景)。這裏特別感謝 @Zhifeng Hu 的提醒,在 #8589 中給出了一個巧妙的解決方案:引入 prewrite_ts 時間戳,即可讓併發事務的 commit_ts 重新變得有序。完整流程如下,注意 Prewrite 的第 1、2 步:

  • Prewrite

    1. TiDB 從 TSO 獲取一個 prewrite_ts,附帶在其中一個 Prewrite 請求上發送給 TiKV
    2. TiKV 用 prewrite_ts(如果收到的話)推高本地最大時間戳 max_ts
    3. TiKV 持久化 Prewrite 的數據以及 min_commit_ts = max_ts
    4. TiKV 返回 Prewrite 成功消息,包含剛剛的 min_commit_ts
  • Finalize

    (異步):計算 commit_ts = max{ min_commit_ts },用該時間戳進行提交

    1. Commit Primary Key
    2. Commit 其他 Key

對應到上面的用例中,現在 T1、T2 兩個事務的提交時間戳不再是獨立計算,依靠 TSO 提供的 prewrite_ts 可以構建出 T1、T2 的正確順序:T2.commit_ts >= T2.prewrite_ts > T1.commit_ts,從而避免了上述異常。

更進一步,該方案能夠滿足線性一致性。這裏只給一個直覺的解釋:我們將 TSO 看作是外部物理時間,依靠 prewrite_ts 可以保證 commit_ts 的取值位於 commit 請求開始之後,而通過本地 max_ts 計算出的 commit_ts 一定在 commit 請求結束之前,故 commit_ts 取值落在執行提交請求的時間範圍內,滿足線性一致性。

總結

  1. 上述已知的時間戳方案中,僅有 TSO 和 TrueTime 能夠保證線性一致性;
  2. Logical Clock 方案僅能保證 Session 一致性;
  3. Cockroach 的 HLC 方案僅能保證行級線性一致性,不保證多行事務的線性一致性;
  4. TiDB Async Commit 通過引入 Prewrite 時間戳保持了外部一致性;但如果去掉 Prewrite 時間戳、使用 HLC 的提交方式,則不保證多行的併發事務的線性一致性。

另外,關注公衆號Java技術棧,在後臺回覆:面試,可以獲取我整理的 Java/ 分佈式系列面試題和答案,非常齊全。

References

  1. https://en.wikipedia.org/wiki/Lamport_timestamp
  2. https://www.slideshare.net/josemariafuster1/spanner-osdi2012-39872703
  3. https://jepsen.io/analyses/cockroachdb-beta-20160829
  4. https://www.cockroachlabs.com/blog/living-without-atomic-clocks/)
  5. https://sergeiturukin.com/2017/06/29/eventual-consistency.html
  6. https://github.com/tikv/sig-transaction/blob/master/design/async-commit/initial-design.md
  7. https://github.com/tikv/tikv/issues/8589

近期熱文推薦:

1.600+ 道 Java面試題及答案整理(2021最新版)

2.終於靠開源項目弄到 IntelliJ IDEA 激活碼了,真香!

3.阿里 Mock 工具正式開源,幹掉市面上所有 Mock 工具!

4.Spring Cloud 2020.0.0 正式發佈,全新顛覆性版本!

5.《Java開發手冊(嵩山版)》最新發布,速速下載!

覺得不錯,別忘了隨手點贊+轉發哦!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章