Skip to content

Latest commit

 

History

History
288 lines (193 loc) · 24.6 KB

File metadata and controls

288 lines (193 loc) · 24.6 KB

LEC 17: Causal Consistency, COPS

NotebookLM 生成的双人播客

https://notebooklm.google.com/notebook/e0b04700-2cb6-4254-92c3-f7c5c3cf6889/audio

微信链接: https://mp.weixin.qq.com/s/vtZXySAivbH4hlmgQRIybw

给我留下的较深的印象:

  1. 没有对线性一致性作出保证,而是对因果一致性作出保证,但是这个因果一致性仅对系统内事件有效
  2. 通过在客户端实现 context 来对数据版本做出控制,记录自己看到的各个数据的版本;通讯过程中要(尤其是写操作)要告诉 server 写了 key1_v2 并且 deps: [key2_v2]
  3. get_trans 都是在本地读,舍弃了让用户看到最新的数据,而是对数据因果一致性作出保证

Gemini 2.5 Pro 生成的“COPS 性能和一致性之间的更贴合业务直觉、更易于编程的「中间地带」”

我们来深入探讨一下 COPS 这篇经典的分布式系统论文。在学习 Spanner 这样的强一致性系统和 Memcached 这样的最终一致性系统之后,COPS 给我们展示了在性能和一致性之间,是否存在一个更贴合业务直觉、更易于编程的“中间地带”。

设定场景:地理分布下的困境与抉择

想象一下,我们正在构建一个全球性的社交网络或电商平台。为了让世界各地的用户都能获得飞快的访问体验,我们会在全球部署多个数据中心,比如在北美、欧洲和亚洲各部署一个。理想情况下,每个数据中心都拥有所有数据的完整副本,这样用户的读请求就可以在本地数据中心完成,延迟极低。

但问题来了: 写操作怎么办?

我们之前学过两种主流方案:

  1. 强一致性方案(如 Goggle/Spanner) :用户的写操作需要通过 Paxos 或 Raft 这样的共识算法,在多个数据中心的副本之间达成一致。这意味着,一次写操作可能需要等待远在地球另一边的数据中心的确认,延迟较高。
  2. 主从方案(如 Facebook/Memcache) :所有写操作都必须发往一个指定的“主”数据中心。其他数据中心都是只读副本,异步地从主数据中心同步数据。这同样意味着非主数据中心的用户无法享受低延迟的本地写入。

这两种方案都牺牲了某些东西。我们能不能设计一个系统,它既能让 任何数据中心都能处理写操作 ,又能让这些写操作 在本地立即完成 ,无需等待其他数据中心?

这正是 COPS 想要实现的目标。为了达到这个高性能目标,我们愿意在一致性上做一些妥协,但我们希望找到一种比“最终一致性”更强大、对程序员更友好的模型。

最终一致性的“陷阱”

让我们先构思一个最简单的方案,称之为“稻草人”设计一号:每个数据中心都分片存储着完整数据。用户的读写请求都只发往本地数据中心的分片,本地写入成功后,再异步地把这次写入“推送”给其他数据中心的对应分片。

这个设计性能极好,但它属于 最终一致性(Eventual Consistency) 。这意味着系统只保证,如果大家停止写入,所有数据中心最终会同步到一样的数据。但在写入过程中,它可能会出现一些反直觉的“异常”现象(anomalies)。

举个经典的例子:

  1. 爱丽丝在她的社交网络主页上传了一张美丽的风景照。
  2. 然后,她把这张照片的链接添加到了自己的“公开相册”里。

这个过程涉及两个写操作: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 之前。

什么是“因果关系”?论文定义了三条规则来确定操作之间的潜在因果关系

  1. 执行线程(Execution Thread) :在同一个客户端的执行流中,先发生的操作是后发生操作的原因。比如,爱丽丝先 put(photo),再 put(album),那么 put(photo) 就因果上先于 put(album)
  2. 读写关系(Gets From) :如果一个 get 操作读到了某个 put 操作写入的值,那么这个 put 操作就是该 get 操作的原因。
  3. 传递性(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 的写请求。

  1. 节点 A 的时钟是 100,收到 put(event_time, "8pm")。它将时钟更新为 101,并标记这次写入的版本为 101-A
  2. 节点 B 的时钟也是 100,收到 put(event_time, "10pm")。它将时钟更新为 101,标记版本为 101-B
  3. 当节点 A 收到来自 B 的 101-B 更新时,它比较 101-A101-B。假设节点 ID B > A,那么 101-B 获胜,event_time 被更新为 "10pm"。
  4. 同样,当节点 B 收到来自 A 的 101-A 更新时,它也会选择 101-B。最终,所有副本都收敛到 "10pm"。

应用层合并逻辑

LWW 虽然简单,但对于很多业务场景来说过于粗暴。例如,合并两个用户的购物车,我们希望得到的是两个购物车商品的并集,而不是一个覆盖另一个。

应用层合并逻辑 就是将冲突的决定权交给应用程序。

  • 工作原理 :存储系统检测到对同一个 key 的并发写入后,它不会擅自决定谁赢谁输。相反,它会保留所有冲突的版本,或者将它们标记为“冲突状态”。当客户端下次读取这个 key 时,系统会返回所有冲突的版本,并告知客户端“这里有冲突,请解决”。应用程序根据自己的业务逻辑来决定如何合并这些值,然后将合并后的结果写回系统。

  • 例子:购物车

    1. 爱丽丝在数据中心 A 将“牛奶”加入购物车。购物车状态为 {"牛奶"}
    2. 鲍勃(使用同一账号)在数据中心 B 并发地将“面包”加入购物车。购物车状态为 {"面包"}
    3. 当这两个更新在某个副本上相遇时,系统检测到冲突。
    4. 下次 get(cart) 时,系统返回两个版本:[{"牛奶"}, {"面包"}]
    5. 客户端(或服务器端的业务逻辑)看到这个结果后,执行合并操作:取两个集合的并集,得到 {"牛奶", "面包"}
    6. 最后,将这个合并后的结果 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 是如何在不引入全局同步点(比如单点日志服务器)的情况下,实现可扩展的因果一致性的呢?答案是: 显式依赖跟踪

它的工作流程是这样的:

  1. 客户端上下文(Client Context) :COPS 的客户端库会为每个执行线程维护一个“上下文”。这个上下文记录了该线程“看到”的所有键值对的最新版本,也就是它的因果历史。
  2. 写入时携带依赖 :当客户端执行一个 put 操作时,比如 put(album_key, ...),客户端库会自动将当前上下文中的所有依赖项,附加到这个写请求上,一起发送给本地数据中心的存储节点。在我们的例子中,put(album) 请求会明确地告诉系统:“我依赖于 photo_key 的某个版本”。
  3. 本地立即写入,远程延迟验证
  • 这个 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₁
}

时序穿越

  1. get("ACL") 读到最新的 ACL@v2 = PUBLIC,Eve 认为相册已对所有人公开。
  2. 随后 get("album") 却命中尚未同步到 ACL@v2 的某个副本,返回旧的 Album@v1,其中含有原本“仅好友可见”的 photo₁

结果 :Eve 最终在“公开”条件下看到了本应“私密”的照片,造成安全泄露。

为了解决这个问题,COPS 的扩展版本 COPS-GT 引入了 get 事务(get transaction / get_trans) 。它能保证一次性返回多个 key 的一个因果一致的快照。

它的实现是一个非常聪明的 两阶段非阻塞算法

  1. 第一轮:并行 get :客户端库向本地数据中心并行地为所有请求的 key(比如 ACLalbum)发出 get_by_version 请求。这些请求会返回每个 key 的最新值、版本号以及 完整的依赖列表
  2. 检查与第二轮 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 是一篇里程碑式的论文,它清晰地阐述了在强一致性和最终一致性之间,存在一个既实用又高效的中间地带——因果一致性。

它的核心贡献是:

  1. 定义了 Causal+ Consistency :一个比最终一致性更强、更符合直觉,但又比强一致性性能更高的模型。
  2. 提出了一种可扩展的实现 :通过客户端跟踪上下文和远程数据中心进行依赖检查,避免了单点瓶颈,实现了系统的水平扩展。
  3. 设计了非阻塞的 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、新闻网站、内容分发网络。 协作编辑工具、有状态的社交互动(如评论回复)、需要保证操作逻辑顺序但对延迟非常敏感的场景。