本页目录

分布式读写路径

一次跨节点读写,quorum、WAL、Raft 日志、一致性哈希同时运转。这条从 coordinator 到 N 个副本再到 read repair 的完整路径,是分布式存储系统的核心数据流。

概述

前面复制策略讲了单 leader / 多 leader / leaderless 三种模式,共识协议讲了 Raft 怎么让多个节点对日志序列达成一致,一致性哈希讲了数据怎么分布到不同节点。但一次跨节点读写,这三件事是同时发生的——写请求落到某个节点后,怎么选副本、怎么写 WAL、怎么等 quorum、数据不一致时怎么在读路径上修?这篇把读写路径完整走一遍。

分布式写路径:一条写请求的旅程

以 leaderless + quorum 模式 (Dynamo/Cassandra 风格) 为例,走一条写请求:

flowchart TD
    C["① Client → Coordinator<br/>写 key=user:42<br/>N=3, W=2"]

    C --> HASH["② Coordinator 一致性哈希<br/>算出 key 落在 3 个节点<br/>[Node-A, Node-B, Node-C]"]

    HASH --> SEND["③ Coordinator 并发写<br/>→ 3 个节点, 等 W=2 个 ACK"]

    SEND --> NODE["在每个目标节点上:"]

    NODE --> WAL["a. append to WAL<br/>→ fsync<br/>crash recovery 保证"]
    WAL --> MEM["b. 写入 MemTable<br/>(内存有序结构)"]
    MEM --> ACK["c. 返回 ACK<br/>(含版本号/时间戳)"]

    ACK --> QUORUM{"④ Coordinator<br/>收到 ≥2 ACK?"}
    QUORUM -->|"✅ W=2 满足"| SUCCESS["返回成功给 Client"]
    QUORUM -.->|"未收到"| STALE["剩余节点数据<br/>暂时不一致"]

    classDef client fill:#e3f2fd,stroke:#1565c0
    classDef coord fill:#fff3e0,stroke:#ef6c00
    classDef node fill:#f3e5f5,stroke:#7b1fa2
    classDef done fill:#e8f5e9,stroke:#2e7d32
    class C client
    class HASH,SEND,QUORUM coord
    class NODE,WAL,MEM,ACK node
    class SUCCESS done

关键:W ≤ N 是"可用性"和"一致性"的调钮。 W=N 时写操作等待全部副本——任何节点故障则写阻塞;W=1 时最快但可能读到旧数据(取决于 R)。平衡点通常是 W + R > N(R 是读 quorum),保证读写集合必有交集。

和 Raft 写路径的区别

Raft 的写是强 leader 串行⁠:所有写必须经 leader → leader 写本地日志 → 复制到 follower majority → committed → apply to state machine。没有 quorum 调钮,没有"部分 ACK 也算成功"——要么 committed(确定性持久),要么没 committed(可能被后续 leader 覆盖)。这和 leaderless quorum 是两个世界:共识协议的确定性 vs 复制策略的可用性优先。

分布式读路径:怎么读到正确的

同上场景,N=3, R=2(读 quorum):

flowchart TD
    C2["① Client → Coordinator<br/>read('user:42')"]

    C2 --> HASH2["② Coordinator 一致性哈希<br/>找到 N=3 个副本节点"]

    HASH2 --> SEND2["并发读请求, 等 R=2 个响应"]

    SEND2 --> COMPARE["③ 收到 2 个响应<br/>Node-A: version=5<br/>Node-B: version=3"]

    COMPARE --> PICK["取最新 version=5<br/>返回给 Client ✅"]

    COMPARE --> REPAIR["④ read repair (异步)<br/>发现 Node-B 落后<br/>后台推送 version=5 → Node-B"]

    classDef client fill:#e3f2fd,stroke:#1565c0
    classDef coord fill:#fff3e0,stroke:#ef6c00
    classDef done fill:#e8f5e9,stroke:#2e7d32
    classDef repair fill:#f3e5f5,stroke:#7b1fa2
    class C2 client
    class HASH2,SEND2,COMPARE coord
    class PICK done
    class REPAIR repair

R + W > N 时,读写 quorum 必有重叠⁠——读一定能看到最新写。但有个精细点:coordinator 必须拿到至少 R 个响应后比较版本⁠(不是取最快那个),否则可能读到旧数据。

read repair vs hinted handoff:两种修复,两种时机

read repairhinted handoff
谁触发读路径上 (coordinator 比对版本)写路径上 (写入时目标节点不可达)
修什么已经存在的副本,只是版本落后根本缺失的副本(目标节点挂了)
存在哪N/A写在 coordinator(或相邻节点)的一个特殊 "hints" 区
生效时间立即(异步)目标节点恢复后,coordinator 把 hint 推过去

hinted handoff 的核心思想:⁠写的时候不降级,把写给挂了节点的数据暂存在别处,等它恢复再补。 这样即使某些节点临时不可达,W 仍能达到(N 不变)——写可用性不降。Cassandra 默认存 hint 3 小时,超时丢弃。

sloppy quorum:可用性走到极致

上面说的 quorum 是 strict quorum:写一定要落到一致性哈希算出的那 N 个"正确"节点上。如果那 N 个里挂了太多,即使有 hinted handoff,W 也凑不够。

sloppy quorum 放宽:coordinator 可选不在 N 里的任意节点来凑 W。数据暂时写到了"不该有这条数据的节点"上,等正确节点恢复后再移过去。这进一步提升写可用性,代价是读可能完全看不到刚写的数据⁠(如果读 quorum 恰好也没覆盖那个"临时节点")——最终一致性窗口可能显著拉大。

Dynamo 论文把 strict + sloppy 都列了,Cassandra 默认 strict(hinted handoff 兜底),DynamoDB 的 on-demand 模式更接近 sloppy。

从日志到状态机:WAL 在分布式下的双重身份

Raft 里,日志是"共识的载体";在单机存储引擎里,WAL 是"持久性的载体"。实际系统里这两层相互作用:

Raft 日志与本地 WAL:两层持久化怎么衔接 Raft 层 · 集群层面确定 Leader log entry[1..N] Follower 复制 committed 状态机层 · 单次 apply 的本地持久化 apply entry → 写本地 WAL → 更新 MemTable/SSTable 存储层 · 落盘文件 实际文件(WAL 文件, SSTable 文件) Raft 日志管"哪些操作已在集群层面确定",committed 后才 apply;本地 WAL 管"单次 apply 还没刷到 SSTable 前别丢"——flush 后,WAL 对应部分可截断。
  • Raft 日志管哪些操作已经"在集群层面确定",宕机恢复后日志重放重做状态机。
  • 本地 WAL管"单次 apply 还没刷到 SSTable 时别丢"——如果 apply 后、flush 前宕机,WAL 恢复;flush 后 WAL 对应部分可截断。
  • 这就是为什么共识协议:Raft里说"committed → apply to state machine",但没有展开 state machine 是什么——state machine 里面就是这一层的 WAL + 存储引擎。

写放大:分布式让已有的放大更复杂

单机 LSM 的写放大来自 compaction(一层层重写)。分布式又多两层:

  • 网络放大⁠:N=3 时一条写变成 3 条网络消息(N 个副本)。加上 hinted handoff 可能更多。
  • Raft 复制放大⁠:Raft 先写日志(majority),再 apply 到状态机(本地写 WAL + MemTable),一条逻辑写可能触发两次本地 fsync(日志 fsync + WAL fsync)——CockroachDB/Yugabyte 的优化是合并成一次。

所以分布式系统的"写吞吐"不能只看单机存储引擎的吞吐,必须把网络和共识开销算进去。

权衡与失败模式

  • W + R ≤ N 时读不到最新写⁠:读写 quorum 不保证重叠 → 始终设 W + R > N。
  • read repair 不及时⁠:读发现但不修(忘了异步推) → 监控副本版本差异,定期反熵(见下篇)。
  • hinted handoff 堆积⁠:目标节点长期不恢复,hints 撑爆 coordinator 磁盘 → 设 hint 过期时间,监控 hints 队列长度。
  • fsync 策略过松⁠:WAL 批量 fsync 时宕机丢最后一批 → 按持久性需求定 fsync 策略(Raft 日志本身已有持久化,本地 WAL 可适当放宽)。

参考

  • 论文⁠: "Dynamo: Amazon's Highly Available Key-value Store"(DeCandia 2007)——quorum + hinted handoff + sloppy quorum 的源头

Keywords: quorum write, quorum read, N/W/R, strict quorum, sloppy quorum, read repair, hinted handoff, coordinator, WAL, fsync, Raft log vs WAL, write amplification, network amplification, consistent hashing + replication, 最终一致性窗口