副本一致性
現代的數據庫系統來說,幾乎都具備了復制機制replication,這種設計方式至少能對系統帶來兩個好處:
- 多副本容災:只要存在一個可用數據副本,數據就能夠恢復
- 讀性能水平擴展:通過分布到不同的機器上,同一份數據能夠在多個節點上同時供外部訪問
保證多副本的數據一致性consistency是一個難題。
最簡單的實現方式是使用同步復制機制sync-replication:保證寫操作在所有副本上執行成功后,再響應客戶端。不過這一方式通常意味着糟糕的寫性能,因此較少使用。
與之相對的則是異步復制機制async-replication:寫操作在某些副本上成功后即可響應客戶端,數據庫會異步將修改同步到剩余副本上。其優勢在於較高的寫入性能,但是數據副本的一致性無法保證。
以最基礎的主從復制架構為例,雖然主庫與從庫最終都會到達一個一致的狀態,但是主從狀態同步會存在時間延遲,這類延遲被稱為復制滯后replication lag現象。 期間可能同時存在兩份相互沖突的數據副本,依賴這些數據的應用如果沒有做好預防處理,最終會引發系統行為異常。
在分布式數據庫中,維護副本狀態的進程分為以下兩類:
- leader / master: 能夠同時處理讀寫請求的進程
- follower / slave: 只能處理讀請求的進程
基於以上定義,常見的復制架構又可以分為下圖中的 3 類:

一主一從 master-slave |
|
|---|---|
| 潛在問題 | 寫后讀一致性 read-after-write consistency 客戶端修改數據,變更未同步到從庫,此時從從庫讀取數據,得到的是未修改的結果 |
| 解決方案 | 寫操作對之后的讀操作立即可見
|
一主多從 single-leader |
|
|---|---|
| 潛在問題 | 單調讀一致性 monotonic read客戶端多次讀取同一條記錄,但請求路由到不同的從庫上面,可能讀取到舊版本的數據 |
| 解決方案 | 之后讀取到的數據一定要比之前讀取到的數據要新
|
多主多從 multi-leader / leaderless |
|
|---|---|
| 潛在問題 | 因果一致性 consistent read prefix客戶端多次修改數據路由到不同的主庫上,且數據之間有因果關系(例如:問答記錄) 可能讀取到次序混亂的數據,也可能修改一條尚未存在的數據(例如:leader 間的網絡延遲) |
| 解決方案 | 寫操作的結果必須按照其執行的順序被讀取到
|
沖突解決
在大規模互聯網應用中,多數據中心正變得越來越流行,其優點如下:
- 地理位置近,訪問速度快
- 高可用,單個數據中心宕機或者網絡出現問題不會導致不可用的情況
當系統需要部署到多個數據中心的時候,不可避免地會使用到 multi-leader 架構,這帶來以下問題:
- 同一份數據可能被兩個數據中心並發修改,導致寫沖突
- 數據庫的某些特性不能很好地支持多 leader 架構,例如:自增主鍵、觸發器
寫入沖突
single-leader 架構下的寫是順序的,對同一份數據的修改,在每個副本上都能得到最終一致的結果。
multi-leader 架構下每個 leader 中的寫也是有序的,但是不同 leader 之間的寫操作是無序的,因此對同一份數據的修改也是無序的,最終導致副本的狀態可能不一致。
如何保證所有庫收斂到同一個狀態?—— 解決沖突
- 每個寫操作關聯一個唯一 ID,選取優先級最高的操作結果
- 每個副本關聯一個唯一ID,選取優先級最高的副本對應的結果
- 將兩個沖突的數據合並成同一條數據
- 保存所有沖突數據,由后續的操作來進行解決
解決沖突的時機又可以分為:
- 寫時解決:向數據庫注入解決沖突的邏輯代碼,當發生沖突時由數據庫進行調用
MySQL MGR 中通過維護全局一致的 Binlog 實現一致性 - 讀時解決:當存在沖突數據時,應用會獲取到這些沖突數據,並自動或手動解決這些沖突
Dynamo NRW 通過調整讀寫副本數量來保證讀取到最新的數據
一致性模型
作為一個開發者,我們關心的一個重要問題是:數據庫本身究竟為我們提供了哪種等級的一致性保證?
為了支持並發操作,單機數據庫引入了事務的概念,從而避免數據不一致導致行為異常。數據庫的事務模型有一個重要概念:事務隔離級別
| 隔離級別 | 讀未提交 Read Uncommitted |
讀已提交 Read Committed |
可重復讀 Repeatable Read |
序列化 Serializable |
|---|---|---|---|---|
臟讀 Dirty Read |
✔ | ✘ | ✘ | ✘ |
不可重復讀 Unrepeatable Read |
✔ | ✔ | ✘ | ✘ |
幻讀 Phantom Read |
✔ | ✔ | ✘ | ✘ |
寫偏斜 Write Skew |
✔ | ✔ | ✔ | ✘ |
在不同的事務級別下,開發者能夠得到數據庫不同程度的一致性保證。越高的隔離級別,提供的一致性更強,也同時意味着更大的性能開銷。
這一模型存在的好處,是的我們能夠在一致性與性能之間進行權衡,作出適合自己應用場景的選擇。在分布式場景中,我們要面對更為復雜的一致性問題。為了方便接下來的討論,首先簡單介紹幾種一致性模型。
最終一致性
某個特點時間點下,數據庫系統中的各個副本間的狀態很可能是不一致的。
上面我們看到的幾個一致性問題,都是由應用層解決的,數據庫本身只提供以下保證:
經過一段任意長的時間后,數據庫中的所有副本最終都能收斂
convergence到相同的狀態。
這種極弱的一致性保證,就是我們常說的 最終一致性Eventual Consistency
-
優點:這種弱一致性保證使得系統設計較為靈活,從而能夠達到較高的性能。
比如:副本間使用異步復制策略、設計專用的對賬系統來離線解決數據沖突…… -
缺點:當系統設計中涉及到最終一致性時,應用層需要十分關注復制滯后對系統的影響。
並且需要根據業務所需的一致性保證來設計系統,變相增加了應用開發者的工作量。
此外,某些問題在網絡錯誤或者高並發時才會暴露出來,難以測試。
線性一致性
數據庫的事務機制本身就是一個容錯協議,能夠為基於事務運行的應用提供數據安全的保障。
為了向應用層隱藏復雜度,事務為應用提供了以下的抽象保障:
- 原子性:數據庫中的數據是完整的,事務執行是完整的(無需擔心執行過程中進程崩潰)
- 隔離性:數據庫不會被並發修改,事務之間是不會相互影響(無需擔心競態條件影響執行結果)
- 持久性:數據庫的存儲是可靠的,事務的變更不會丟失(無需擔心存儲器故障導致數據丟失)
事務機制提供的抽象保證,將應用開發者從繁雜的錯誤處理中解放出去,使其只需要專注於業務。不但提高了開發效率,也減少了 bug 出現的概率,系統更穩定且易於測試。
理想情況下,我們希望能夠分布式數據庫能夠像事務一樣,為我們提供一個更強的一致性保證:
- 全局寫后讀一致性:
- 系統只對外暴露一份數據,不存在同時存在多個版本的數據的問題
- 所有的修改操作都是原子性的,且每次讀取到的數據都是最新的
- 全局單調讀一致性:一旦寫入操作成功,結果對所有之后的讀取操作均可見,並且不會讀取到舊版本的數據
這種跨進程的全局強一致性保證,被稱為 線性一致性
Linearizability
下面通過一些具體場景來具體介紹這一模型。
首先,我們假定 x 是數據庫中的某個條目:
- 在鍵值數據庫中,x 就是一個 key
- 在關系數據庫中,x 就是一行
- 在文檔數據庫中,x 就是一個文檔
線性一致性模型定義了 3 種基本操作:
read(x) => v:客戶端從數據庫中讀取 x 對應的值 vwrite(x, v) => r:客戶端向數據庫中寫入 x 對應的值 v,返回操作結果 rcas(x, v1, v2) => r:客戶端使用 compare-and-set 操作將 x 對應的值從 v1 修改為 v2 並返回操作結果 r
線性一致性是跨進程Cross-Process的,可作為實現以下分布式應用場景的基礎:
- 分布式鎖與選主:用 CAS 操作實現鎖,獲得鎖的節點就是 leader
- 唯一性約束:使用 CAS 操作來獲取某個值對應的鎖,如果獲取成功,則這個值是唯一的,否則這個值就不是唯一的
- 多信道間的時序依賴:一個進程A修改數據成功后通知進程B,此時線程B一定能夠獲取到進程A的修改結果
一個滿足線性一致性的場景

圖中有 3 個客戶端,其中客戶端 A、B 讀 x,客戶端 C 寫 x 。
- 當 B 第一次讀 x 時,C 正在進行寫操作,此時 B 讀到的值是 0(此時 C 寫操作未提交)
- 當 A 第二次讀 x 時,C 正在進行寫操作,此時 A 讀到的值是 1(此時 C 寫操作已提交)
- 當 B 第二次讀 x 時,由於此前 A 讀 x 結果為 1,此時 B 讀取到的值必然也是 1(全局單調讀一致性)
一個違反線性一致性的場景

圖中有 4 個客戶端 A、B、C、D 並發進行讀寫操作,圖中的連線表明了事務提交與讀操作實際發生的時間點。圖中存在一個違反線性一致性的行為: 在 A 讀取到 4 之后 B 讀取到了 2
雖然單從客戶端 B 本身來說並沒有違反單調讀一致性,但是在全局上來說違反了單調讀一致性:后發的 B 讀請求的結果,滯后於先發的 A 讀請求的結果
一個實際應用場景【生成相冊縮略圖】

圖中的 FileStorage 是個多副本的分布式文件存儲,用於存儲用戶的照片數據,后台需要生成縮略圖加快 web 預覽:
- 當用戶上傳或修改照片時候,WebServer 將原始尺寸的用戶圖片儲於 FileStorage
- 通過 MQ 將圖片 id 異步通知 ImageResizer
- ImageResizer 根據 MQ 給出的圖片 id 從 FileStorage 讀取數據並生成縮略圖
在步驟 B 中,MQ 消息傳遞的同時, FileStorage 內部進行副本復制。如果 FileStorage 不滿足線性一致性,ImageResizer 可能讀取不到圖片(違反全局寫后讀)或者讀取到舊(違反全局單調讀)的照片。從而導致處理失敗,甚至會生成錯誤的縮略圖,整個系統最終會處於一個不一致的狀態。
實現方式
實現線性一致性語義的最簡單方式就是只使用一個數據副本,但這種做法會使得系統不具有容錯性。
為了提高系統的容錯能力,多副本的架構是唯一的選擇,下面我們按照不同的多副本架構分情況討論:
| 復制架構 | 實現方式 |
|---|---|
| Single-leader | 在不使用快照隔離(例如:mysql 的 MVCC)的前提下,同時使用以下兩種策略可以滿足線性一致性:
|
| Multi-leader | 允許多節點同時寫,並且需要支持異步復制,可能引起寫沖突,因此需要暴露多個副本來解決沖突,因此肯定無法滿足線性一致性。 |
| Consensus algorithms | 共識算法涵蓋了 single-leader 的功能,並且同時具備防止腦裂和過期副本的機制,因此天然滿足線性一致性。 |
權衡
盡管線性一致性是個強有力的一致性保證,這類強一致模型在實踐中的應用並不廣泛。
舉個例子:現代計算機的內存模型並不能保證線性一致性。
在多核 CPU 中,每個內核都有自己的 cache,因此會盡可能的使用數據局部性來提高訪問性能。
當 CPU 需要訪問修改 RAM 中的數據,首先會修改 cache,然后異步將修改刷新到實際的 RAM 中(多副本 + 異步復制機制)。
為了保證多線程訪問的一致性,需要向代碼中插入 memory barrier 指令,強制 CPU 將修改同步刷新到 RAM 中。
犧牲一致性以換取更好的性能,這類權衡在數據庫系統中更為常見。為了保證強一致性,線性一致性模型會帶來糟糕的性能,這是不可改變的事實。
因果一致性
事件的發生先后順序包含了因果關系Causality。
一個違反因果一致性的場景【醫生排班表】

每個醫院都有一個排班表 (on-call shift),以 保證至少有一個或者以上的值班醫生 (on-call docotor) 在場,以應付急診之類的突發情況。如果值班當天醫生身體不適,可以在排班系統申請提前下班,值班系統會檢測當前值班醫生人數,判斷是否允許下班。
某天,醫院只有兩個值班醫生 Alice 和 Bob,但是恰巧兩人身體都出現了不適,並且在同一時刻申請了提前下班,此時可能出現以下情況:
- 系統首先開啟了兩個並發的事務,分別由 Alice 和 Bob 兩人發起
- 兩個事務同時查詢了值班人數,並且得到值班人數為 2 (currently_on_call = 2)
- 兩個事務同時更新了值班記錄,分別將 Alice 和 Bob 改為非值班狀態,並提交事務成功
- 最終醫院的值班人數為 0,急診病人 R.I.P
上面這個例子違反了因果一致性:事務中的 write 操作依賴於 read 操作
Alice 的 write 事務先提交,從而導致 Bob 事務中 read 的結果失效。
但是 Bob 的事務沒有檢測出 read 失效的情況,而是直接提交了事務,最終導致系統違反了排班約束
這種由並發 read-write 事務導致的不一致現象被稱為寫偏斜Write Skew。值得注意的是,這種事務並發的情況不一定是人為引起的,網絡延遲拉長事務周期而也可能間接引發這一問題。
模型對比
我們先回顧一下兩個順序相關的定義:
- 全序
total order/linear order集合中,任意兩個元素之間都是可以比較 - 偏序
partial order集合中,部分元素之間是可以比較的
這兩種順序分別代表了兩種一致性模型:
- 線性一致性:系統對外只暴露一份數據,所有操作在唯一一份數據上串行執行(沒有並發操作),因此任意操作之間肯定是有先后順序的
- 因果一致性:有因果關系的操作之間是有序的,但並發操作之間沒有因果關系,因此也沒有先后順序
線性一致性模型更簡單,容易理解,並且能夠處理多信道時序依賴的因果問題。
然而實現線性一致性需要付出較高的性能代價,操作之間需要相互等待,並且在網絡延遲較高的環境下,系統不可用的概率會增大。
因果一致性模型比較抽象,難以理解,但是足以應付多數應用場景。
因果一致性模型最終可以達到接近最終一致性模型的標准,並且對網絡延遲不敏感,在面臨網絡故障的情況下仍能保證可用性。
相比線性一致性,因果一致性的一個重大差異是:允許不相關數據的並發
- 線性一致性只有一個單一的時間線
- 因果一致性則是一棵具有多個分叉的樹(可以參考 Git 的分支模型)
序列號生成
因果關系本身就是一個先后順序的問題,因此只要知道了順序,就能夠據此推導出因果關系。
在討論因果一致性模型前,我們需要找到一種合適的方法來表示因果順序,從而使得我們能夠分析因果依賴。在實際應用中,我們無法記錄所有依賴關系,否則將會造成巨大的開銷。
一個可行方法是:為每個操作分配一個表示順序的序列號,序列號本身占用空間少,並且本身具有全序關系。
常見的大規模序列號生成手段有:
- 時間戳:使用高精度的時間戳作為序列號
- 提前規划:使用取模的方式,按照生成器個數來划分可用序列號,部署多個序列號生成服務(例如:兩個節點可以分別使用奇偶序列號的生成器)
- 批量生成:生成器以批量分配的方式,每次向節點分配一個連續的區間
問題在於,這些序列號不能保證全局有序:
- 系統時鍾會有偏差,多個節點間時鍾之間不一定同步,時間戳不一定能表示操作的先后順序
- 如果服務節點的負載不均,則舊的序列號可能會被應用到新的操作上面,先后順序也無法保證
為此,我們需要一個能夠保證全局順序的序列號生成機制。
Lamport timestamps
先介紹一種通過邏輯時鍾生成具有因果關系的序列號的方法:Lamport timestamps。

每個進程要維護以下兩個信息:
ID:全局唯一的不可變進程標識Counter:初值為 0 的單調遞增的整型計數器
系統的所有交互會被封裝為一系列的事件,每個事件都會關聯一個全局唯一序列號 (C,ID),其生成規則如下:
- 進程生成一個事件時,首先遞增計數器得到一個局部唯一的序列號
Clatest = ++Counter,然后將其與進程標識組合成(Clatest,ID)用於表示這個事件的發生順序。 - 進程接收一個來自其他進程的事件時,會更新本地計數器
Counter = max(Cother, Counter)+1
該序列號滿足以下全序關系:C 越大優先級越高;C相同時,ID 越大優先級越高。通過為每個操作關聯上這樣一個序列號,間接為所有操作建立起一個全序的順序關系,此時任意兩個操作都是有序的。
不過這一方案也存在缺陷:操作順序只能在操作發起之后才能得知,無法立刻對數據沖突作出反應。比如:
兩個客戶端分別在兩個節點上同時發起了一次有沖突的操作(例如:添加同名賬戶)。 當發生沖突時,系統會自動用序列號更大的值來解決沖突,從而導致序列號較小的操作失效,但是在客戶端看來自己操作已經成功了。
這類不能實時解決的沖突可能會導致一致性的問題,出現這種問題的主要原因是:雖然最終的序列號是全序,但是某個時刻的實時的序列並不完整,后面可能有未知的序列號插入其中。 例如:進程 A 已生成最大序列號 (A,1),進程 B 已生成最大序列號是 (B,5),若 A 此時接收到一個來自進程 C 的事件 (C,2),則后續可能生成一個序列號為 (A,4) 的事件。
為了保證操作的安全性(不可逆),我們需要保證當前已知的序列是不變的。全序廣播
全序廣播total order broadcast/atomic broadcast通常被描述為一個在節點間交換消息的協議,該協議具有兩個特征:
- 可靠傳遞:一旦一個節點接收到某條消息,其他節點也一定會收到這個消息。
不會出現消息丟失的情況,並且每條消息只會出現一次。 - 全序傳遞:所有節點都以相同的順序接收到消息。
一旦消息發出,此時順序就確定下來了。節點不允許往已經存在的消息序列中插入消息,只能往后追加,因此可以將全序廣播看作是一個記錄日志的過程:所有節點都在異步記錄一個全局順序一致的日志。
當出現故障時,需要有一個重試機制來保證上面兩個約束。
很多一致性服務都實現了全序廣播協議,全序廣播可以用於實現以下功能:
- 一致的復制機制:把對數據庫的寫操作當作是消息,只要能夠保證所有節點以相同的順序接收到這些消息,則能夠保證所有副本的一致性
- 序列化事務:把事務操作當作是消息,每個節點以相同的順序處理事務,那么每個節點都保證具有一致的狀態
- 分布式鎖:每個獲取鎖的請求被記錄在一個有序日志中,可以根據請求的順序來決定鎖的獲取順序
- CAS 操作:CAS(username, A, B)
- 發出一條 assert(username = A) 消息
- 監聽 username 的相關日志,在接收到第一條日志之后:
- 如果是自己發出的 assert 日志,則通過追加一條 commit(username = B) 消息來完成修改,
- 如果是其他節點的 assert 或 commit 日志,則修改失敗
- 全局一致性讀:
- 利用消息在日志中的位置確定讀發生的時間,發出一條消息,監聽這條消息返回后,再進行讀操作(etcd)
- 如果日志系統能夠獲取到最新的日志所在的位置,則可以等待日志追加到這一位置之后,再進行讀操作(zk)
- 從寫操作同步更新的副本上讀去數據
將最后兩個功能合並起來,則相當於實現了線性一致性。
實現方式
在 single-leader 架構中,只有一個 leader 能接受寫操作,從而能夠保證所有寫操作都是有序的,進而保證所有副本都是有序的。這意味着:single-leader 架構本身就具有全序傳遞的特性,只要在此基礎上解決了可靠傳輸問題,就能實現全序廣播。
常規的 single-leader 架構需要在啟動時,人為指定一個 leader 節點,一旦這個節點失效,整個系統將會陷入一個不可用的狀態,直到人工介入指定一個新的 leader。這對系統的可用性無疑會造成嚴重的影響。
為了實現自動災備auto-failover,系統本身需要支持 leader 選舉功能:
當 leader 失效時,從健康的 follower 中選擇一個新的 leader,繼續對外提供服務。
選舉過程中需要防止腦裂的情況發生,避免同時出現多個個 leader,影響系統的一致性。
此類選舉場景中,不可避免的會用到布式一致性算法distributed consensus algorithm,后面我們將用獨立的篇幅對其進行介紹。
