COPS 性能和一致性之間的更貼合業務直覺、更易于編程的「中間地帶」
我們來深入探討一下 COPS 這篇經典的分布式系統論文。在學習 Spanner 這樣的強一致性系統和 Memcached 這樣的最終一致性系統之后,COPS 給我們展示了在性能和一致性之間,是否存在一個更貼合業務直覺、更易于編程的“中間地帶”。
設定場景:地理分布下的困境與抉擇
想象一下,我們正在構建一個全球性的社交網絡或電商平臺。為了讓世界各地的用戶都能獲得飛快的訪問體驗,我們會在全球部署多個數據中心,比如在北美、歐洲和亞洲各部署一個。理想情況下,每個數據中心都擁有所有數據的完整副本,這樣用戶的讀請求就可以在本地數據中心完成,延遲極低。
但問題來了: 寫操作怎么辦?
我們之前學過兩種主流方案:
- 強一致性方案(如 Goggle/Spanner) :用戶的寫操作需要通過 Paxos 或 Raft 這樣的共識算法,在多個數據中心的副本之間達成一致。這意味著,一次寫操作可能需要等待遠在地球另一邊的數據中心的確認,延遲較高。
- 主從方案(如 Facebook/Memcache) :所有寫操作都必須發往一個指定的“主”數據中心。其他數據中心都是只讀副本,異步地從主數據中心同步數據。這同樣意味著非主數據中心的用戶無法享受低延遲的本地寫入。
這兩種方案都犧牲了某些東西。我們能不能設計一個系統,它既能讓 任何數據中心都能處理寫操作 ,又能讓這些寫操作 在本地立即完成 ,無需等待其他數據中心?
這正是 COPS 想要實現的目標。為了達到這個高性能目標,我們愿意在一致性上做一些妥協,但我們希望找到一種比“最終一致性”更強大、對程序員更友好的模型。
最終一致性的“陷阱”
讓我們先構思一個最簡單的方案,稱之為“稻草人”設計一號:每個數據中心都分片存儲著完整數據。用戶的讀寫請求都只發往本地數據中心的分片,本地寫入成功后,再異步地把這次寫入“推送”給其他數據中心的對應分片。
這個設計性能極好,但它屬于 最終一致性(Eventual Consistency) 。這意味著系統只保證,如果大家停止寫入,所有數據中心最終會同步到一樣的數據。但在寫入過程中,它可能會出現一些反直覺的“異常”現象(anomalies)。
舉個經典的例子:
- 愛麗絲在她的社交網絡主頁上傳了一張美麗的風景照。
- 然后,她把這張照片的鏈接添加到了自己的“公開相冊”里。
這個過程涉及兩個寫操作:put(photo_key, photo_data)
和 put(album_key, photo_link)
。在愛麗絲看來,這兩個操作是先后發生的。
現在,愛麗絲的朋友鮑勃刷新頁面,看到了“公開相冊”里新增的鏈接。他滿心歡喜地點進去,結果卻看到了什么?一個“圖片不存在”的錯誤!
愛麗絲 (數據中心A):
操作1: put(photo_key, data)
操作2: put(album_key, link_to_photo)
|
|--- (異步復制) --->
|
鮑勃 (數據中心B):
get(album_key) -> 成功, 拿到了 link_to_photo
get(photo_key) -> 失敗!
為什么會這樣?因為put(album)
的更新數據包可能比 put(photo)
的數據包更早地從數據中心 A 到達了數據中心 B。對于應用開發者來說,這種不確定性非常頭疼,需要寫很多復雜的防御性代碼來處理“鏈接存在但內容不存在”的窘境。
撥亂反正:因果一致性
為了解決上述問題,我們需要一個比最終一致性更強的一致性模型。COPS 提出的核心概念是 因果+一致性(Causal+ Consistency) 。
它的核心思想很簡單:如果操作 A 是操作 B 的“原因”,那么在系統中的任何觀察者看來,A 都必須發生在 B 之前。
什么是“因果關系”?論文定義了三條規則來確定操作之間的潛在因果關系
- 執行線程(Execution Thread) :在同一個客戶端的執行流中,先發生的操作是后發生操作的原因。比如,愛麗絲先
put(photo)
,再put(album)
,那么put(photo)
就因果上先于put(album)
。 - 讀寫關系(Gets From) :如果一個
get
操作讀到了某個put
操作寫入的值,那么這個put
操作就是該get
操作的原因。 - 傳遞性(Transitivity) :如果 A 是 B 的原因,B 是 C 的原因,那么 A 就是 C 的原因。
有了這個模型,put(photo)
和 put(album)
就有了明確的因果關系。因果一致性保證了,如果鮑勃能讀到 album
的更新,他一定也能讀到 photo
的內容。
“+”號的含義:收斂的沖突處理
因果一致性只對有因果關系的操作進行排序,對于并發(concurrent)的操作,它不作任何保證。例如,愛麗絲和查理同時修改同一個活動的開始時間,一個改成晚上 8 點,一個改成晚上 10 點。這兩個 put
操作是并發的。
單純的因果一致性可能會導致一個嚴重問題: 數據副本永久分裂 。數據中心 A 可能永遠顯示 8 點,而數據中心 B 永遠顯示 10 點。
Causal+ 中的“+”號,代表了 收斂的沖突處理(Convergent Conflict Handling) 。它要求所有副本必須用同樣的方式處理并發沖突,最終達到一致的狀態。最常見的策略是 最后寫入者獲勝(last-writer-wins) 。
在生產實踐中,這是一個很重要但也很棘手的問題。對于計數器遞增、購物車加商品這類場景,“最后寫入者獲勝”顯然是不夠的。更高級的系統可能會采用:
- CRDTs (Conflict-free Replicated Data Types) :為特定數據類型(如計數器、集合)設計的、天然能夠收斂的數據結構。
- 應用層合并邏輯 :系統檢測到沖突后,交給應用程序自己去定義如何合并,比如購物車的合并邏輯就是取兩個集合的并集。
COPS 默認使用 last-writer-wins,并通過 蘭伯特時鐘(Lamport Clocks) 來為每個寫操作生成一個全局有序的版本號,從而確定誰是“最后的寫入者”。
蘭伯特時鐘(Lamport Clocks)與最后寫入者獲勝(LWW)
“最后寫入者獲勝”(Last-Writer-Wins, LWW)是最簡單直接的沖突解決方法。當兩個并發的寫操作修改同一個 key 時,系統選擇一個“勝利者”覆蓋另一個。但問題是,在沒有全局統一時鐘的分布式系統中,如何定義“最后”?
這就是 蘭伯特時鐘(Lamport Clocks) 發揮作用的地方。它不是一個物理時鐘,而是一個邏輯計數器,用來為分布式系統中的事件確定一個偏序關系。
- 工作原理 :每個節點都維護一個本地的邏輯時鐘(一個整數)。
每當節點內部發生一個事件(比如處理一個寫請求),它就將本地時鐘加一。
當節點發送消息時,會附上自己當前的邏輯時鐘。
當節點接收到消息時,它會將自己的本地時鐘更新為 max(本地時鐘, 接收到的時鐘) + 1
。
通過這個機制,如果事件 A 因果上先于事件 B,那么 A 的蘭伯特時間戳一定小于 B 的時間戳。
- 在 COPS 中的應用 :COPS 為每個寫操作分配一個版本號,這個版本號就是基于蘭伯特時鐘生成的。為了處理時間戳完全相同(雖然罕見)的情況,COPS 會在時間戳的低位附加一個唯一的節點 ID 來打破平局。當兩個對同一 key 的并發
put
操作到達一個副本時,該副本只需比較它們的版本號,保留版本號較大的那個,丟棄較小的那個。由于所有副本都遵循同樣的規則,它們最終都會保留同一個“勝利”的版本,從而實現收斂。
例子 : 假設數據中心 A 和 B 的節點同時收到對 key event_time
的寫請求。
- 節點 A 的時鐘是 100,收到
put(event_time, "8pm")
。它將時鐘更新為 101,并標記這次寫入的版本為101-A
。 - 節點 B 的時鐘也是 100,收到
put(event_time, "10pm")
。它將時鐘更新為 101,標記版本為101-B
。 - 當節點 A 收到來自 B 的
101-B
更新時,它比較101-A
和101-B
。假設節點 ID B > A,那么101-B
獲勝,event_time
被更新為 "10pm"。 - 同樣,當節點 B 收到來自 A 的
101-A
更新時,它也會選擇101-B
。最終,所有副本都收斂到 "10pm"。
應用層合并邏輯
LWW 雖然簡單,但對于很多業務場景來說過于粗暴。例如,合并兩個用戶的購物車,我們希望得到的是兩個購物車商品的并集,而不是一個覆蓋另一個。
應用層合并邏輯 就是將沖突的決定權交給應用程序。
- 工作原理 :存儲系統檢測到對同一個 key 的并發寫入后,它不會擅自決定誰贏誰輸。相反,它會保留所有沖突的版本,或者將它們標記為“沖突狀態”。當客戶端下次讀取這個 key 時,系統會返回所有沖突的版本,并告知客戶端“這里有沖突,請解決”。應用程序根據自己的業務邏輯來決定如何合并這些值,然后將合并后的結果寫回系統。
- 例子:購物車
- 愛麗絲在數據中心 A 將“牛奶”加入購物車。購物車狀態為
{"牛奶"}
。 - 鮑勃(使用同一賬號)在數據中心 B 并發地將“面包”加入購物車。購物車狀態為
{"面包"}
。 - 當這兩個更新在某個副本上相遇時,系統檢測到沖突。
- 下次
get(cart)
時,系統返回兩個版本:[{"牛奶"}, {"面包"}]
。 - 客戶端(或服務器端的業務邏輯)看到這個結果后,執行合并操作:取兩個集合的并集,得到
{"牛奶", "面包"}
。 - 最后,將這個合并后的結果
put(cart, {"牛奶", "面包"})
寫回系統,解決沖突。
Bayou 和 DynamoDB 等系統都支持這種自定義沖突解決策略。COPS 也可以被配置為顯式地檢測沖突并調用應用定義好的處理函數。
CRDTs (Conflict-free Replicated Data Types)
CRDTs 是一種更優雅的解決方案。它們是一類特殊的數據結構,其數學性質保證了無論并發操作以何種順序應用,所有副本最終都會收斂到相同的狀態,并且這個過程 不需要額外的協調或復雜的合并邏輯 。
- 工作原理 : CRDTs 的操作被設計為天然滿足交換律、結合律和冪等性。這意味著操作的順序和重復執行都不會影響最終結果。
- 例子 1:計數器 (PN-Counter) :一個支持增和減的計數器。它內部由兩個普通計數器組成:一個只增不減的 P-Counter (Increments) 和一個只增不減的 N-Counter (Decrements)。
要增加總數,就在 P-Counter 上加一。
要減少總數,就在 N-Counter 上加一。
總值 = P-Counter 的值 - N-Counter 的值。
如果數據中心 A 執行了兩次 increment
,它的狀態是 (P:2, N:0)
。數據中心 B 執行了一次 decrement
,狀態是 (P:0, N:1)
。當它們的狀態同步后,大家都會合并成 (P:2, N:1)
,最終總值都是 1,結果永遠正確。
- 例子 2:集合 (G-Set, Grow-Only Set)
一個只能添加元素的集合。它的合并操作就是取兩個集合的并集。無論并發地添加了多少元素,最終所有副本的并集都是一樣的。
CRDTs 非常適合實現點贊數、在線用戶統計、集合標簽等功能,它將沖突解決的復雜性隱藏在了數據結構的設計之中,極大地簡化了應用開發。
COPS 的核心魔法:顯式依賴跟蹤
那么,COPS 是如何在不引入全局同步點(比如單點日志服務器)的情況下,實現可擴展的因果一致性的呢?答案是: 顯式依賴跟蹤 。
它的工作流程是這樣的:
- 客戶端上下文(Client Context) :COPS 的客戶端庫會為每個執行線程維護一個“上下文”。這個上下文記錄了該線程“看到”的所有鍵值對的最新版本,也就是它的因果歷史。
- 寫入時攜帶依賴 :當客戶端執行一個
put
操作時,比如put(album_key, ...)
,客戶端庫會自動將當前上下文中的所有依賴項,附加到這個寫請求上,一起發送給本地數據中心的存儲節點。在我們的例子中,put(album)
請求會明確地告訴系統:“我依賴于photo_key
的某個版本”。 - 本地立即寫入,遠程延遲驗證 :
- 這個
put(album)
操作在 本地數據中心 會 立即執行 并返回,保證了低延遲。 - 然后,本地存儲節點會將這個寫入(連同它的依賴列表)異步地復制到其他數據中心。
- 當一個 遠程數據中心 的節點收到這個
put(album)
請求時,它不會立即寫入。它會先進行一次 依賴檢查(dependency check) 。它會檢查該請求的所有依賴項(比如photo_key
)是否已經在本地滿足了。 - 只有當所有依賴項都已在本地存在時,這個
put(album)
操作才會被應用。如果依賴項還沒到,它就會 等待 。
數據中心A (寫入方)
Client: put(photo, v1) -> Context: {photo:v1}
Client: put(album, v2, deps:{photo:v1}) -> Context: {album:v2}
|
+--- 異步復制 put(photo, v1) ---> 數據中心B
|
+--- 異步復制 put(album, v2, deps:{photo:v1}) ---> 數據中心B
數據中心B (接收方)
NodeB: 收到 put(album, v2, deps:{photo:v1})
NodeB: 檢查依賴 {photo:v1}
- 如果 photo v1 已存在 -> 直接寫入 album v2
- 如果 photo v1 不存在 -> 等待... 直到 photo v1 到達并寫入
這個設計的精妙之處在于,它把保證因果序的責任從“寫入方”的同步等待,轉移到了“接收方”的異步驗證。這使得系統可以水平擴展,因為每個分片可以獨立地復制和驗證自己的數據,避免了中心化的瓶頸。
更進一步:多 key 讀取與 Get 事務
因果一致性解決了單向依賴的問題,但對于需要同時讀取多個 key 的場景,我們可能還需要更強的保證。
論文中舉了 訪問控制列表(Access Control List, ACL) 的例子:
愛麗絲想修改相冊,她先把相冊設為私密,然后往相冊里添加了一張私密照片。
- 初始狀態(系統空白或之前已有一些公開照片,無關本例)。
- 操作 A:上傳私密照片
put(album, add photo?)
系統先讀當前 ACL(此時是 v1=FRIENDS_ONLY
),
在新版本 Album@v1
里寫入條目 photo? (depends_on ACL=v1)
。
- 操作 B:切換為公開
put(ACL, PUBLIC)
- 生成新 ACL 版本
ACL@v2 = PUBLIC
,但 不 去改動已有的Album@v1
內容。 - 此時,系統全局已有:
ACL@v2 = PUBLIC
(部分副本已可見)Album@v1
包含一條依賴ACL=v1(FRIENDS_ONLY)
的私密照片photo?
。
另一個用戶伊芙想查看這個相冊。她的代碼邏輯是:先檢查 ACL,如果公開,就去讀取相冊內容。
// 伊芙的客戶端代碼
const acl = get("ACL"); // 讀到 ACL@v2 → "PUBLIC"
if (acl === "PUBLIC") {
const album = get("album"); // 可能從某副本讀到舊版 Album@v1
display(album); // 展示全部條目,包括 photo?
}
時序穿越
get("ACL")
讀到最新的ACL@v2 = PUBLIC
,Eve 認為相冊已對所有人公開。- 隨后
get("album")
卻命中尚未同步到ACL@v2
的某個副本,返回舊的Album@v1
,其中含有原本“僅好友可見”的photo?
。
結果 :Eve 最終在“公開”條件下看到了本應“私密”的照片,造成安全泄露。
為了解決這個問題,COPS 的擴展版本 COPS-GT 引入了 get 事務(get transaction / get_trans) 。它能保證一次性返回多個 key 的一個因果一致的快照。
它的實現是一個非常聰明的 兩階段非阻塞算法 :
- 第一輪:并行
get
:客戶端庫向本地數據中心并行地為所有請求的 key(比如ACL
和album
)發出get_by_version
請求。這些請求會返回每個 key 的最新值、版本號以及 完整的依賴列表 。 - 檢查與第二輪
get
(如果需要) :
- 客戶端庫拿到第一輪的所有結果后,開始檢查一致性。例如,它發現返回的
album
版本依賴于一個比它在第一輪中拿到的ACL
版本 更新的版本 。 - 如果發現這種不一致,它會發起第二輪
get_by_version
請求。但這次,它不是去獲取最新版本,而是去獲取在第一輪依賴中看到的、能滿足一致性需求的 那個特定版本 。
這個算法為什么最多兩輪就能搞定?因為因果+一致性保證了,如果一個依賴(比如 ACL_v2
)被另一個值(album_v2
)所依賴,那么 ACL_v2
一定 已經存在于本地數據中心了。所以第二輪的 get
絕不會被阻塞等待,可以立即返回。
現實世界的考量:垃圾回收與系統開銷
COPS 的設計聽起來很美好,但在工程實踐中,魔鬼藏在細節里。
- 垃圾回收 (Garbage Collection) :依賴列表不能無限增長,否則客戶端上下文和存儲服務端的元數據都會爆炸。COPS 設計了一套垃圾回收機制。比如,當一個寫操作已經被所有數據中心確認后,它就可以被標記為“無需再依賴”,客戶端和服務器就可以清理掉相關的依賴信息。系統還會計算一個 全局檢查點時間(global checkpoint time) ,所有早于這個時間戳的依賴都可以被安全地清理掉。
- 性能與開銷 :
COPS 本身相比于純粹的最終一致性系統,增加了跟蹤和檢查依賴的開銷,但評估顯示其性能依然很高,且擴展性良好。
COPS-GT 的開銷更大,因為它需要存儲每個 key 的多個歷史版本和完整的依賴列表,以便響應 get_trans
。但在讀多寫少的典型 Web 負載下,它的性能可以接近 COPS 。
總結與反思
COPS 是一篇里程碑式的論文,它清晰地闡述了在強一致性和最終一致性之間,存在一個既實用又高效的中間地帶——因果一致性。
它的核心貢獻是:
- 定義了 Causal+ Consistency :一個比最終一致性更強、更符合直覺,但又比強一致性性能更高的模型。
- 提出了一種可擴展的實現 :通過客戶端跟蹤上下文和遠程數據中心進行依賴檢查,避免了單點瓶頸,實現了系統的水平擴展。
- 設計了非阻塞的 Get 事務:通過巧妙的兩階段算法,實現了多 key 讀取的一致性快照,且延遲可控。
啟發:
- 理解權衡 :沒有銀彈。Spanner、Dynamo、COPS 代表了在一致性、可用性、分區容忍性和性能之間不同的設計哲學和權衡點。理解這些權衡是分布式系統設計的核心。
- 因果性的局限 :COPS 只能看到通過其 API 產生的因果關系。如果我
put
之后,用微信告訴你去看,這個“外部”的因果關系系統是不知道的。這是它和 Spanner 這種能保證外部一致性(或線性一致性)的系統的根本區別。 - 沖突處理是關鍵 :在允許任意節點寫入的系統中,如何優雅地處理寫沖突,是一個繞不開的難題。last-writer-wins 是最簡單的,但往往不是最好的。
盡管因果一致性在學術界備受關注,但在工業界的大規模應用還比較少見。這可能是因為它對客戶端的侵入性較強(需要管理上下文),且對外部因果性的無能為力使其在某些場景下仍然不夠用。然而,COPS 的設計思想,尤其是其在去中心化系統中維護數據依賴關系的方法,至今仍對我們設計復雜的分布式系統有著深刻的啟發。
對比 Spanner / Memcache / COPS
維度 | Spanner | Facebook/Memcache (主從模型) | COPS |
一致性模型 | 線性一致性 (Linearizability) :提供全局、實時的操作順序,如同單機系統。 | 主從復制下的最終一致性 :寫操作在主庫是原子的,但讀操作可能從緩存或只讀副本讀到舊數據。 | 因果+一致性 (Causal+) :保證因果相關的操作順序,但并發操作無序。 |
寫操作路徑 | 全局同步 :寫操作需要在多個數據中心的 Paxos 副本中達成共識,通常涉及兩階段提交。 | 主數據中心寫入 :所有寫操作必須路由到主數據中心的數據庫(如 MySQL),延遲較高。 | 本地寫入 :寫操作在任何數據中心都能立即完成,然后異步復制到其他地方。 |
寫延遲 | 高 :至少是一個廣域網的往返時延(RTT)。 | 高 (對于非主數據中心的用戶):需要一個到主數據中心的 RTT。 | 極低 :本地數據中心內的操作延遲,通常在毫秒級。 |
讀操作路徑 | 本地快照讀 :可以讀取一個時間戳快照,通常很快,但若需最新數據則可能變慢。 | 本地緩存優先 :優先從本地 Memcache 讀取,速度極快(百萬 QPS 級)。緩存未命中則回源到只讀數據庫。 | 本地讀取 :所有讀操作都在本地數據中心完成。 |
讀延遲 | 低 (對于快照讀)。 | 極低 (緩存命中時)。 | 極低 。 |
可用性 | 高 :只要大多數副本存活,系統就能讀寫。但廣域網分區可能影響寫入。 | 寫可用性受限于主數據中心 :主數據中心宕機,則無法寫入。讀可用性很高。 | 極高 :即使數據中心之間完全隔離,每個數據中心依然能獨立處理本地的讀寫請求。 |
可擴展性 | 良好 :可以通過增加分片來水平擴展。 | 良好 :緩存層 (Memcache) 和數據庫層都可以通過分片來擴展。 | 良好 :通過將 key 分區到不同節點,避免了單點瓶頸,可以水平擴展。 |
開發者體驗 | 簡單直觀 :線性一致性最符合程序員的單機編程直覺,事務功能強大。 | 復雜 :需要處理緩存失效、讀到舊數據等問題???key 操作通常需要應用層自己保證。 | 中等 :比最終一致性簡單,因為因果關系得到了保證。但需要理解因果上下文,且 Get 事務比標準事務更微妙。 |
典型用例 | 金融系統、關鍵元數據存儲、需要強一致性保證的全球性應用。 | 讀取密集型的社交網絡 Feed、新聞網站、內容分發網絡。 | 協作編輯工具、有狀態的社交互動(如評論回復)、需要保證操作邏輯順序但對延遲非常敏感的場景。 |