Skip to content

一致性 Hash 与 Raft 共识 ​

#数据结构 · #分布式 · #一致性Hash · #Raft · #共识算法 · #数据分片

从单机 Hash 表到分布式 Hash 表,数据分片与节点动态增减引入了新问题:如何让缓存在扩容时尽量不失效?多副本间如何保证数据一致?——一致性 Hash 和 Raft 分别解决这两个核心问题。


1. 一致性 Hash 深入 ​

1.1 问题:为什么不用取模? ​

初始: 3 个缓存节点, hash(key) % 3 → 均匀分配
扩容: 添加第 4 个节点, hash(key) % 4 → ~75% 的 key 需要搬家

这意味着:
  - 新增节点后, 缓存命中率近乎 0%
  - 所有请求穿透到数据库 → 雪崩
  - 要重新预热缓存, 代价极大

1.2 环形 Hash 空间 ​

mermaid
flowchart TD
    subgraph Ring["Hash 环 0 到 2的32次方减1"]
        direction LR
        R1["0"] ~~~ R2["2^32-1"]
    end
将 hash 空间视为一个环 (0 → 2^32-1 → 0):

         2^32-1 ─ 0
         /         \
        /     Hash 环    \
       /                  \
     ...                  N1 (hash(N1) = 1,000,000)
      |                   /  \
      |                  /    \
     N3 (300M)          /  这个区间归 N1 管
      |                /
      |               /
     ...         N2 (hash(N2) = 500,000)

规则: key 映射到 hash 环上,顺时针找到的第一个节点负责该 key
添加 N4 (hash = 2,000,000):
  - N4 进入环 → N4 和 N1 之间多了一个节点
  - 只有落在 N4→N1 之间的 key 需要重新分配给 N4
  - 其他所有 key 不受影响 ✅
  - 迁移量 ≈ 1/(n+1) 的数据 (~25%,远优于取模法的 75%)

1.3 虚拟节点解决倾斜 ​

mermaid
flowchart LR
    subgraph Without["无虚拟节点: 不均匀"]
        WA["A ████████████████ 40%"]
        WB["B ██████████ 25%"]
        WC["C ██████████████ 35%"]
    end

    subgraph With["150 虚拟节点: 均匀"]
        VA["A ████████████ 33%"]
        VB["B ████████████ 33%"]
        VC["C ████████████ 34%"]
    end

    Without -.->|"虚拟节点"| With

    style WA fill:#F44336,color:#fff
    style VA fill:#4CAF50,color:#fff
  • 每个物理节点映射为 100~200 个虚拟节点分布到环上
  • 虚拟节点越多,分布越均匀(但维护成本越大)
  • 实践中 150 个虚拟节点 即足够,偏差 < 5%

为什么是 150-200 个虚拟节点?——数学推导

一致性哈希环上的负载均衡问题本质是"球入箱"模型:
  - N 个物理节点,每个有 V 个虚拟节点 → 环上共 N×V 个点
  - 假设 hash 函数均匀 → 这些点将环分成 N×V 个弧段
  - 每个弧段的长度 ∝ 该虚拟节点负责的 key 数量

弧段长度的分布:
  N×V 个均匀随机点将 [0, 2^32) 分成 N×V 段
  每段长度服从 Exponential(N×V) 分布

  一个物理节点负责 V 段 → 总负载 = V 个指数分布随机变量之和
  → 服从 Gamma(V, 1/(N×V)) 分布

  期望负载 = 1/N (每个节点平均负责 1/N 的 key)
  标准差 = 1/(N × √V)

  变异系数 CV = 标准差/期望 = 1/√V

  CV 与虚拟节点数的关系:
    V = 50:   CV = 1/√50 ≈ 14.1%  → 最大偏差可能 ~30%
    V = 100:  CV = 1/√100 = 10%   → 最大偏差可能 ~20%
    V = 150:  CV = 1/√150 ≈ 8.2%  → 最大偏差可能 ~15%
    V = 200:  CV = 1/√200 ≈ 7.1%  → 最大偏差可能 ~12%
    V = 500:  CV = 1/√500 ≈ 4.5%  → 最大偏差可能 ~8%
    V = 1000: CV = 1/√1000 ≈ 3.2% → 最大偏差可能 ~5%

  工程目标: 最大偏差 < 10-15% → V ≈ 150 即可满足

  但 V 不能无限大:
    - 每个虚拟节点需要在环上维护一个条目
    - 查找时需要在 N×V 个点中做二分查找: O(log(N×V))
    - 节点增删时需要重新分配 V 个虚拟节点的 key
    - 内存: 每个虚拟节点 ≈ 8B(hash) + 8B(指针) = 16B
      N=100, V=200 → 100×200×16B = 320KB (可接受)
      N=100, V=10000 → 16MB (可能过大)

  最优选择:
    V = 150~200: 偏差 < 10%,内存可控,查找 O(log(N×200)) 仍然很快
    这就是为什么 Memcached、Ketama、Consul 等都默认 150-200 个虚拟节点

实际验证 (N=10 个节点, 100万个 key):
  V=1:    最大节点负载 38.2%, 最小 2.1%  → 极不均匀
  V=10:   最大 15.3%, 最小 6.8%          → 偏差大
  V=50:   最大 12.1%, 最小 8.2%          → 可接受
  V=150:  最大 11.2%, 最小 9.1%          → 良好 ✅
  V=200:  最大 10.8%, 最小 9.3%          → 优秀 ✅
  V=1000: 最大 10.3%, 最小 9.7%          → 接近完美(但维护成本高)

1.4 三种一致性 Hash 变体对比 ​

方案状态均匀度增删成本典型场景
环形 Hash + 虚拟节点需维护环好 (>100 vnode)O(log n) 查找Redis Cluster, Memcached
Jump Hash无状态好O(log n) 但不能删中间Google 负载均衡
Rendezvous Hash无状态好O(n) 但支持权重数据中心间路由
取模法无状态完美 (不改动时)O(1) 但全量迁移固定分片

1.5 Redis Cluster 中的哈希槽(Hash Slot) ​

Redis Cluster 采用了介于取模和一致性 Hash 之间的方案:

16384 个 hash slot:
  CRC16(key) % 16384 → slot 编号

每个节点负责一段连续的 slot:
  Node A: slot 0-5460
  Node B: slot 5461-10922
  Node C: slot 10923-16383

添加新节点:
  - 从每个已有节点各搬一部分 slot 给新节点
  - 迁移以 slot 为最小单位(一个 slot 的 key 全部搬)
  - 迁移期间 key 可继续访问(ASK 重定向)

这比一致性 Hash 简单(固定 16384 个槽,不用虚拟节点),迁移粒度可控。


2. Raft — 分布式共识算法 ​

为什么需要共识算法? 分布式系统中,多副本(Leader+Followers)之间如何就"哪个值是正确的"达成一致?这就是共识问题。Raft 是目前工业界最广泛使用的共识算法(etcd、TiKV、Consul、NATS)。

2.1 角色与领导者选举 ​

mermaid
flowchart TD
    subgraph States["节点三种角色"]
        L["Leader<br/>处理所有写请求<br/>发送心跳/日志复制"]
        F["Follower<br/>接收 Leader 日志<br/>响应心跳"]
        C["Candidate<br/>选举期间<br/>请求投票"]
    end

    F -->|"选举超时 150-300ms 随机"| C
    C -->|"获得多数票"| L
    C -->|"选举超时或平票"| C
    L -->|"发现更高 Term"| F

    L -.->|"心跳 每50ms"| F

    style L fill:#4CAF50,color:#fff
    style F fill:#2196F3,color:#fff
    style C fill:#FF9800,color:#fff

关键设计:

  • 随机超时(150-300ms):防止多个 Follower 同时变成 Candidate 导致平票。谁先超时谁先发起选举。
  • Term(任期):单调递增的逻辑时钟。每个 Term 最多一个 Leader。
  • 心跳:Leader 周期性发送,Follower 收到后重置选举定时器。

2.2 日志复制 ​

mermaid
sequenceDiagram
    participant Client
    participant Leader
    participant F1 as Follower 1
    participant F2 as Follower 2

    Client->>Leader: SET x=1
    Leader->>Leader: 追加到本地日志 term=1 idx=5 cmd=x1
    Leader->>F1: AppendEntries logs term=1 idx=5
    Leader->>F2: AppendEntries logs term=1 idx=5

    F1-->>Leader: ACK - 已持久化
    F2-->>Leader: ACK - 已持久化

    Note over Leader: 多数 2/3 已确认, commitIndex 5
    Leader->>Leader: 应用到状态机 x=1
    Leader->>Client: OK

    Leader->>F1: 下条心跳 AppendEntries 带 leaderCommit 5
    F1->>F1: 应用到状态机

关键保证:

  • 当日志被多数节点持久化后才算 committed
  • committed 的日志永不丢失(除非多数节点同时故障)
  • Leader 的日志一定是最新的(选举限制:Candidate 的日志不能比投票者旧)

2.3 安全性保证 ​

保证如何实现
选举安全每 Term 最多一个 Leader
Leader 日志完整性投票者检查 Candidate 的日志不比自己的旧
日志匹配性如果两个节点的日志在相同 index 和 term 一致,则之前的所有日志都一致
Leader 从不覆盖日志Leader 只追加,不修改已存在的 entry
状态机安全所有节点最终以相同顺序应用相同的日志

2.4 领导者变更 — 5 个节点示例 ​

初始: Term 5, Leader=S1, Followers=S2-S5

S1 宕机:
  S2 选举超时 (180ms) → Term 6, Candidate
  S2 收到 S3,S4 的投票 (3/5 > 半数) → 成为 Leader
  S5 可能晚收到,但已不影响结果

  S1 恢复:
    发现 Term 6 > 自己的 Term 5
    → 自动降级为 Follower

2.5 Raft vs 其他共识算法 ​

算法可理解性领导者日志典型应用
Raft⭐⭐⭐⭐⭐ 极易强 Leader复制日志etcd, TiKV, Consul
Paxos⭐ 极难多 Proposer单值Chubby
Multi-Paxos⭐⭐ 困难弱 Leader复制日志Spanner, ZooKeeper
ZAB⭐⭐ 困难强 Leader复制日志ZooKeeper
Viewstamped Replication⭐⭐⭐ 中等强 Leader复制日志学术

2.6 工程实践要点 ​

Raft 的三大性能瓶颈与优化:

瓶颈原因优化
串行写入所有写必须经过 Leader,单点瓶颈Multi-Raft(分 Region,每个 Region 独立 Raft 组)
磁盘 fsync每条日志必须 fsync 才能返回批量提交(Batch),pipeline 复制
领导者选举Leader 宕机后有不可用窗口(~1s)Pre-Vote(防网络分区后的无效选举)、租约(Lease)读
成员变更直接切配置可能产生双 LeaderJoint Consensus(两阶段过渡)

成员变更 — Joint Consensus

这是 Raft 中最精妙也最容易被忽视的设计。如果直接从一个 3 节点集群(S1,S2,S3)切换到 5 节点(S1,S2,S3,S4,S5),在中间状态可能出现两个互不认识的多数派,分别选出不同的 Leader——这被称为"双主脑裂"。

Raft 通过 Joint Consensus 解决:引入一个过渡配置 C_old,new,在此期间老多数派和新多数派都必须同意才能提交:

mermaid
flowchart LR
    Cold["C_old<br/>3节点 S1 S2 S3<br/>多数派 2"] -->|"写入 C_old_new"| ColdNew["C_old_new 过渡<br/>老3节点 + 新5节点<br/>多数派 3"]
    ColdNew -->|"写入 C_new"| Cnew["C_new<br/>5节点 S1-S5<br/>多数派 3"]

    Note["过渡期间:<br/>1. 日志复制需老新多数派都确认<br/>2. 选举需任一配置多数派投票<br/>保证不会有双 Leader"]
    ColdNew --> Note

简单理解:Joint Consensus 就像"改朝换代时的过渡期"——旧皇帝(C_old)还没退位,新皇帝(C_new)已经在逐步接管权力。在此期间,重要决策(日志提交)需要两朝元老(老多数派+新多数派)都同意,避免了权力真空。

Multi-Raft 示例 (TiKV):

整个集群按 Key Range 分片:
Region 1 ["" - "m"):    Raft Group 1 (Leader=S1)
Region 2 ["m" - "z"):   Raft Group 2 (Leader=S4)
Region 3 ["z" - ∞):     Raft Group 3 (Leader=S2)

每个 Region 独立选主、独立复制日志
→ 写入负载分散到所有节点

日志压缩与快照(Snapshot) ​

Raft 日志会无限增长——必须通过快照截断:

时间线: ... [entry 1-5000] [entry 5001-10000] [entry 10001-15000]
                                    ↑ 已 applied,可以截断

Follower 落后太多时,Leader 不再逐条发 AppendEntries,
而是直接发送全量快照(InstallSnapshot RPC):

1. Leader 对 applied 的日志做快照(状态机序列化 + 最后 applied index/term)
2. 发送 InstallSnapshot 给落后的 Follower
3. Follower 收到后:清空旧日志 → 加载快照 → 从快照的 index 之后开始接收新日志

实现要点:
  - 快照频率:etcd 默认每 10000 条日志做一次快照
  - 快照不宜太频繁(IO 开销),也不宜太稀(日志过长,恢复慢)
  - etcd 的快照机制:boltDB 的 MVCC 层天然支持快照,一边服务读写一边做快照

Lease Read — 绕过 Raft 日志的线性读优化 ​

问题:etcd 的读操作默认也要走一遍 Raft——Leader 需要广播心跳确认自己还是 Leader 后才能返回读结果。这引入了网络往返延时。

mermaid
sequenceDiagram
    participant Client
    participant Leader
    participant Followers

    Note over Client,Followers: 默认 ReadIndex 方式 - 串行读
    Client->>Leader: GET /key
    Leader->>Leader: 记录当前 commitIndex
    Leader->>Followers: 心跳广播,确认自己仍是 Leader
    Followers-->>Leader: ACK 多数确认
    Leader->>Leader: 等待 appliedIndex >= readIndex
    Leader->>Client: 返回 value - 线性一致性保证

    Note over Client,Followers: Lease Read 优化 - 减少一次网络往返
    Leader->>Leader: 在 Lease 有效期内,跳过心跳确认
    Leader->>Leader: 直接等待 appliedIndex >= readIndex
    Leader->>Client: 返回 value, 假设 Lease 期间无新 Leader

Lease Read 的安全前提:

Leader 租约时间 = 选举超时时间 - 一些缓冲(如 150ms 选举超时 → 100ms 租约)

在租约有效期内,Leader 可以安全地:
1. 跳过心跳确认 → 减少一次网络往返
2. 直接返回本地 applied 的数据

风险:如果发生了脑裂(旧 Leader 的网络分区),旧 Leader 在租约内
可能返回过期数据。但 etcd 通过以下方式规避:
  - 新 Leader 当选后,在租约到期前不服务读请求
  - 写请求始终走完整 Raft 流程(不受 Lease 影响)

etcd 生产调优关键参数 ​

参数默认值调优建议
HeartbeatTick1(内部时钟)网络稳定调为 1(更快故障检测),跨地域调为 2-3
ElectionTick10公式:选举超时 = ElectionTick × HeartbeatTick × 时钟周期。跨地域建议调大(20-30)
MaxInflightMsgs256高吞吐量场景调大(512-1024),减少 pipeline 等待
SnapshotCount100000根据写入量调整,写入密集降低到 50000
MaxRequestBytes1.5 MBgRPC 消息体限制,大批量场景调大
QuotaBackendBytes2 GB数据库大小限制,超过则只读。生产建议 8 GB

Paxos vs Raft — 本质区别展开 ​

Paxos (Lamport, 1989):
  - 两阶段:Prepare/Promise → Accept/Accepted
  - 每个实例独立达成共识
  - 多 Proposer 可以同时发起提案 → 活锁风险
  - 论文极其晦涩,工程实现各有变体

Raft (Ongaro, 2014):
  - 强 Leader 模型:所有日志通过 Leader 分发
  - 日志连续性保证:Log Matching Property
  - 角色分离:Leader / Follower / Candidate
  - 设计目标:Understandable(可理解的)

核心差异:
  1. Paxos 可以乱序提交(每个 instance 独立),Raft 必须顺序提交
     → Raft 简单但写入是串行的,Multi-Paxos 可以一定程度并行

  2. Paxos 中 Leader 只是"加速器"(任何节点都可以发起提案)
     Raft 中 Leader 是核心(只有 Leader 能发起日志复制)

  3. Paxos 的日志可能不连续(holes),需要额外协议补齐
     Raft 保证日志连续,通过 Log Matching Property 简化恢复

为什么工业界选 Raft?
  - 正确性容易验证(TLA+ 形式化证明)
  - 实现简单(etcd Raft 库 ~3000 行,Paxos 实现通常上万行)
  - 工程化友好(成员变更、快照、配置变更都明确定义)

3. 分布式 Hash 表与共识的结合应用 ​

系统数据分布共识算法说明
etcd单 Raft 组Raft所有 key 在一个 Raft 组
TiKVRange 分片 + RaftMulti-Raft每个 Region 一个 Raft 组
Redis ClusterHash SlotGossip + 配置纪元去中心化,无共识
Apache Kafka分区 + ISR类 Raft (KRaft 3.3+)每个分区一个 Leader
Consul单 Raft 组Raft服务发现 + KV
mermaid
flowchart LR
    subgraph Dist["分布式数据系统设计三角"]
        A["一致性 Hash<br/>数据如何分布到节点?"]
        B["Raft / Paxos<br/>多副本间如何达成一致?"]
        C["Gossip / SWIM<br/>节点如何发现彼此?"]
    end

    A <--> B
    B <--> C
    C <--> A

    style A fill:#4CAF50,color:#fff
    style B fill:#2196F3,color:#fff
    style C fill:#FF9800,color:#fff

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记