你在社交应用里改了一条个人简介。点完保存,页面显示”修改成功”。你刷新页面——简介还是老的。再刷一次——变成新的了。再刷一次——又变回老的了。
这不是 bug。这是最终一致性(Eventual Consistency)在正常工作。
你的写入落在了副本 A,但随后的读请求被负载均衡器分配到了副本 B 和副本 C。B 已经同步到了你的修改,C 还没有。三次刷新,三个副本,三种结果。系统承诺”最终”所有副本会收敛到同一个值。但”最终”是什么时候,它不保证。在收敛之前,用户看到的行为完全取决于请求落到哪个副本。
这种体验对用户来说是不可接受的。一个连自己刚写入的数据都读不到的系统,不管后端架构多优雅,用户只会认为”这个东西坏了”。
问题的根源不在最终一致性本身,而在于它缺少一组面向用户会话的保证。1994 年,Terry、Demers、Petersen 等人在 Bayou 系统的研究中提出了四种会话保证(Session Guarantees),精确地定义了用户在一次会话中应该看到什么、不应该看到什么。这四种保证后来成为理解因果一致性(Causal Consistency)的基础。
本文是分布式系统百科 Part II(一致性与时钟)的最后一篇。在上一篇中,我们从全局视角梳理了一致性模型的层级关系。这一篇转向用户视角:当用户与一个弱一致性系统交互时,哪些保证是必要的?这些保证怎么实现?真实系统做到了什么程度?
一、四种会话保证
Terry 等人在 1994 年的论文 “Session Guarantees for Weakly Consistent Replicated Data” 中定义了四种会话保证。这些保证不要求全局强一致性,只关注同一个客户端会话(Session)内的操作序列是否满足直觉预期。
理解这四种保证的前提:系统有多个副本(Replica),客户端的不同请求可能被路由到不同副本。副本之间通过异步复制(Asynchronous Replication)传播更新,存在同步延迟。
1. 读己之写(Read Your Writes, RYW)
定义:如果一个会话执行了写操作 W,那么该会话随后的所有读操作都应该能看到 W 的效果(或 W 之后的某个更新值)。
违反场景:
用户在副本 A 上更新头像为 new.jpg(版本
v5)。下一次读请求被路由到副本 B,B 还停留在版本 v2,返回
old.jpg。用户看到自己的头像”变回去了”。
这和什么无关:RYW 不要求其他用户也能立刻看到这个更新。它只保证写入者自己能看到自己的写入。
实现思路:会话在客户端维护一个写入版本记录。每次读请求发出前,检查目标副本是否已经包含会话写入的版本。如果不包含,切换到包含该版本的副本,或者等待目标副本同步到该版本。
2. 单调读(Monotonic Reads, MR)
定义:如果一个会话在某次读操作中读到了一个值(版本 v),那么该会话随后的所有读操作对同一个键返回的值,版本不低于 v。
违反场景:
用户查看银行余额,第一次请求落在副本 A(版本 v8),显示余额 100 元。第二次请求落在副本 B(版本 v5),显示余额 80 元。用户看到余额从 100 “跌到” 80——但实际上没有任何扣款操作发生,只是读到了更旧的副本。
这和 RYW 的区别:RYW 关注的是”自己写的能读到”。MR 关注的是”读到的值不会倒退”,不管这个值是谁写的。一个纯粹的只读用户也需要 MR 保证。
实现思路:会话记录每个键的最高已读版本。后续读请求只接受版本不低于已读版本的响应。
3. 写跟随读(Writes Follow Reads, WFR)
定义:如果一个会话先读到了某个写操作 W1 的结果,然后执行了写操作 W2,那么 W2 在因果上排在 W1 之后。任何看到 W2 的副本,必须也已经看到了 W1。
违反场景:
用户在副本 A 上读到了帖子 P1(版本 v3),然后在副本 B 上写了一条回复 R1(引用了 P1 的内容)。但副本 B 还没有同步到 P1。此时另一个用户读副本 B,看到了回复 R1,却看不到 R1 引用的帖子 P1——一条”回复了不存在的帖子”的评论。
核心问题:WFR 保护的是因果依赖关系(Causal Dependency)。如果 W2 是在看到 W1 的结果之后做出的,那么 W2 的存在隐含地依赖于 W1 的存在。任何看到 W2 的人,都应该能看到 W1。
实现思路:会话的写操作携带依赖声明(Dependency),标注这个写入依赖于哪些已读取的版本。接收端在应用写入之前,确认所有依赖已经就绪。
4. 单调写(Monotonic Writes, MW)
定义:一个会话内的写操作在所有副本上按照会话内的发起顺序应用。如果 W1 在 W2 之前发起,那么在任何副本上,W1 都在 W2 之前生效。
违反场景:
用户在同一个会话中先写 x=1(W1),再写
x=2(W2)。两个写入都发往副本 A。A
通过异步复制把 W1 和 W2 传播给副本 B。由于网络乱序,W2
先到达 B,W1 后到达。如果 B 采用 last-writer-wins
策略且以到达顺序为准,B 最终的值是 x=1——W2
被覆盖了。
这为什么重要:MW 保证的是会话内写操作的全序关系在所有副本上被尊重。没有 MW,用户对同一个键的”先设置密码再修改密码”可能在某些副本上变成”先修改再设置”,结果密码不是用户最后设置的那个。
实现思路:每个写操作携带前序写入的标识。副本在应用写入时,确保前序写入已被应用。
四种保证的关系
这四种保证是独立的,不互相蕴含。一个系统可以满足 RYW 但不满足 MR,也可以满足 MW 但不满足 WFR。
| 保证 | 约束对象 | 核心问题 |
|---|---|---|
| Read Your Writes | 读 ← 同会话写 | 自己写的能不能读到 |
| Monotonic Reads | 读 ← 读 | 读到的值会不会倒退 |
| Writes Follow Reads | 写 ← 同会话读 | 因果依赖是否被保留 |
| Monotonic Writes | 写 ← 写 | 写入顺序是否被保持 |
Terry 等人在论文中证明了:如果一个系统同时满足这四种保证,那么用户在单个会话内的体验等价于在一个总序(Total Order)的写操作序列上进行读写操作。换句话说,四种会话保证的组合给用户提供了”这个系统行为是合理的”这一最低预期。
会话状态追踪器的生命周期
实现四种会话保证的核心组件是会话状态追踪器(Session State Tracker)。下图展示了追踪器在一次完整会话中的状态流转:
stateDiagram-v2
[*] --> 初始化: 创建会话
初始化 --> 活跃: 首次读或写
state 活跃 {
[*] --> 空闲
空闲 --> 处理写: 客户端发起写操作
处理写 --> 空闲: 记录写入版本到 writtenVersions
空闲 --> 处理读: 客户端发起读操作
处理读 --> 副本选择: 检查 RYW + MR 约束
副本选择 --> 空闲: 更新 readVersions,返回结果
}
活跃 --> 过期: 超时或显式关闭
过期 --> [*]: 释放会话状态
追踪器在每次写操作后将 (key, version) 记录到
writtenVersions
映射中;每次读操作后将已读版本更新到
readVersions。当后续读请求到来时,追踪器根据这两组元数据选择满足约束的副本:目标副本的版本必须不低于
writtenVersions 中的对应条目(RYW)且不低于
readVersions
中的对应条目(MR)。追踪器的存储开销通常只有几十到几百个
(key, int64) 对,内存占用极小。
二、从会话保证到因果一致性
会话保证解决的是单个用户会话内的一致性感知。但分布式系统中还存在跨会话的因果关系。
考虑这个场景:用户 Alice 发了一条帖子 P。用户 Bob 看到了 P,然后发了一条回复 R。用户 Carol 看到了 R,但没看到 P。从 Carol 的视角看,这就是一条”回复了空气的评论”。
这个问题不在任何单个会话内——Alice 的会话没有违反任何保证,Bob 的会话也没有。问题出在跨会话的因果关系上:R 因果依赖于 P(Bob 是在看到 P 之后才写的 R),但 Carol 的副本先收到了 R,还没收到 P。
因果一致性(Causal Consistency)处理的正是这类跨会话的因果依赖。它的定义基于 Lamport 的 happens-before 关系(→):
- 同一进程内:如果操作 a 在操作 b 之前执行,则 a → b。
- 跨进程:如果进程 P1 发送消息 m,进程 P2 接收 m,则 send(m) → receive(m)。
- 传递性:如果 a → b 且 b → c,则 a → c。
因果一致性要求:如果写操作 W1 → W2(W1 因果地先于 W2),那么所有进程看到 W2 的时候,必须也已经看到了 W1。对于没有因果关系的并发写入,不同进程可以按不同顺序看到。
这比线性一致性(Linearizability)弱——线性一致性要求所有操作都有一个全局实时顺序。但因果一致性比最终一致性强——它保证了因果相关的操作在所有副本上按因果顺序可见。
一个关键的理论结果来自 Mahajan 等人 2011 年的研究:因果一致性是在网络分区(Partition)下仍然可以保持可用性(Availability)的最强一致性模型。 线性一致性在分区时必须牺牲可用性(CAP 定理)。因果一致性不需要。这使得因果一致性在跨数据中心的地理分布系统中具有独特的工程价值:每个数据中心可以独立响应本地请求,不需要跨数据中心的同步等待。
四种会话保证是因果一致性在单会话内的投影。因果一致性蕴含全部四种会话保证,但反过来不成立——四种会话保证只管一个会话内的操作,不管跨会话的因果关系。
三、因果一致性的工程实现:COPS 与 Eiger
把因果一致性从理论定义变成可运行的分布式存储系统,核心挑战是依赖追踪(Dependency Tracking):每个写操作需要知道它因果依赖于哪些先前的写操作,接收端需要在应用一个写操作之前确认其所有依赖都已就绪。
依赖追踪的代价
最朴素的方案是为每个写操作维护一个完整的因果历史(Causal History)——记录所有因果地先于它的写操作的标识符。但这个历史会随着操作数量线性增长,存储和传输成本都不可接受。
实用的替代方案是版本向量(Version Vector):每个节点维护一个向量,记录它已经看到的来自每个源节点的最高版本号。写操作只需要携带当前的版本向量作为依赖声明。接收端比较版本向量就能判断依赖是否就绪。向量的维度等于节点数量,在节点数可控的场景下开销可接受。
COPS(2011)
COPS(Clusters of Order-Preserving Servers)是 Lloyd 等人在 SOSP 2011 上发表的系统。它的设计目标是为跨广域网的键值存储提供因果+ 一致性(Causal+ Consistency)——因果一致性加上对并发写入的收敛冲突处理(Convergent Conflict Handling)。
架构:
COPS 的部署单元是数据中心。每个数据中心内部是一个完整的键值存储分区集合。数据中心之间通过异步复制传播更新。客户端的读写请求在本地数据中心内完成,不需要跨数据中心通信。
依赖追踪机制:
COPS 的核心是显式依赖追踪(Explicit Dependency
Tracking)。每个 put(key, value)
操作携带一组最近依赖(Nearest
Dependencies)——即当前会话最近读取或写入的键版本。
put(photo, img_data, deps=[(profile, v5), (album, v3)])
这表示:这次对 photo 的写入,因果地依赖于
profile 的版本 v5 和 album 的版本
v3。
下面用伪代码展示 COPS 客户端和服务端的依赖追踪核心逻辑:
# COPS 客户端:维护因果上下文
class COPSClient:
def __init__(self):
self.causal_context = {} # key -> version
def get(self, key):
value, version = local_dc.read(key)
self.causal_context[key] = version
return value
def put(self, key, value):
# 将当前因果上下文作为依赖集
deps = dict(self.causal_context)
version = local_dc.write(key, value, deps)
# 写入后更新上下文
self.causal_context[key] = version
# COPS 远程数据中心:依赖检查与排队
class RemoteDCHandler:
def __init__(self):
self.pending_queue = []
self.local_store = {} # key -> version
def on_replicated_write(self, key, value, version, deps):
if self._deps_satisfied(deps):
self._apply(key, value, version)
self._drain_queue() # 检查队列中是否有等待项可以释放
else:
self.pending_queue.append((key, value, version, deps))
def _deps_satisfied(self, deps):
for dep_key, dep_version in deps.items():
local_ver = self.local_store.get(dep_key, 0)
if local_ver < dep_version:
return False
return True
def _apply(self, key, value, version):
self.local_store[key] = version
# 实际存储写入...
def _drain_queue(self):
changed = True
while changed:
changed = False
remaining = []
for item in self.pending_queue:
if self._deps_satisfied(item[3]):
self._apply(item[0], item[1], item[2])
changed = True
else:
remaining.append(item)
self.pending_queue = remaining这段伪代码展示了两个关键机制:客户端通过
causal_context 字典自动积累因果依赖,每次
put
时将完整上下文作为依赖集发送;远程数据中心在收到复制写入后执行依赖检查,未满足的写入被排入等待队列,每次有新写入被应用后尝试释放队列中的阻塞项。
远程数据中心收到这个写入后,在应用之前检查本地是否已经有
profile >= v5 和
album >= v3。如果没有,排入等待队列,直到依赖就绪。
下面的时序图展示了 COPS 跨数据中心依赖传播的完整流程:
sequenceDiagram
participant Client as 客户端
participant DC1 as 数据中心 DC1
participant DC2 as 数据中心 DC2
Client->>DC1: put(x, v1, deps={})
DC1-->>Client: ack
Note over DC1: x=v1 已写入本地
Client->>DC1: get(x) -> v1
Note over Client: 因果上下文更新: {x:v1}
Client->>DC1: put(y, v2, deps={x:v1})
DC1-->>Client: ack
Note over DC1: y=v2 已写入,依赖 x:v1
DC1->>DC2: replicate put(x, v1, deps={})
DC2->>DC2: 无依赖,直接应用 x=v1
DC1->>DC2: replicate put(y, v2, deps={x:v1})
DC2->>DC2: 检查依赖: x >= v1?
alt 依赖已就绪
DC2->>DC2: 应用 y=v2
else 依赖未就绪
DC2->>DC2: 排入等待队列
Note over DC2: 等待 x:v1 到达后再应用
end
图中的关键在于依赖检查步骤。DC2 收到
put(y, v2) 时,不会盲目应用,而是先验证其依赖
x:v1 是否已在本地存在。这保证了任何读到
y=v2 的客户端,也一定能读到
x=v1——因果链在跨数据中心复制中被完整保持。如果网络乱序导致
put(y) 先于 put(x) 到达
DC2,put(y) 会被阻塞直到 put(x)
就绪。
依赖压缩:
COPS 做了一个关键优化:只传递最近依赖而非完整因果历史。如果操作 C 依赖于 B,B 依赖于 A,那么 C 只需声明依赖 B,不需要显式声明依赖 A。因为 B 被应用的前提是 A 已经被应用,传递性保证了 A 在 C 之前。
这将每个写操作的依赖元数据从 O(操作总数) 降低到 O(会话宽度)——一个会话同时涉及的键的数量。
get_by_version 接口:
COPS 提供了一个特殊的读接口
get_by_version(key, version),返回指定键的指定版本或更新版本。这用于实现因果一致的快照读取:客户端先对所有需要的键调用
get,得到各自的版本;然后检查这些版本是否构成一个因果一致的快照(Causal
Cut);如果不一致,用 get_by_version 补齐。
Eiger(2013)
Eiger 是 Lloyd 等人在 NSDI 2013 上发表的后续工作。它把 COPS 的设计扩展到了列族(Column-Family)数据模型(类似 Cassandra),并支持只读事务(Read-Only Transaction)和只写事务(Write-Only Transaction)。
与 COPS 的关键区别:
| 维度 | COPS | Eiger |
|---|---|---|
| 数据模型 | 键值 | 列族 |
| 事务支持 | 无 | 只读事务、只写事务 |
| 读协议 | 单轮 get + 补偿的
get_by_version |
两轮读协议 |
| 时钟 | 版本号 + 依赖列表 | 每列 Lamport 时钟 |
| 写入延迟 | 本地一跳 | 本地一跳 |
| 适用场景 | 简单键值存储 | 宽表场景(如用户动态流) |
Eiger 的两轮读协议:
只读事务需要读取多个键并得到一个因果一致的快照。Eiger 的做法分两轮:
- 第一轮:向所有涉及的键发出读请求,得到各自的最新值和版本。
- 第二轮:检查第一轮读到的版本之间是否存在冲突(某个键在第一轮读取之后又被更新了)。如果存在冲突,向冲突的键发起第二轮读取,指定一个安全的版本上界。
两轮都在本地数据中心内完成,不需要跨数据中心通信。代价是读事务的延迟翻倍(两轮网络往返),但换来了事务级别的因果一致快照。
写事务:
Eiger 的只写事务使用两阶段提交的变体,但限制在本地数据中心内完成。事务内的所有写入被赋予同一个时间戳,作为原子单元复制到远程数据中心。
COPS 和 Eiger 的局限
两个系统都假设数据中心内部的通信是可靠且低延迟的,因果一致性的保证主要体现在跨数据中心的复制上。数据中心内部的一致性依赖于底层存储的线性一致性或顺序一致性。
依赖追踪的元数据开销在写入密集的工作负载下不可忽视。每个写操作携带的依赖列表需要存储和传输,且接收端需要对每个写入做依赖检查。在高吞吐场景下,这可能成为瓶颈。
跨广域网的代价:依赖追踪的隐性成本
因果一致性在跨数据中心(WAN)场景下的工程代价,远不止”多传几个版本号”这么简单。以下是三个经常被低估的成本维度:
1. 元数据带宽放大
每个写操作携带的依赖列表大小与”会话宽度”(session
width,即一个会话同时涉及的键的数量)成正比。在典型的社交应用中,一个用户请求可能读取
10-50 个键(用户资料、好友列表、动态流),然后写入 1-2
个键。此时每个写操作的依赖列表包含 10-50 个
(key, version) 对,每对约 20-40
字节。对于每秒百万级写入的系统,元数据带宽开销可达数十
MB/s。
在跨数据中心链路上,这些元数据与实际数据争抢带宽。如果跨数据中心链路带宽有限(如 1 Gbps),元数据占比可能达到 10-30%,显著降低有效数据吞吐。
2. 依赖检查的排队延迟
远程数据中心收到一个写操作后,必须等待其所有依赖在本地就绪才能应用。在跨数据中心延迟为 50-100ms 的条件下,如果依赖链较长(A 依赖 B,B 依赖 C),最坏情况下的等待时间是依赖链深度乘以跨数据中心单程延迟。
更严重的是阻塞传播:如果写操作 W 被阻塞在等待依赖,所有因果依赖于 W 的后续写操作也会被阻塞,形成级联等待。在高写入吞吐下,等待队列可能快速膨胀。
3. 垃圾回收的复杂性
依赖元数据不能永远保留。COPS 需要一种安全的垃圾回收机制:只有当所有数据中心都已应用了某个写操作时,该写操作才能从依赖追踪中移除。这要求数据中心之间定期交换”已应用水位线”(applied watermark),增加了协调通信量。在数据中心数量为 \(k\) 的部署中,水位线交换的消息复杂度为 \(O(k^2)\)。
WAN 成本量化(典型参数):
┌──────────────────────────────────────────────────────────┐
│ 参数 │ COPS │ 无因果追踪 │
│ 每写操作元数据 │ 200-2000 B │ 0 │
│ 跨 DC 带宽开销(100K writes/s)│ ~100 MB/s │ ~0 │
│ 写入可见延迟(跨 DC 50ms) │ 50-200 ms │ 50 ms │
│ GC 协调消息(3 DC) │ O(k^2)/轮 │ 0 │
└──────────────────────────────────────────────────────────┘
这些代价解释了为什么大多数生产系统(包括 DynamoDB、Cassandra)选择不实现完整的因果一致性。它们提供的是更轻量级的会话保证(如 RYW),在单会话内用简单的版本比较实现,避免了跨数据中心的依赖追踪开销。
四、Bolt-on 因果一致性
COPS 和 Eiger 都是从头构建的因果一致存储系统。但现实中,大量已有系统(如 Cassandra、DynamoDB)已经提供了最终一致性,不可能为了因果一致性推倒重来。
Bailis 等人在 SIGMOD 2013 上提出了 Bolt-on 因果一致性——在已有的最终一致存储之上”加装”因果保证,不修改底层存储引擎。
核心思路
Bolt-on 的架构分两层:
- 底层:一个已有的最终一致性键值存储(如 Cassandra),负责数据持久化和跨数据中心复制。
- 上层:一个因果一致性 shim 层,运行在客户端或客户端侧的代理中,负责追踪因果依赖并控制读取可见性。
写操作的流程:
客户端写入 (key, value, causal_deps)
|
shim 层将依赖元数据编码进 value(或写入独立的元数据表)
|
底层存储执行常规 put 操作
读操作的流程:
客户端请求读取 key
|
shim 层从底层存储读取 key 的当前值和依赖元数据
|
shim 层检查所有依赖是否在本地可见
|
如果依赖都满足 -> 返回值
如果某些依赖不满足 -> 等待依赖到达,或返回满足依赖的最新历史版本
因果切割(Causal Cut)
Bolt-on 的正确性依赖于因果切割的概念。一个因果切割是系统状态的一个子集,满足:如果切割包含了某个写入 W,那么 W 因果依赖的所有写入也在切割内。
读操作返回的值必须来自一个因果切割。如果底层存储的当前状态不是一个因果切割(某些依赖还没到达),shim 层需要”退回”到一个较早的但满足因果切割的状态。
代价和局限
Bolt-on 的主要代价:
- 读延迟增加:如果依赖没有就绪,读操作要么等待(增加延迟),要么返回旧值(降低新鲜度)。
- 元数据开销:每个写入需要额外存储因果依赖信息。
- 垃圾回收复杂:需要保留历史版本以支持因果切割的退回操作,需要安全的垃圾回收策略。
- 不适用于强依赖的工作负载:如果写入之间的因果链很长且传播延迟很大,读操作可能频繁退回到很旧的版本。
Bailis 等人在论文中用 Cassandra 作为底层存储做了实验。在跨数据中心延迟约 80ms 的条件下,Bolt-on 因果一致性的读延迟开销在 2-10% 左右,取决于工作负载的因果依赖密度。
Bolt-on 方案的工程价值在于:它不要求替换已有的存储基础设施。如果你已经有一个 Cassandra 集群并且需要因果一致性,你不需要迁移到 COPS——你可以在客户端侧加一个 shim 层。
五、真实系统中的会话保证
MongoDB 因果一致性会话
MongoDB 从 3.6 版本(2017 年)开始提供因果一致性会话(Causally Consistent Sessions)。这是目前工业级数据库中对会话保证支持最完整的实现之一。
机制:
MongoDB 使用集群时间(Cluster
Time)作为逻辑时钟。集群时间是一个混合逻辑时钟(HLC),由
(timestamp, increment)
组成。每个操作在执行时被赋予一个操作时间(Operation
Time)。
客户端创建一个因果一致性会话后,会话内部维护两个时间戳:
operationTime:该会话最近一次读或写操作的逻辑时间。clusterTime:该会话观测到的最高集群时间。
读操作携带 afterClusterTime
参数,告诉目标节点:“只返回在这个时间点之后的数据。”这保证了
RYW 和 MR。
写操作的 clusterTime 签名确保了
MW——写入按会话内的逻辑顺序应用。WFR 通过读操作更新
operationTime
并在后续写操作中携带该时间戳来实现。
使用方式:
// MongoDB 因果一致性会话示例
const session = client.startSession({ causalConsistency: true });
const coll = session.getDatabase("app").getCollection("users");
// 写入操作
coll.updateOne(
{ _id: "user123" },
{ $set: { avatar: "new.jpg" } },
{ session: session }
);
// 后续读操作保证能看到上面的写入
// 即使请求被路由到了 Secondary 节点
const user = coll.findOne(
{ _id: "user123" },
{ session: session, readPreference: "secondary" }
);
// user.avatar === "new.jpg" —— Read Your Writes 保证约束条件:
- 写操作需要使用
writeConcern: "majority",确保写入被多数节点确认。 - 读操作需要使用
readConcern: "majority",确保读取的数据已被多数节点持久化。 - 因果保证的范围是单个会话。跨会话的因果关系不被自动追踪。
DynamoDB 的一致性选择
DynamoDB 的一致性模型和 MongoDB 的会话机制不同。DynamoDB 不提供显式的因果一致性会话,而是通过读一致性级别来让应用自行控制。
DynamoDB 的每次写入都发往分区的主节点(Leader),写入成功后异步复制到其他节点。读取有两种模式:
| 读取模式 | 行为 | RYW 保证 | 延迟 |
|---|---|---|---|
| 最终一致性读(默认) | 可能读取任意副本 | 不保证 | 低 |
| 强一致性读 | 始终从 Leader 读取 | 保证 | 较高 |
如果应用对同一个键先写后读且使用强一致性读,DynamoDB 保证 RYW。但这不是通过会话追踪实现的——它直接走 Leader,相当于绕开了多副本读取的问题。
对于 MR,DynamoDB 不提供跨请求的保证。两次最终一致性读可能落在不同副本,返回不同版本的数据。如果需要 MR,应用要么始终使用强一致性读,要么在应用层自行追踪版本。
DynamoDB 的全局表(Global Tables)跨区域复制使用 last-writer-wins 策略,不追踪因果依赖。WFR 在跨区域场景下不被保证。
对比
| 特性 | MongoDB 因果会话 | DynamoDB |
|---|---|---|
| RYW | 会话内保证 | 强一致性读保证 |
| MR | 会话内保证 | 不保证(最终一致性读) |
| WFR | 会话内保证 | 不保证 |
| MW | 会话内保证 | 单分区内保证 |
| 实现机制 | 逻辑时钟 + 会话状态 | Leader 读 / 最终一致性读 |
| 跨会话因果 | 不自动追踪 | 不追踪 |
MongoDB 的因果会话提供了 Terry 等人定义的全部四种会话保证。DynamoDB 通过强一致性读提供了 RYW,但需要应用层处理其他保证。两种方式都是合理的工程选择——MongoDB 选择在数据库层面承担会话状态管理的复杂性,DynamoDB 选择保持 API 简洁并把一致性决策交给应用。
六、Go 模拟器:有无会话保证的体验差距
以下 Go 代码模拟了一个多副本最终一致性存储,演示了有无会话保证时的用户体验差异。代码模拟了异步复制延迟,展示了四种会话保证被违反时的具体表现。
package main
import (
"fmt"
"strings"
)
// VersionedValue 带版本号的值
type VersionedValue struct {
Data string
Version int64
}
// Replica 模拟一个最终一致性副本
type Replica struct {
Name string
store map[string]VersionedValue
}
func NewReplica(name string) *Replica {
return &Replica{Name: name, store: make(map[string]VersionedValue)}
}
func (r *Replica) Write(key, data string, version int64) {
if cur, ok := r.store[key]; !ok || version > cur.Version {
r.store[key] = VersionedValue{Data: data, Version: version}
}
}
func (r *Replica) Read(key string) (VersionedValue, bool) {
v, ok := r.store[key]
return v, ok
}
// SessionTracker 维护会话状态以提供四种保证
type SessionTracker struct {
writtenVersions map[string]int64 // 会话写入过的 key -> version
readVersions map[string]int64 // 会话读取过的 key -> 最高 version
}
func NewSession() *SessionTracker {
return &SessionTracker{
writtenVersions: make(map[string]int64),
readVersions: make(map[string]int64),
}
}
// CheckRYW 检查副本是否包含会话写入的版本
func (s *SessionTracker) CheckRYW(r *Replica, key string) bool {
wv, wrote := s.writtenVersions[key]
if !wrote {
return true
}
cur, exists := r.Read(key)
return exists && cur.Version >= wv
}
// CheckMR 检查副本版本是否不低于已读版本
func (s *SessionTracker) CheckMR(r *Replica, key string) bool {
rv, hasRead := s.readVersions[key]
if !hasRead {
return true
}
cur, exists := r.Read(key)
return exists && cur.Version >= rv
}
func (s *SessionTracker) RecordWrite(key string, version int64) {
s.writtenVersions[key] = version
}
func (s *SessionTracker) RecordRead(key string, version int64) {
if version > s.readVersions[key] {
s.readVersions[key] = version
}
}
// SelectReplica 根据会话保证选择合适的副本
func (s *SessionTracker) SelectReplica(replicas []*Replica, key string) *Replica {
for _, r := range replicas {
if s.CheckRYW(r, key) && s.CheckMR(r, key) {
return r
}
}
var best *Replica
var bestVer int64
for _, r := range replicas {
if v, ok := r.Read(key); ok && v.Version > bestVer {
best = r
bestVer = v.Version
}
}
return best
}
func header(title string) {
fmt.Printf("\n%s\n%s\n", title, strings.Repeat("-", 50))
}
func main() {
rA := NewReplica("副本A")
rB := NewReplica("副本B")
replicas := []*Replica{rA, rB}
// ── Read Your Writes ──
header("场景 1: Read Your Writes")
rA.Write("avatar", "old.jpg", 2)
rB.Write("avatar", "old.jpg", 2)
rA.Write("avatar", "new.jpg", 5) // 写入副本 A,B 尚未同步
fmt.Println("\n[无保证]")
v, _ := rB.Read("avatar")
fmt.Printf(" 写入 avatar=new.jpg -> 副本A (v5)\n")
fmt.Printf(" 读取 avatar <- 副本B: %s (v%d)\n", v.Data, v.Version)
fmt.Printf(" 结果: x 读到旧值\n")
fmt.Println("\n[有 RYW 保证]")
sess := NewSession()
sess.RecordWrite("avatar", 5)
sel := sess.SelectReplica(replicas, "avatar")
v, _ = sel.Read("avatar")
fmt.Printf(" 写入 avatar=new.jpg -> 副本A (v5)\n")
fmt.Printf(" 会话检查: 选择 %s\n", sel.Name)
fmt.Printf(" 读取 avatar <- %s: %s (v%d)\n", sel.Name, v.Data, v.Version)
fmt.Printf(" 结果: ok 读到自己写入的值\n")
// ── Monotonic Reads ──
header("场景 2: Monotonic Reads")
rA.Write("balance", "100", 8)
rB.Write("balance", "80", 5)
fmt.Println("\n[无保证]")
v1, _ := rA.Read("balance")
v2, _ := rB.Read("balance")
fmt.Printf(" 第1次读 balance <- 副本A: %s (v%d)\n", v1.Data, v1.Version)
fmt.Printf(" 第2次读 balance <- 副本B: %s (v%d)\n", v2.Data, v2.Version)
fmt.Printf(" 结果: x 余额从 %s 变成 %s\n", v1.Data, v2.Data)
fmt.Println("\n[有 MR 保证]")
sess2 := NewSession()
v1, _ = rA.Read("balance")
sess2.RecordRead("balance", v1.Version)
fmt.Printf(" 第1次读 balance <- 副本A: %s (v%d)\n", v1.Data, v1.Version)
sel = sess2.SelectReplica(replicas, "balance")
v2, _ = sel.Read("balance")
fmt.Printf(" 会话检查: 已读 v%d, 选择 %s\n", v1.Version, sel.Name)
fmt.Printf(" 第2次读 balance <- %s: %s (v%d)\n", sel.Name, v2.Data, v2.Version)
fmt.Printf(" 结果: ok 余额不倒退\n")
// ── Writes Follow Reads ──
header("场景 3: Writes Follow Reads")
rA.Write("post", "原帖内容", 3)
fmt.Println("\n[无保证]")
fmt.Printf(" 从副本A读取 post (v3)\n")
fmt.Printf(" 向副本B写入 reply(引用 post)\n")
_, hasPost := rB.Read("post")
fmt.Printf(" 副本B 有原帖: %v\n", hasPost)
fmt.Printf(" 结果: x 回复可见但原帖不存在\n")
fmt.Println("\n[有 WFR 保证]")
fmt.Printf(" 从副本A读取 post (v3)\n")
fmt.Printf(" 写入 reply 携带依赖 deps={post:v3}\n")
fmt.Printf(" 副本B 检查依赖: post v3 未就绪,等待同步\n")
rB.Write("post", "原帖内容", 3)
_, hasPost = rB.Read("post")
fmt.Printf(" 同步完成,写入 reply\n")
fmt.Printf(" 副本B 有原帖: %v\n", hasPost)
fmt.Printf(" 结果: ok 因果依赖保持\n")
// ── Monotonic Writes ──
header("场景 4: Monotonic Writes")
fmt.Println("\n[无版本保护]")
fmt.Printf(" 写入 w1: x=1 (v10)\n")
fmt.Printf(" 写入 w2: x=2 (v11)\n")
fmt.Printf(" 副本C 收到: w2 先到 -> x=2, w1 后到 -> x=1\n")
fmt.Printf(" 如果按到达顺序覆盖: 最终 x=1\n")
fmt.Printf(" 结果: x 丢失了更新的 w2\n")
fmt.Println("\n[有 MW 保证]")
rC := NewReplica("副本C")
rC.Write("x", "2", 11)
rC.Write("x", "1", 10) // 版本比较拒绝旧写入
v, _ = rC.Read("x")
fmt.Printf(" 写入 w1: x=1 (v10), w2: x=2 (v11)\n")
fmt.Printf(" 副本C 版本比较: v10 < 已有 v11,拒绝覆盖\n")
fmt.Printf(" 最终 x=%s (v%d)\n", v.Data, v.Version)
fmt.Printf(" 结果: ok 写入顺序被版本号保护\n")
}运行输出清楚地展示了每种保证被违反时的异常行为。核心机制并不复杂:客户端维护一组版本号,在选择副本时做一次版本检查。代价是客户端需要感知多副本的存在,并可能因为没有合适的副本而增加延迟。
四种保证的实现代价对比:
| 保证 | 客户端状态 | 路由开销 | 额外延迟来源 |
|---|---|---|---|
| RYW | 每键写入版本(int64) | 版本检查 | 可能需要切换副本 |
| MR | 每键已读版本(int64) | 版本检查 | 可能需要切换副本 |
| WFR | 依赖列表 | 依赖检查 | 等待依赖就绪 |
| MW | 前序写入标识 | 顺序检查 | 等待前序写入应用 |
七、工程取舍:该选哪种一致性
到这里,我们已经看到了从最终一致性到因果一致性之间的一系列选择。它们不是”好”和”坏”的区别,而是不同场景下的工程取舍。
| 一致性级别 | 保证范围 | 性能代价 | 适用场景 |
|---|---|---|---|
| 最终一致性 | 仅保证最终收敛 | 最低 | 对一致性不敏感:CDN 缓存、DNS |
| 会话保证(四种) | 单个用户会话内 | 低(客户端状态追踪) | 用户交互型应用:社交、电商 |
| 因果一致性 | 跨会话因果链 | 中等(依赖追踪 + 延迟检查) | 协作编辑、消息系统 |
| 线性一致性 | 全局实时序 | 高(跨副本协调) | 金融、锁服务、配置管理 |
Terry 等人在论文中指出,四种会话保证的实现开销远低于强一致性。客户端只需要维护少量的版本元数据(通常是几个 int64),服务端只需要在路由层加一次版本检查。这使得会话保证成为大多数面向用户的分布式应用的合理默认选择。
因果一致性的实现代价更高,但在网络分区下仍然可用。对于跨数据中心部署、无法承受跨地域同步延迟、但又需要比最终一致性更强保证的场景,因果一致性是理论上的最优解。COPS 和 Eiger 证明了它在工程上是可行的。Bolt-on 方案证明了它可以渐进式地加到已有系统上。
选择因果一致性还是线性一致性,本质上是一个关于”你的应用是否需要全局实时序”的判断。如果用户操作之间的因果关系已经足以定义正确性(如社交网络的帖子和回复),因果一致性就够了。如果操作需要全局互斥(如分布式锁),你需要线性一致性。
八、Part II 总结与 Part III 预告
本文是分布式系统百科 Part II(一致性与时钟)的最后一篇。
Part II 从物理时钟的不可靠性出发,经过逻辑时钟、混合时钟、一致性模型全景,最终落在这篇文章的主题:面向用户的会话保证与因果一致性。这条线索的核心问题始终是一个——分布式系统中怎么定义”正确”。
我们在 Part II 中看到的答案:
- “正确”不只有一种定义。线性一致性、顺序一致性、因果一致性、最终一致性是不同强度的正确性承诺,对应不同的工程代价。
- 时钟是一致性保证的基础设施。没有可靠的事件排序,就无法实现任何一致性保证。
- 用户视角的一致性(会话保证)和系统视角的一致性(一致性模型)是两个不同但相关的维度。一个系统可以在全局只提供最终一致性,但在会话层面提供接近强一致性的用户体验。
Part III 进入共识协议(Consensus Protocols)。共识是比一致性更具体的问题:多个节点怎么对一个值达成一致?Paxos、Raft、拜占庭容错——这些协议回答的核心问题是”谁说了算”。下一篇共识问题的精确定义从 FLP 不可能定理开始,严格定义共识问题的边界。
参考文献
- Terry, D. B., Demers, A. J., Petersen, K., Spreitzer, M. J., Theimer, M. M., & Welch, B. B. (1994). Session Guarantees for Weakly Consistent Replicated Data. Proceedings of the International Conference on Parallel and Distributed Information Systems (PDIS).
- Lloyd, W., Freedman, M. J., Kaminsky, M., & Andersen, D. G. (2011). Don’t Settle for Eventual: Scalable Causal Consistency for Wide-Area Storage with COPS. Proceedings of the 23rd ACM Symposium on Operating Systems Principles (SOSP).
- Lloyd, W., Freedman, M. J., Kaminsky, M., & Andersen, D. G. (2013). Stronger Semantics for Low-Latency Geo-Replicated Storage. Proceedings of the 10th USENIX Symposium on Networked Systems Design and Implementation (NSDI).
- Bailis, P., Ghodsi, A., Hellerstein, J. M., & Stoica, I. (2013). Bolt-on Causal Consistency. Proceedings of the 2013 ACM SIGMOD International Conference on Management of Data.
- Lamport, L. (1978). Time, Clocks, and the Ordering of Events in a Distributed System. Communications of the ACM, 21(7), 558-565.
- Mahajan, P., Alvisi, L., & Dahlin, M. (2011). Consistency, Availability, and Convergence. Technical Report UTCS TR-11-22, University of Texas at Austin.
- MongoDB Documentation. Causal Consistency and Read and Write Concerns.
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【分布式系统百科】一致性模型全景:从线性一致性到最终一致性的光谱
分布式系统中一致性模型不是二选一,而是一条光谱。本文从线性一致性、顺序一致性讲到因果一致性、最终一致性及其变体,用反例区分每一级的差异,用 Go 代码实现操作历史的一致性检测,并把 ZooKeeper、Spanner、DynamoDB、Cassandra 映射到这条光谱上。
【分布式系统实战】CRDT 入门:不靠共识也能合并——但代价是什么
Raft 保证强一致,但 Leader 一挂全卡。CRDT 不需要共识也能合并——代价是元数据膨胀、只能单调、最终一致。G-Counter、LWW-Register、OR-Set 的 Go 实现,正确性验证,以及什么时候该用 Raft、什么时候该用 CRDT。
【分布式系统百科】成员协议:SWIM 与 Gossip 的工程实现
从 Gossip 协议的 SI 传播模型出发,深入拆解 SWIM 故障检测协议的直接探测、间接探测和怀疑机制,分析 HashiCorp Memberlist 的源码实现,对比 Serf 与 Consul 的成员管理策略,并提供基于 Memberlist 构建集群成员管理的完整 Go 代码示例。
【分布式系统百科】共识协议的工程权衡:Raft vs Multi-Paxos vs EPaxos 实测对比
从性能基准、选型决策、隐藏成本三个维度,系统对比 Raft、Multi-Paxos、EPaxos 三大共识协议在工程实践中的真实表现,帮助架构师做出有据可依的选型决策。