https://notebooklm.google.com/notebook/e0b04700-2cb6-4254-92c3-f7c5c3cf6889/audio
微信链接: https://mp.weixin.qq.com/s/vtZXySAivbH4hlmgQRIybw
给我留下的较深的印象:
- 没有对线性一致性作出保证,而是对因果一致性作出保证,但是这个因果一致性仅对系统内事件有效
- 通过在客户端实现 context 来对数据版本做出控制,记录自己看到的各个数据的版本;通讯过程中要(尤其是写操作)要告诉 server 写了
key1_v2并且deps: [key2_v2] get_trans都是在本地读,舍弃了让用户看到最新的数据,而是对数据因果一致性作出保证
我们来深入探讨一下 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, {"牛奶", "面包"})写回系统,解决冲突。
- 爱丽丝在数据中心 A 将“牛奶”加入购物车。购物车状态为
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 是如何在不引入全局同步点(比如单点日志服务器)的情况下,实现可扩展的因果一致性的呢?答案是: 显式依赖跟踪 。
它的工作流程是这样的:
- 客户端上下文(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 的场景,我们可能还需要更强的保证。
论文中举了 访问控制列表(Access Control List, ACL) 的例子:
爱丽丝想修改相册,她先把相册设为私密,然后往相册里添加了一张私密照片。
- 初始状态(系统空白或之前已有一些公开照片,无关本例)。
- 操作 A:上传私密照片
put(album, add photo₁)- 系统先读当前 ACL(此时是 v1=
FRIENDS_ONLY), - 在新版本
Album@v1里写入条目photo₁ (depends_on ACL=v1)。
- 系统先读当前 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 | Facebook/Memcache (主从模型) | COPS |
|---|---|---|---|
| 一致性模型 | 线性一致性 (Linearizability) :提供全局、实时的操作顺序,如同单机系统。 | 主从复制下的最终一致性 :写操作在主库是原子的,但读操作可能从缓存或只读副本读到旧数据。 | 因果+一致性 (Causal+) :保证因果相关的操作顺序,但并发操作无序。 |
| 写操作路径 | 全局同步 :写操作需要在多个数据中心的 Paxos 副本中达成共识,通常涉及两阶段提交。 | 主数据中心写入 :所有写操作必须路由到主数据中心的数据库(如 MySQL),延迟较高。 | 本地写入 :写操作在任何数据中心都能立即完成,然后异步复制到其他地方。 |
| 写延迟 | 高 :至少是一个广域网的往返时延(RTT)。 | 高 (对于非主数据中心的用户):需要一个到主数据中心的 RTT。 | 极低 :本地数据中心内的操作延迟,通常在毫秒级。 |
| 读操作路径 | 本地快照读 :可以读取一个时间戳快照,通常很快,但若需最新数据则可能变慢。 | 本地缓存优先 :优先从本地 Memcache 读取,速度极快(百万 QPS 级)。缓存未命中则回源到只读数据库。 | 本地读取 :所有读操作都在本地数据中心完成。 |
| 读延迟 | 低 (对于快照读)。 | 极低 (缓存命中时)。 | 极低 。 |
| 可用性 | 高 :只要大多数副本存活,系统就能读写。但广域网分区可能影响写入。 | 写可用性受限于主数据中心 :主数据中心宕机,则无法写入。读可用性很高。 | 极高 :即使数据中心之间完全隔离,每个数据中心依然能独立处理本地的读写请求。 |
| 可扩展性 | 良好 :可以通过增加分片来水平扩展。 | 良好 :缓存层 (Memcache) 和数据库层都可以通过分片来扩展。 | 良好 :通过将 key 分区到不同节点,避免了单点瓶颈,可以水平扩展。 |
| 开发者体验 | 简单直观 :线性一致性最符合程序员的单机编程直觉,事务功能强大。 | 复杂 :需要处理缓存失效、读到旧数据等问题。跨 key 操作通常需要应用层自己保证。 | 中等 :比最终一致性简单,因为因果关系得到了保证。但需要理解因果上下文,且 Get 事务比标准事务更微妙。 |
| 典型用例 | 金融系统、关键元数据存储、需要强一致性保证的全球性应用。 | 读取密集型的社交网络 Feed、新闻网站、内容分发网络。 | 协作编辑工具、有状态的社交互动(如评论回复)、需要保证操作逻辑顺序但对延迟非常敏感的场景。 |