---
title: 分布式读写路径
url: https://doc.liz6.com/distributed-systems/06-distributed-storage/01-distributed-read-write-path
locale: zh
area: distributed-systems
tags:
- distributed-systems
- 分布式存储
date: 2026-06-30
modified: 2026-07-16
description: 一次跨节点读写,quorum、WAL、Raft 日志、一致性哈希同时运转。这条从 coordinator 到 N 个副本再到 read repair 的完整路径,是分布式存储系统的核心数据流。
---

# 分布式读写路径

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

## 概述

前面[复制策略](/distributed-systems/03-replication-and-consistency/01-replication-strategies.md)讲了单 leader / 多 leader / leaderless 三种模式,[共识协议](/distributed-systems/02-consensus-protocols/01-Raft.md)讲了 Raft 怎么让多个节点对日志序列达成一致,[一致性哈希](/distributed-systems/04-partitioning-and-routing/01-consistent-hashing.md)讲了数据怎么分布到不同节点。但一次跨节点读写,这三件事是**同时发生**的——写请求落到某个节点后,怎么选副本、怎么写 WAL、怎么等 quorum、数据不一致时怎么在读路径上修?这篇把读写路径完整走一遍。

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

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

```mermaid
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 是两个世界:[共识协议](/distributed-systems/02-consensus-protocols/01-Raft.md)的确定性 vs [复制策略](/distributed-systems/03-replication-and-consistency/01-replication-strategies.md)的可用性优先。

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

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

```mermaid
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 repair | hinted 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](/distributed-systems/02-consensus-protocols/01-Raft.md) 里,日志是"共识的载体";在单机存储引擎里,WAL 是"持久性的载体"。实际系统里这两层相互作用:

<svg viewBox="0 0 720 390" xmlns="http://www.w3.org/2000/svg" font-family="-apple-system,'Source Han Sans CN','Microsoft YaHei',sans-serif" role="img" aria-label="Raft 日志层、状态机层、存储层三层持久化如何衔接">
  <defs>
    <marker id="rw-arr" markerWidth="10" markerHeight="8" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6 Z" fill="#475569"/></marker>
  </defs>
  <rect width="720" height="390" fill="#ffffff"/>
  <text x="360" y="28" text-anchor="middle" font-size="17" font-weight="700" fill="#1f2933">Raft 日志与本地 WAL:两层持久化怎么衔接</text>

  <rect x="40" y="48" width="640" height="24" rx="6" fill="#4f46e5"/>
  <text x="360" y="64" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">Raft 层 · 集群层面确定</text>
  <rect x="60" y="78" width="270" height="40" rx="6" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="195" y="103" text-anchor="middle" font-size="12" font-weight="700" fill="#3730a3">Leader log entry[1..N]</text>
  <line x1="330" y1="98" x2="386" y2="98" stroke="#475569" stroke-width="1.7" marker-end="url(#rw-arr)"/>
  <rect x="390" y="78" width="270" height="40" rx="6" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="525" y="103" text-anchor="middle" font-size="12" font-weight="700" fill="#3730a3">Follower 复制</text>

  <line x1="360" y1="118" x2="360" y2="140" stroke="#475569" stroke-width="1.7" marker-end="url(#rw-arr)"/>
  <text x="372" y="133" font-size="11" fill="#475569">committed</text>

  <rect x="40" y="144" width="640" height="24" rx="6" fill="#0d9488"/>
  <text x="360" y="160" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">状态机层 · 单次 apply 的本地持久化</text>
  <rect x="60" y="174" width="600" height="40" rx="6" fill="#f0fdfa" stroke="#99f6e4"/>
  <text x="360" y="199" text-anchor="middle" font-size="12" font-weight="700" fill="#115e59">apply entry → 写本地 WAL → 更新 MemTable/SSTable</text>

  <line x1="360" y1="214" x2="360" y2="232" stroke="#475569" stroke-width="1.7" marker-end="url(#rw-arr)"/>

  <rect x="40" y="236" width="640" height="24" rx="6" fill="#64748b"/>
  <text x="360" y="252" text-anchor="middle" font-size="13" font-weight="700" fill="#ffffff">存储层 · 落盘文件</text>
  <rect x="60" y="266" width="600" height="40" rx="6" fill="#e2e8f0" stroke="#cbd5e1"/>
  <text x="360" y="291" text-anchor="middle" font-size="12" font-weight="700" fill="#334155">实际文件(WAL 文件, SSTable 文件)</text>

  <rect x="60" y="316" width="600" height="58" rx="8" fill="#eef2ff" stroke="#c7d2fe"/>
  <text x="76" y="337" font-size="12.5" fill="#3730a3">Raft 日志管"哪些操作已在集群层面确定"，committed 后才 apply；本地 WAL 管"单次</text>
  <text x="76" y="357" font-size="12.5" fill="#3730a3">apply 还没刷到 SSTable 前别丢"——flush 后，WAL 对应部分可截断。</text>
</svg>

- **Raft 日志**管哪些操作已经"在集群层面确定",宕机恢复后日志重放重做状态机。
- **本地 WAL**管"单次 apply 还没刷到 SSTable 时别丢"——如果 apply 后、flush 前宕机,WAL 恢复;flush 后 WAL 对应部分可截断。
- 这就是为什么[共识协议:Raft](/distributed-systems/02-consensus-protocols/01-Raft.md)里说"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, 最终一致性窗口*
