分布式读写路径

本页目录

一条存储请求通常经过路由、副本交互、本地存储和响应四层。分区决定找谁,复制协议决定等谁,本地持久化决定单节点如何恢复,读写语义决定成功意味着什么。这些职责共同组成系统,但不要求每个系统同时使用一致性哈希、Raft 和同一套 WAL。

先看 复制策略 可区分 leader 与 leaderless。本文用两条独立路径说明其差异。

无主写入:Coordinator 不是固定 Leader

客户端可联系某个节点充当请求协调者。协调者按分区元数据找到 key 的副本集合,把写请求发给副本,等待指定数量的成功响应。下一次请求可以由不同节点协调,并不需要先完成领导权选举。

设副本数 N=3,写确认数 W=2。A、B 完成后可以返回成功,C 可能仍在追赶。这意味着本次操作达到声明的确认条件,不意味着所有副本同时更新,也不意味着同一个 key 的并发写已被全局排序。

每个副本通常将变更记入本地日志并更新内存结构,之后刷成磁盘数据文件。ACK 是否等待刷盘,取决于存储引擎和配置;不能把所有实现统一画成每条写都先 fsync。日志保证本地恢复,W 决定副本确认门槛,两者必须一起分析。

Quorum 交集如何推导

在固定的 N 个副本内,写集合大小 W,读集合大小 R。若两集合不相交,总大小至多 N;因此 R + W > N 保证它们至少共享一个副本。

N=3、W=2、R=2:交集只证明接触到某个写副本 A 参与写 B 同时参与写与读 C 参与读 写集合 {A, B} 读集合 {B, C} 交集不自动定义并发写顺序;还需要版本规则与完整的读写协议。

这个集合结论需要固定副本集合、响应确实代表所要求的写入状态等前提。它不是一个完整的线性一致寄存器算法:

  • 并发写可能各自在部分节点成功,需要定义如何比较或保留版本。
  • 一次超时写可能已传播到少数节点,后续读可能看到它;超时不等于回滚。
  • 若响应版本由不可靠的物理时钟排序,数值更大不一定代表业务上更晚。
  • sloppy quorum 若写入临时替代节点,原副本集合中的读不再必然与实际写集合相交。

所以,不能仅凭 R+W>N 写下“任何情况下都读到最新值”。应继续说明版本顺序、读修复/写回、失败写与成员变更规则。

一个失败写,怎样让两次 quorum 读取发生倒退

初始 A、B、C 都保存版本 0。写版本 1 只到达 A,没获得 W=2 个确认便超时。接下来没有任何新写:第一次读 A、B,取到较新的版本 1;第一次读完成后,第二次读 B、C,却只能取到版本 0。两次读取都满足 R=2,且 R+W>N,仍然发生“先见新值,再见旧值”。

from itertools import combinations

replicas = {"A": 1, "B": 0, "C": 0}  # 数字是有序版本,不是物理时间
quorums = list(combinations(replicas, 2))
assert all(set(w) & set(r) for w in quorums for r in quorums)

def read(nodes):
    return max(replicas[node] for node in nodes)

assert read(("A", "B")) == 1
assert read(("B", "C")) == 0

# 改变协议:第一次读返回前,把选中版本写回到一个多数派。
chosen = read(("A", "B"))
for node in ("A", "B"):
    replicas[node] = max(replicas[node], chosen)
assert all(read(nodes) == 1 for nodes in quorums)

集合论没有失效:那个超时写根本没有形成 W=2 的成功集合。第一次读若想把局部版本作为稳定可观察结果,需要额外传播规则;上例写回多数派之后,后续每个读多数派才必然碰到该版本。

这解释了 Cassandra 阻塞读修复维护单调 quorum 读取的动机,具体保证见 Read Repair。模型只包含单值、有序版本、固定副本且不再发生新写的场景;不能据此把“加一次修复”概括成完整的多写者线性一致算法。多写者还要解决唯一版本生成、并发排序与所有读写阶段的协议条件。

两次都读多数,值为什么会倒退?

先在关闭写回时依次读 AB、BC,可以复现 1→0;重置后打开写回,再做相同读取,B 在第一次返回前获得版本 1,第二次读也就能碰到它。这里稳定的是一个已经被读取的版本:失败写本身从未得到 W 个确认,不能把它偷偷算成成功写来套用交集证明。

正在呈现知识画面
两次都读多数,值为什么会倒退?

R+W>N 只保证与成功写集合相交。失败写可能只到达少数节点;读取若返回该版本却未传播,下一次读可能看不到它。

无主读取:收集版本,而非抢到最快响应就返回

协调者收集满足读一致性级别的响应,再按系统规则归并版本。若一个版本因果上覆盖另一个,可以丢弃旧版本;若两个版本并发,向量时钟只能识别并发,无法凭空决定哪个业务值正确。

例如两个客户端同时修改购物车,一个增加商品,一个删除商品。按集合并集可能让已删除商品重新出现;按最后时间戳选择可能丢掉并发新增。存储读路径必须与 冲突解决 的业务规则衔接。

读修复可在发现落后副本后补数据,但可能阻塞读,也可能在后台进行,不能统一称为异步。Cassandra 的行为由相应表配置和读路径决定,参见 Read Repair。

Leader 复制:先确定日志顺序,再执行状态变化

Leader 复制写:从提议到状态机结果 Leader 接受命令 分配日志位置,并按协议持久化 1 复制与多数确认 满足 Raft 的任期和提交规则 2 推进 committed index 已提交的日志前缀成为状态机输入 3 按序 apply 结果返回时需明确是否已执行完成 4 日志追加、集群提交与状态机执行是三个不同阶段。

Raft 同样使用部分副本确认,通常是多数派;不同之处在于日志一致性、任期、选举约束和提交规则共同维护一个已提交前缀。它不是“必须所有副本确认”的协议。

Leader 将当前任期日志复制到多数派后,按协议推进提交;此前任期条目的提交还受额外规则约束。committed 与 applied 是两个位置:日志已经确定,但本地状态机可能仍未执行到那里。参见 Raft 论文 与本库 Raft。

读请求也不能只因为“发给 leader”就认定线性一致。旧 leader 在分区后可能不知道新 leader 已产生;安全读需要确认领导权、确定读位置,并等待本地状态机应用到该位置。允许读 follower 可以降低延迟,但必须明确是陈旧读、按时间戳的快照读,还是有额外同步条件的强一致读。

已提交 43,为什么本地读仍可能只看到 41

假设本地已经应用到日志 41,余额为 50;日志 42 加 5,43 减 2,二者都已提交,但 apply 线程还没执行。一次强一致读取通过协议确定需要至少读到 43,不能直接读取当前余额 50。仅知道 commit_index=43 也不能把内存状态瞬间变成 53。

读屏障到 43:提交完成,不代表本地状态已经就绪 applied = 41 余额 50 应用日志 42、43 +5,再 −2 applied = 43 余额 53 读请求等待 满足读位置条件 还必须有本次请求所需的有效领导权确认 日志 44 尚未提交,即使本地已有该条目,也不能提前应用。
commands = {42: 5, 43: -2, 44: 100}
applied, balance, committed = 41, 50, 43

def read_if_ready(leadership_confirmed, read_index):
    if not leadership_confirmed or applied < read_index:
        return None  # 尚不能按这个读请求的条件返回
    return balance

assert read_if_ready(True, 43) is None
while applied < committed:
    applied += 1
    balance += commands[applied]
assert read_if_ready(True, 43) == 53
assert read_if_ready(False, 43) is None
assert applied == 43 and balance != 153  # 未提交的 44 不可应用

模型把有效的领导权确认与读屏障作为输入,没有实现 ReadIndex 协议。实际系统还必须保证确认属于当前读请求所依赖的任期和协议过程,满足相应提交前提;不能把上次成功心跳缓存成永久有效的 True。Raft 的只读请求条件见 Raft 论文第 8 节。

一个确认解决“这个节点是否有资格确定本次读的安全位置”,另一个等待解决“本地状态机是否已经执行到那里”。即使共识网络延迟很低,apply 队列积压也会拖慢强一致读取;排查时应同时看提交到应用的距离及执行耗时。

Raft 日志与本地 WAL:逻辑职责可分,物理实现可合

共识日志保存集群决定的操作序列,本地 WAL 帮助存储引擎恢复尚未落入稳定数据文件的状态。状态机可以是内存结构、B-tree、LSM 引擎或其他实现,不必都再套一份 WAL。

若系统选择跳过状态机层 WAL,必须证明快照、已持久化的共识日志与 apply 进度足以恢复,且日志截断不会先删除唯一恢复来源。不能从“已经有 Raft 日志”直接推导“本地持久化可以随意放宽”。

同样,批量 fsync、日志合并和 apply 批处理都是实现选择。一次业务写对应几次磁盘同步,应按具体系统测量与源码解释,不能给所有分布式数据库套固定倍数。

Hints、修复与替代副本

Hinted handoff 保存目标暂时不可达时的待补写信息,恢复后回放。它是有保留期限和容量限制的恢复机制,不是任意一致性级别都可用的额外正式副本。Cassandra 中 hints 是否能满足请求,取决于一致性级别;普通 quorum 不能把存下 hint 自动当成目标副本确认。Hints 文档 给出了明确边界。

Sloppy quorum 则允许使用正常副本集合之外的可达节点承接临时副本。这会改变读写交集的前提,需要后续迁回与修复。它是 Dynamo 论文中的设计,不应从 DynamoDB 的计费模式反推其内部复制机制。

如何读一张系统架构图

沿一条请求标出:路由依据是什么;谁决定写顺序;ACK 覆盖日志追加、持久化、复制还是 apply;请求超时后可能留下什么;读取能看到哪个边界;缺失副本由谁补齐。

吞吐还要考虑复制网络、日志写入、状态机写入、压缩合并和后台修复。每一层放大针对的资源不同。下一篇 反熵与数据修复 继续说明后台恢复如何闭合这条链路。

参考

关键词:quorum、N/W/R、coordinator、leader、committed、applied、WAL、read repair、hinted handoff