Skip to content

分布式共识算法实战:Etcd 的 Raft 实现,如何保证线性一致读

问题

Etcd 作为分布式 KV 存储,如何保证强一致性?Raft 协议在 Etcd 中是如何实现的?线性一致读(Linearizable Read)有哪几种实现方式,各有什么优缺点?

从 Raft 到 Etcd

Raft 是共识算法的一种,核心思路是选主 + 日志复制。集群中的节点通过选举产生一个 Leader,所有写请求都由 Leader 处理。Leader 将写入的日志条目复制到大多数节点(Quorum),确认后提交。这个机制保证了写入的强一致性——只要多数节点活着,写入就不会丢。

写操作的完整链路(Raft 日志复制)

Client         Leader                        Follower-1          Follower-2
  |             |                                |                   |
  |--Set(k=1)-->|                                |                   |
  |             |-- AppendEntry(term=1, index=5) |                   |
  |             |-- AppendEntry(term=1, index=5) |                   |
  |             |<-- AppendEntry ACK ------------|                   |
  |             |<-- AppendEntry ACK --------------------------------|
  |             |(Quorum 达成,commit index=5)    |                   |
  |             |-- Apply k=1 → state machine    |                   |
  |             |-- AppendEntry(commit=5) ------->|                   |
  |             |-- AppendEntry(commit=5) ------->|                   |
  |             |                                |-- Apply k=1       |
  |             |                                |                   |-- Apply k=1
  |<-- OK ------|                                |                   |

关键点:客户端收到 OK 时,只有 Leader 保证 apply 完成。Follower 可能还没 apply。这就是为什么 Raft 的读需要额外处理——读和写走的是两条不同的路径

Etcd 在 v3 版本中完整实现了 Raft 协议,核心能力就是强一致的配置存储。Kubernetes 等系统的集群状态数据全部放在 Etcd 中,依赖的就是它的强一致性保证。

但问题来了:读操作怎么办?

如果每次读都走一遍 Raft 的完整流程(写日志、复制、提交、apply),成本太高,性能无法接受。但如果直接读 Leader 的本地状态,网络分区时 Leader 可能已经不是合法的 Leader,读到的数据可能是过期的。

这就引出了线性一致读的三种实现方式。

三种线性一致读方案

1. Leader Read(默认方案,v3.4 之前)

原理:读请求直接打到 Leader,Leader 依靠心跳机制确认自己仍是 Leader,然后返回本地状态。

流程时序

Client          Leader           Follower-1       Follower-2
  | --- Read(k) -> |
  |                | — heartbeat → |              |
  |                | ← heartbeat — |              |
  |                | — heartbeat →               |
  |                | ← heartbeat —               |
  |                | (认为自己仍是 Leader)
  |                | (直接读本地状态)
  | <- value ----  |

问题发生在哪? 网络分区场景:

  [分区前]
  Node-A (Leader)   Node-B            Node-C
    心跳正常         心跳正常          心跳正常

  [分区后]
  Node-A (旧 Leader, 踢出群)     Node-B (新 Leader)   Node-C
    心跳只发给自己                   互相心跳正常,选 B 为新 Leader
    读请求来了 → 返回过期数据        写入 k=2
                                 读请求 → 返回 k=2(正确)

旧 Leader 没有收到新 Leader 的任期更新,依赖心跳 lease 认为自己还是 Leader,这时返回的是过期数据。这就是为什么 Leader Read 不是严格线性一致的。

竞选超时与分裂投票:Raft 的选举超时(Election Timeout)默认 150-300ms 随机。如果旧 Leader 的心跳间隔(Etcd 默认 100ms)小于选举超时,旧 Leader 在分区后会持续发送心跳给自己,但收不到多数节点的响应。当选举超时触发(Follower 150-300ms 没收到心跳),Follower 发起新选举,term 递增。旧 Leader 收到 term 更高的 AppendEntries RPC 后,才会降级为 Follower。这个窗口期就是线性一致读的漏洞。

优缺点

  • 性能最好,没有额外网络开销
  • 缺陷:如果发生网络分区,旧 Leader 被隔离但心跳没超时,它会以为自己还是 Leader,返回过期的数据——不是严格的线性一致

Etcd v3.4 之前默认就是 Leader Read,这也是为什么某些 Kubernetes 集群在发生网络分区后读到了过期的 Pod 状态。比如某公司 3 节点 etcd 集群,Leader 在 AZ-A,Follower 在 AZ-B 和 AZ-C。AZ-A 网络故障,旧 Leader 被隔断,但心跳 lease 还没过期,继续服务读请求。kube-scheduler 读到过期数据,把 Pod 调度到了已满的 Node 上,导致 Pod 调度失败率飙升 30%。

2. ReadIndex

原理:Leader 收到读请求后,先向 Quorum 节点发送心跳,确认自己确实是当前任期内的 Leader。确认后记录当前的 Commit Index,等待状态机 apply 到这个 Index,然后返回。

流程时序

Client          Leader          Follower-1      Follower-2
  | -- Read(k) -> |
  |               | -- heartbeat →  |              |
  |               | <-- heartbeat -- |              |
  |               | -- heartbeat →                |
  |               | <-- heartbeat --              |
  |               | (确认 Leader 身份,记录 readIndex = committed)
  |               | (等待状态机 apply 到 readIndex)
  |               | (读本地状态机)
  | <- value ---  |

关键代码(Etcd v3.5 的实际实现):

go
// server/v3/etcdserver/v3_server.go
func (s *EtcdServer) LinearizableReadNotify(ctx context.Context) error {
    // 1. 向 Raft 提交一个 ReadIndex 请求,内部会广播心跳
    //    确认 Leader 身份并获取当前 committed index
    s.r.Read(ctx) // 实际调用 raft.Node.ReadIndex()

    // 2. 等待 readState 被 apply 到状态机
    select {
    case <-s.readwaitc:
        // 安全读取
    case <-ctx.Done():
        return ctx.Err()
    }
    return nil
}

// raft.Node.ReadIndex() 在底层会构造 MsgReadIndex 消息
// 经过 raft.Step() 处理后,Leader 将当前 committed 记录到 readStates

Raft 协议层的 ReadIndex 实现(来自 etcd-raft 的 raft.go):

go
// raft.go 中 Step 方法处理 MsgReadIndex 消息
func (r *raft) Step(m pb.Message) error {
    // ...
    switch m.Type {
    case pb.MsgReadIndex:
        // 如果不是 Leader,把请求转发给 Leader
        if r.state != StateLeader {
            r.send(pb.Message{To: r.lead, Type: pb.MsgReadIndex, ...})
            return nil
        }

        // Leader 处理:
        // 1. 记录当前 committed index
        // 2. 向所有节点发送心跳确认
        // 3. 等待确认后,在 readStates 中记录
        r.readStates = append(r.readStates, ReadState{
            Index: r.raftLog.committed,
            // ...
        })
    }
}

ReadIndex 的 committed 确认机制:当 Leader 收到半数以上节点的心跳响应后,readStates 中的 committed index 才能被确认为安全。这是因为心跳响应中包含了 Follower 的 matchIndex,Leader 据此可以判断当前 committed 是否仍然是多数节点认可的。如果某个 Follower 的心跳响应超时,Leader 需要等待直到超时重试,或者判断该节点已失联,需要重新计算 Quorum。

关键优化——ReadIndex 批处理:Etcd 不会每个读请求都单独走一次 ReadIndex 流程。如果多个读请求在短时间内到达,它们可以共享同一个 ReadIndex 确认。Etcd 内部通过 readwaitc channel 实现:多个读请求阻塞在 select 等待,一次 ReadIndex 确认后,广播通知所有等待的读请求。这个优化在并发读场景下能大幅降低 RTT 开销。

go
// 伪代码:ReadIndex 批处理
type readIndexBatcher struct {
    pendingReads []chan struct{}  // 等待通知的读请求
    mu           sync.Mutex
}

func (b *readIndexBatcher) RequestRead(ctx context.Context) {
    // 创建等待 channel
    ch := make(chan struct{}, 1)
    b.mu.Lock()
    b.pendingReads = append(b.pendingReads, ch)
    b.mu.Unlock()

    // 如果这是第一个读请求,触发 ReadIndex 流程
    if len(b.pendingReads) == 1 {
        go s.r.Read(ctx)  // 触发一次 ReadIndex
    }

    // 等待 ReadIndex 结果
    select {
    case <-ch:
        // 可以安全读取
    case <-ctx.Done():
        return
    }
}

// ReadIndex 确认后,广播给所有等待的读请求
func (b *readIndexBatcher) notifyAll() {
    b.mu.Lock()
    for _, ch := range b.pendingReads {
        close(ch)
    }
    b.pendingReads = nil
    b.mu.Unlock()
}

优缺点

  • 严格保证线性一致,比 Leader Read 安全
  • 每次读多一次 RTT。跨机房场景下,假如 RTT 是 2ms,QPS 上限约 500/s(因为每个读都需要一次 Quorum 确认)
  • Etcd v3.4+ 默认使用 ReadIndex

3. LeaseRead

原理:Leader 当选后获得一个 Lease 时间窗口(默认 500ms),在这个窗口内 Leader 认为自己是合法的,不需要额外确认。读请求直接处理,Lease 过期前续约。

流程时序

时间线:0ms                   500ms                  1000ms
        | ← Lease 有效 → |    | ← Lease 有效 → |
        |                 |    |                 |
        当选 Leader       心跳续约             心跳续约
        读请求直接返回     读请求直接返回        读请求直接返回

安全隐患:如果 Lease 时间窗口内发生网络分区:

  [t=0]    Node-A 当选 Leader,Lease 开始 → 有效到 t=500ms
  [t=200]  网络分区,Node-A 与 B、C 断开
  [t=300]  客户端向 Node-A 发起读请求
           → Node-A 认为 Lease 还在有效期内,返回数据
           → 此时 Node-B 和 Node-C 已经选出了新 Leader(Node-B)
           → Node-A 返回的数据是过期的!
  [t=500]  Node-A 的 Lease 过期,才知道自己不是 Leader

Lease 时间越长,不一致窗口越大

  • 500ms Lease → 最多 500ms 的过期数据窗口
  • 100ms Lease → 最多 100ms,但续约频率更高(每 50ms 就要续约一次)
go
// 伪代码示例:LeaseRead 的实现
type LeaseRead struct {
    // lease 过期时间戳
    expireAt   time.Time
    // lease 时长,默认 500ms
    duration   time.Duration
}

func (l *LeaseRead) isLeaseValid() bool {
    // 在 Lease 有效期内,认为自己是合法 Leader
    return time.Now().Before(l.expireAt)
}

func (l *LeaseRead) renewLease() {
    // 每次成功心跳或写入后,续约 Lease
    l.expireAt = time.Now().Add(l.duration)
}

Etcd 的 LeaseRead 实现方式:Etcd 在 v3.4 通过 --experimental-enable-lease-read 标志引入了 LeaseRead,但这个标志在 v3.5 中已被移除,原因是 LeaseRead 的安全隐患被社区认定为不可接受的。Etcd 团队认为,对于一个配置存储系统,数据正确性远比那几毫秒的性能重要。移除的 PR 在 https://github.com/etcd-io/etcd/pull/12800 可以查到。

Follower 一致性读

还有一个被问得很多的问题:Follower 能不能处理读请求?

可以。Follower 收到读请求后,会通过 MsgReadIndex 消息转发给 Leader。Leader 走 ReadIndex 流程确认后,向 Follower 回复一个 readState 通知,Follower 收到通知后才能安全读取。

Client          Follower-1          Leader
  | -- Read(k) -> |
  |               | -- MsgReadIndex -> |
  |               |                    | (心跳确认 Leader 身份)
  |               |                    | (记录 committed)
  |               | <-- readState ---  |
  |               | (等待 apply 到 committed)
  | <- value ---  |

这个过程比直接读 Leader 多了一跳,时延更高,但可以分担 Leader 的读压力。Kubernetes 的 etcd 集群通常会把读请求均匀分发到所有节点。在 Etcd 的 clientv3 中,可以通过 WithSerializable(false) 选项控制是否开启串行化读(即线性一致读):

go
// 走 ReadIndex,严格线性一致(默认)
resp, err := cli.Get(ctx, "key")

// 走 Follower 读,分担 Leader 压力(但时延更高)
// 底层仍然是 Leader ReadIndex + Follower 等待通知
resp, err := cli.Get(ctx, "key", clientv3.WithSerializable())

// 允许过期读,最高性能
resp, err := cli.Get(ctx, "key", clientv3.WithSerializable(true))
// 这个读不走任何一致性检查,直接读 Follower 本地状态
// 可能读到过期数据,但时延最低

关于 WithSerializable 的坑:很多新人以为传了 WithSerializable() 就是"串行化读=强一致",实际上 WithSerializable() 是不带参数的选项,默认值就是 false(即强一致)。传 WithSerializable(true) 才是允许过期读。命名确实反直觉,注意区分。

实战:Etcd 做配置中心,读出 10 万 QPS

Etcd 本身单节点 QPS 上限大约 1-2 万(纯读,ReadIndex 模式下,受限于 RTT 和磁盘 IO)。具体实测数据:

场景节点数部署方式读 QPS读 P99 时延
Leader Read3 同机房单 region~150001-2ms
ReadIndex3 同机房单 region~80003-5ms
ReadIndex3 跨机房3 AZ~20008-15ms
Follower Read3 同机房单 region~60005-10ms
本地缓存+WatchN/A应用内100000+<0.1ms

如果作为配置存储需要更高的吞吐量,标准做法是本地缓存 + Watch 推模式

  应用进程
  ┌────────────────────────────┐
  │  Get(key) → 本地缓存          │  ← 读 10 万 QPS
  │                            │
  │  Watch 回调 → 更新缓存        │  ← 写 100 QPS(Etcd 承受)
  │                            │
  │  etcd 客户端 (Watch) ------ │
  └────────────────────────────┘


             Etcd 集群
         (写 100 QPS,完全够用)
go
// 实战:Etcd 做配置中心 + 本地缓存
type ConfigCache struct {
    mu       sync.RWMutex
    cache    map[string]string
    etcdCli  *clientv3.Client
}

func (c *ConfigCache) WatchAndCache(ctx context.Context, key string) error {
    rch := c.etcdCli.Watch(ctx, key, clientv3.WithPrefix())
    for wresp := range rch {
        for _, ev := range wresp.Events {
            c.mu.Lock()
            switch ev.Type {
            case mvccpb.PUT:
                c.cache[string(ev.Kv.Key)] = string(ev.Kv.Value)
            case mvccpb.DELETE:
                delete(c.cache, string(ev.Kv.Key))
            }
            c.mu.Unlock()
        }
    }
    return nil
}

func (c *ConfigCache) Get(key string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.cache[key]
    return v, ok
}

这个方案有个坑:Watch 可能丢事件(Etcd Watch 在连接断开重连时,如果 compaction 已经删除了历史版本,旧的变更事件会丢失)。Etcd 默认每 5 分钟做一次 compaction,保留最近 5000 个版本。如果 Watch 断开超过 5 分钟,重连时历史的变更事件可能已经被 compaction 清理了,导致缓存永远缺失了那段时间的变更。

解决方案:Watch 启动时先 Get(key) 全量拉一次,确保缓存和 Etcd 始终保持一致。

go
func (c *ConfigCache) Init(ctx context.Context, key string) error {
    // 1. 全量拉取,确保缓存基准
    resp, err := c.etcdCli.Get(ctx, key, clientv3.WithPrefix())
    if err != nil {
        return err
    }
    for _, kv := range resp.Kvs {
        c.cache[string(kv.Key)] = string(kv.Value)
    }

    // 2. 再启动 Watch
    go c.WatchAndCache(ctx, key)
    return nil
}

另一个坑:Watch 的 compaction 错误处理。如果 Watch 打开的 revision 已经被 compaction 清理了,Watch 会返回 ErrCompacted 错误。这时需要重新 Get 全量数据:

go
func (c *ConfigCache) WatchAndCache(ctx context.Context, key string) error {
    // 从当前 revision 开始 Watch
    resp, _ := c.etcdCli.Get(ctx, key, clientv3.WithPrefix())
    startRev := resp.Header.Revision

    rch := c.etcdCli.Watch(ctx, key, clientv3.WithPrefix(),
        clientv3.WithRev(startRev+1))
    for wresp := range rch {
        if wresp.Err() != nil {
            // compaction 错误,全量重拉
            c.mu.Lock()
            c.cache = make(map[string]string)
            c.mu.Unlock()
            go c.Init(ctx, key)
            return wresp.Err()
        }
        // 正常处理变更事件
        for _, ev := range wresp.Events {
            // ...
        }
    }
    return nil
}

生产环境选型建议

几个真实的生产事故场景

场景一:Kubernetes 集群网络分区后读到过期数据

某公司 3 节点 etcd 集群,Leader 在 AZ-A,Follower 在 AZ-B 和 AZ-C。AZ-A 网络故障,AZ-A 的 Leader 被隔断,但心跳 lease 还没过期,继续服务读请求。kube-scheduler 读到过期数据,把 Pod 调度到了已满的 Node 上,导致资源争抢。事后排查才发现是读到了过期数据。升级到 v3.4+ 启用 ReadIndex 后解决。

场景二:跨机房部署 ReadIndex 性能瓶颈

某公司 etcd 集群跨 3 个机房部署,机房间 RTT 约 5ms。ReadIndex 每次读需要一次 Quorum 确认,QPS 上限只有 200/s。ETCD 的 KV 存储 QPS 不够,导致业务方频繁超时。最终方案:改为本地缓存 + Watch 推模式,读走本地缓存,写走 Etcd,QPS 从 200 提升到 5 万+。

场景三:Watch compaction 导致缓存漏更新

某团队实现了一个配置中心 Watcher,服务运行半年后出现配置不一致。排查发现是因为网络抖动导致 Watch 连接中断 3 分钟,重连后 compaction 清理了中间 3 分钟的变更版本,导致配置缓存永远停留在旧版本。解决方案:添加了全量拉取的兜底逻辑。

选型对比

方案一致性读性能额外 RTT适用场景风险
Leader Read弱(网络分区下可能过期)最高0允许短时间不一致、非关键路径网络分区时返回过期数据,v3.4 之前默认
ReadIndex严格线性一致1 次 Quorum 确认生产环境推荐,数据一致性敏感跨机房场景下 QPS 受限,但一致性好
LeaseRead准线性一致(Lease 窗口内弱)0网络稳定、低延迟集群Lease 窗口内网络分区不一致,v3.5 已移除
Follower Read严格线性一致(底层 ReadIndex)2 次(Follower → Leader → Follower)读压力分担比直接读 Leader 时延更高

面试追问清单

面试官如果问 Raft 线性一致读,通常会从这几个角度深入:

  1. "Raft 的读和写用了同一个 RTT 吗?" — 不是。写需要做日志复制(AppendEntries RPC),读只需要 ReadIndex 的心跳确认(MsgHeartbeat RPC)。心跳包比 AppendEntries 小得多,所以读的 RTT 开销比写小。

  2. "ReadIndex 和 Leader Read 的性能差距有多大?" — 同机房实测,Leader Read 约 15000 QPS,ReadIndex 约 8000 QPS(3 节点,P99 1ms 以内)。差距主要来自心跳确认的 RTT。

  3. "怎么实现 Follower 读的负载均衡?" — Etcd clientv3 默认通过 round-robin 把请求分到所有节点。如果所有节点都处理读请求,Leader 的负担就减轻了。但 Follower 读走的是 Follower → Leader ReadIndex → Follower 等待通知 路径,实际上 Leader 还是参与了。

  4. "Watch 丢事件怎么处理?" — 初始化时先 Get 全量,再 Watch。Watch 返回 ErrCompacted 时重新 Get 全量。

  5. "Etcd 的 compaction 策略会影响读吗?" — compaction 不会影响 ReadIndex 读,但会影响 Watch 的 revision 可用性。生产环境建议调整 compaction 的保留版本数,默认 5000 对于高频变更场景不够。

总结

选型上记住一条原则:一致性保证和性能是 trade-off。Etcd v3.4+ 默认 ReadIndex 是推荐选择,因为大部分场景下多一次 RTT 的代价远低于数据不一致带来的故障排查成本。如果确实需要高吞吐,用本地缓存+Watch 做读写分离,比在一致性方案上做妥协更安全。

面试官问 Raft 读,想听的不是背定义,而是

  1. 你能否在一致性保证和性能之间做取舍?
  2. 是否理解 LeaseRead 的时间窗口安全隐患?
  3. Follower 如何处理读请求?
  4. Watch 推模式 + 本地缓存的全量初始化防丢事件方案?
  5. ReadIndex 的批处理优化如何实现?

能聊到这个层面,说明你不仅看过源码,还踩过坑。

一句话总结:Etcd 的读有 3 种方案,生产环境默认 ReadIndex,高吞吐用本地缓存+Watch,不要用 LeaseRead(v3.5 已移除自有原因)。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。