本文做者: Eric Fuhtml
本文連接: https://ericfu.me/timestamp-i...java
時間戳(timestamp)是分佈式事務中繞不開的重要概念,有意思的是,如今主流的幾個分佈式數據庫對它的實現都不盡相同,甚至是主要區分點之一。git
本文聊一聊時間戳的前世此生,爲了把討論集中在主題上,假設讀者已經對數據庫的 MVCC、2PC、一致性、隔離級別等概念有個基本的瞭解。github
自從 MVCC 被髮明出來以後,那個時代的幾乎全部數據庫都拋棄(或部分拋棄)了兩階段鎖的併發控制方法,緣由無它——性能太差了。當分佈式數據庫逐漸興起時,設計者們幾乎都選擇 MVCC 做爲併發控制方案。面試
MVCC 的全稱是多版本併發控制(Multi-Version Concurrency Control),這個名字彷佛暗示咱們必定會有個版本號(時間戳)存在。然而事實上,時間戳還真不是必須的。MySQL 的 ReadView 實現就是基於事務 ID 大小以及活躍事務列表進行可見性判斷。spring
事務 ID 在事務開啓時分配,體現了事務 begin 的順序;提交時間戳 commit_ts 在事務提交時分配,體現了事務 commit 的順序。
分佈式數據庫 Postgres-XL 也用了一樣的方案,只是將這套邏輯放在全局事務管理器(GTM)中,由 GTM 集中式地維護集羣中全部事務狀態,併爲各個事務生成它們的 Snapshot。這種中心化的設計很容易出現性能瓶頸,制約了集羣的擴展性。數據庫
另外一套方案就是引入時間戳,只要比較數據的寫入時間戳(即寫入該數據的事務的提交時間戳)和 Snapshot 的讀時間戳,便可判斷出可見性。在單機數據庫中產生時間戳很簡單,用原子自增的整數就能以很高的性能分配時間戳。Oracle 用的就是這個方案。併發
MVCC 原理示意:比較 Snapshot 讀取時間戳和數據上的寫入時間戳,其中最大但不超過讀時間戳的版本,即爲可見的版本intellij-idea
而在分佈式數據庫中,最直接的替代方案是引入一個集中式的分配器,稱爲 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。本文主要關注一致性。
TSO 時間戳可以提供線性一致性保證。完整的證實超出了本文的範疇,這裏只說說直覺的解釋:用於判斷可見性的 snapshot_ts 和 commit_ts 都是來自於集羣中惟一的 TSO,而 TSO 做爲一個單點,可以確保時間戳的順序關係與分配時間戳的物理時序一致。
可線性化是一個極好的特性,用戶徹底不用考慮一致性方面的問題,可是代價是必須引入一箇中心化的 TSO。咱們後邊會看到,想在去中心化的狀況下保持可線性化是極爲困難的。
Google Spanner 是一個定位於全球部署的數據庫。若是用 TSO 方案則須要橫跨半個地球拿時間戳,這個延遲可能就奔着秒級去了。可是 Google 的工程師認爲 linearizable 是必不可少的,這就有了 TrueTime。
TrueTime 利用原子鐘和 GPS 實現了時間戳的去中心化。可是原子鐘和 GPS 提供的時間也是有偏差的,在 Spanner 中這個偏差範圍 εε 被設定爲 7ms。換句話說,若是兩個時間戳相差小於 2ε2ε ,咱們就沒法肯定它們的物理前後順序,稱之爲「不肯定性窗口」。
Spanner 對此的處理方法也很簡單——等待不肯定性窗口時間過去。
在事務提交過程當中 Spanner 會作額外的等待,直到知足 TT.now()−Tstart>2εTT.now()−Tstart>2ε,而後纔將提交成功返回給客戶端。在此以後,不管從哪裏發起的讀請求必然會拿到一個更大的時間戳,於是必然能讀到剛剛的寫入。
Lamport 時鐘是最簡單的邏輯時鐘(Logical Clock)實現,它用一個整數表示時間,記錄事件的前後/因果關係(causality):若是 A 事件致使了 B 事件,那麼 A 的時間戳必定小於 B。
當分佈式系統的節點間傳遞消息時,消息會附帶發送者的時間戳,而接收方老是用消息中的時間戳「推高」本地時間戳:Tlocal=max(Tmsg,Tlocal)+1Tlocal=max(Tmsg,Tlocal)+1。
Lamport Clock 只是個從 0 開始增加的整數,爲了讓它更有意義,咱們能夠在它的高位存放物理時間戳、低位存放邏輯時間戳,當物理時間戳增長時邏輯位清零,這就是 HLC(Hybrid Logical Clock)。很顯然,從大小關係的角度看,HLC 和 LC 並無什麼不一樣。
HLC/LC 也能夠用在分佈式事務中,咱們將時間戳附加到全部事務相關的 RPC 中,也就是 Begin、Prepare 和 Commit 這幾個消息中:
HLC/LC 並不知足線性一致性。咱們能夠構造出這樣的場景,事務 A 和事務 B 發生在不相交的節點上,好比事務 TATA 位於節點 一、事務 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 物理時間戳部分僅供觀賞,並無發揮實質性的做用。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 進行讀取。
有了這套額外的機制,上一節中的「寫後讀」場景下,能夠保證讀事務 TBTB 必定能讀到 TATA 的寫入。具體來講,因爲 TATA 提交先於 TBTB 發起,TATA 的寫入時間戳必定小於 B.snapshot_ts + max_clock_shift,所以要麼讀到可見的結果(A.commit_ts < B.snapshot_ts),要麼事務重啓、用新的時間戳讀到可見的結果。
那麼,CockroachDB 是否知足可線性化呢?答案是否認的。Jepsen 的一篇測試報告中提到如下這個「雙寫」場景(其中,數據 C一、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 不一樣,違反了可線性化。歸根結底是由於 T一、T2 兩個事務的時間戳由各自的節點獨立產生,沒法保證前後關係,而 Read Restart 機制只能防止數據存在的狀況,對於這種尚不存在的數據(C1)就無能爲力了。
Jepsen 對此總結爲:CockroachDB 僅對單行事務保證可線性化,對於涉及多行的事務則沒法保證。這樣的一致性級別是否能知足業務須要呢?這個問題就留給讀者判斷吧。
最近看到 TiDB 的 Async Commit 設計文檔 引發了個人興趣。Async Commit 的設計動機是爲了下降提交延遲,在 TiDB 本來的 Percolator 2PC 實現中,須要通過如下 4 個步驟:
爲了下降提交延遲,咱們但願將第 3 步也異步化。可是第 2 步中獲取的 commit_ts 須要由第 3 步來保證持久化,不然一旦協調者在 二、3 步之間宕機,事務恢復時就不知道用什麼 commit_ts 繼續提交(roll forward)。爲了避開這個麻煩的問題,設計文檔對 TSO 時間戳模型的事務提交部分作了修改,引入 HLC 的提交方法:
Prewrite
Finalize
(異步):計算 commit_ts = max{ min_commit_ts },用該時間戳進行提交
上述流程和 HLC 提交流程基本是同樣的。注意,事務開始時仍然是從 TSO 獲取 snapshot_ts,這一點保持原狀。
咱們嘗試代入上一節的「雙寫」場景發現:因爲依賴 TSO 提供的 snapshot_ts,T一、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 同樣:T一、T2 兩個事務的提交時間戳由各自節點計算得出,沒法確保前後關係。
上個小節給出的 Async Commit 方案破壞了本來 TSO 時間戳的線性一致性(雖然僅僅是個很是邊緣的場景)。這裏特別感謝 @Zhifeng Hu 的提醒,在 #8589 中給出了一個巧妙的解決方案:引入 prewrite_ts 時間戳,便可讓併發事務的 commit_ts 從新變得有序。完整流程以下,注意 Prewrite 的第 一、2 步:
Prewrite
Finalize
(異步):計算 commit_ts = max{ min_commit_ts },用該時間戳進行提交
對應到上面的用例中,如今 T一、T2 兩個事務的提交時間戳再也不是獨立計算,依靠 TSO 提供的 prewrite_ts 能夠構建出 T一、T2 的正確順序:T2.commit_ts >= T2.prewrite_ts > T1.commit_ts,從而避免了上述異常。
更進一步,該方案可以知足線性一致性。這裏只給一個直覺的解釋:咱們將 TSO 看做是外部物理時間,依靠 prewrite_ts 能夠保證 commit_ts 的取值位於 commit 請求開始以後,而經過本地 max_ts 計算出的 commit_ts 必定在 commit 請求結束以前,故 commit_ts 取值落在執行提交請求的時間範圍內,知足線性一致性。
另外,關注公衆號Java技術棧,在後臺回覆:面試,能夠獲取我整理的 Java/ 分佈式系列面試題和答案,很是齊全。
近期熱文推薦:
1.600+ 道 Java面試題及答案整理(2021最新版)
2.終於靠開源項目弄到 IntelliJ IDEA 激活碼了,真香!
3.阿里 Mock 工具正式開源,幹掉市面上全部 Mock 工具!
4.Spring Cloud 2020.0.0 正式發佈,全新顛覆性版本!
以爲不錯,別忘了隨手點贊+轉發哦!