惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

月光博客
月光博客
H
Hackread – Cybersecurity News, Data Breaches, AI and More
V
Vulnerabilities – Threatpost
L
LangChain Blog
Stack Overflow Blog
Stack Overflow Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
云风的 BLOG
云风的 BLOG
C
Cisco Blogs
V
Visual Studio Blog
L
Lohrmann on Cybersecurity
Latest news
Latest news
S
Securelist
The Last Watchdog
The Last Watchdog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
The Register - Security
The Register - Security
Webroot Blog
Webroot Blog
The Cloudflare Blog
S
Secure Thoughts
Y
Y Combinator Blog
aimingoo的专栏
aimingoo的专栏
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
N
News and Events Feed by Topic
S
Security Affairs
Attack and Defense Labs
Attack and Defense Labs
Microsoft Azure Blog
Microsoft Azure Blog
T
Tailwind CSS Blog
V2EX - 技术
V2EX - 技术
GbyAI
GbyAI
L
LINUX DO - 热门话题
PCI Perspectives
PCI Perspectives
Schneier on Security
Schneier on Security
V
V2EX
K
Kaspersky official blog
Hugging Face - Blog
Hugging Face - Blog
AWS News Blog
AWS News Blog
T
The Exploit Database - CXSecurity.com
C
CERT Recently Published Vulnerability Notes
C
Cyber Attacks, Cyber Crime and Cyber Security
P
Proofpoint News Feed
T
Threatpost
WordPress大学
WordPress大学
SecWiki News
SecWiki News
B
Blog RSS Feed
Blog — PlanetScale
Blog — PlanetScale
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
A
Arctic Wolf
酷 壳 – CoolShell
酷 壳 – CoolShell
W
WeLiveSecurity
Jina AI
Jina AI
D
Darknet – Hacking Tools, Hacker News & Cyber Security

博客园_首页

Plist 二进制格式 Milvus 和 PGVector,哪个更好? OpenClaw 已过时?在 VS Code 中运行 Hermes Agent! 第30篇文章:一个大三计科生的自白 Manim如何在数学公式中完美显示中文? Docker 部署 RocketMQ 5 并发编程核心概念辨析 C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生 CLI 是什么?为什么大厂突然集体卷命令行? 【从0到1构建一个ClaudeAgent】协作-自主Agent UIImageView 设置图片不生效的原因排查 最小二乘问题详解20:无先验约束下的增量式SFM自由网平差 痞子衡嵌入式:大话双核i.MXRT1180之XIP应用里借助MU实现可靠Flash IAP的方法 AI Chat 封装, SemanticKerne.AiProvider.Unified 已发布 Windows下右键编辑js文件无法打开记事本——在注册表中使用环境变量 在后台服务中使用 Scoped 服务,为什么总是报错? H200 安装驱动并使用sglang启动模型 wireshark 抓包Trap上报告警内容 我用 AI 辅助开发了一系列小工具(2):图片压缩工具 [A Primer On MC and CC] 2.1 Memory Consistency 1 - 指令重排序和 SC 模型 Oracle数据库SCN推进技术详解与实践指南 玩转控件:封装个带图片的Label控件 Claude Code 4.7 真正该升级的不是模型,而是你的工作流 前端小白一句话,AI 帮我做了个颜值拉满的桌面媒体播放器。当代码不再是门槛,一句话编程就是现实。 5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具 SQLite 分片方案实战:三种分片策略的深度对比 告别简陋 UI!一款基于 Fluent Design 和基于 WinUI 的开源免费、现代化的 Avalonia UI 控件库 关于二进制排列组合枚举的总结 AI开发-python-LangGraph框架(3-27-LangGraph从零实现大模型智能决策工作流) ElasticSearch主分片和副本分片概念详解 【002】HTTPS 粗解:证书、TLS 握手与对后端配置的影响 Hermes Agent 一周暴涨五万 Star,但我劝你别急着追 明明连接的是Redis的DB0,为什么能查到DB3的数据? 【从0到1构建一个ClaudeAgent】协作-Agent团队 熟悉电子元器件之后,电子小白下一步该怎么走? MAF快速入门(23)通过C#类定义Skills .NET 高级开发 | 手写一个对象映射框架 FastAPI数据库ORM怎么选?我肝了三个Demo后,终于不再纠结了 mysqldump 参数拾遗:在遗忘与铭记之间 C# .NET 周刊|2026年3月5期 Claude code入门 - 陈彦斌 一文学习入门 ThingsBoard 开源物联网平台 GitHub 热门项目 | 2026年04月16日 如何为GIT设置全局勾子,为每次提交追加信息 Number.isFinite和isFinite与isNaN()和Number.isNaN的区别 PortSwigger SQL注入LAB2 推荐一个测试人必备的Skills,从功能到性能全搞定(附详细实操和安装下载方式) 筑基期:掌握Odoo基础核心知识点02(Odoo XML 开发方式详解) GLM模型这么火,咱们用vllm也咧一个呗! 深入理解 AbortController:从底层原理到跨语言设计哲学 字符串学习笔记 多租户系统框架的基础模块设计和分析设计 Apache SeaTunnel Zeta 为什么能做到“又快又稳”? AI开发-python-LangGraph框架(3-26-LangGraph基本概念及第一个简单样例) Vue 3 组件通信,别只会用 Props 和 Emits 了,这几个狠活儿你得看看 ElasticSearch7.X版本配置密码 用Manim实现动态交点计算--从一个动点问题说起 团结引擎+Addressable+Instant Game打包抖音小游戏 function call 实战:让 LLM 自动判断 pod 异常、调用日志工具并完成故障分析 bubseek —— 让 Agent 的足迹,变成团队的洞察 通过 C# 读取并导出 PDF 书签 如何用 GitHub Actions 实现 Steam 自动化发布 【从0到1构建一个ClaudeAgent】并发-后台任务 .NET 高级开发 | 定制 ASP.NET Core 框架 电子小白:什么是运算放大器(运放) zero2Agent:面向大厂面试的 Agent 工程教程,从概念到生产的完整学习路线 堆上的ORW HC32F460 USB CDC通信异常:非对齐访问异常排查 20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过 那些喊着AI 要淘汰你的人,正在靠你的焦虑赚大钱! 深度学习进阶(八)Swin Transformer 最小二乘问题详解19:带先验约束的增量式SFM优化与实现 SnapTranslate 3.0 正式发布:全局划词翻译 + 完整英语学习闭环,一站式搞定查词、记词、复习 工作的意义、工作的困难认知再思考 .NET + AI 进阶实战:基于类的技能开发 - 打造可治理的 Agent 能力模块 【从0到1构建一个ClaudeAgent】规划与协调-技能 上周热点回顾(4.6-4.12) 电子小白的工具三件套:面包板、杜邦线、万能板 单表五亿数据的查询优化 | Mysql、StarRocks 2. WorkBuddy:从“我是谁”到“帮我干活” C# 如何减少代码运行时间:7 个实战技巧 基于HelixToolkit.SharpDX 渲染3D模型 - 笺上知微 从零开始的双臂具身VLA起源及现阶段发展综述 - SkyXZ 记对 xonsh shell 的使用, 脚本编写, 迁移及调优 - pluvium27 受够了Vibe Coding的失控?换个起点,让AI事半功倍 从开始配置漏洞环境到漏洞复现流程 - 難しい 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 - 虚无境 Any metadata 的内存布局 C# .NET 周刊|2026年3月2期 - InCerry 我帮你测过了,测试圈排名第二的 Skill 依然很牛逼 Skill Discovery | 无监督技能发现的经典工作总结 - MoonOut 上下文工程是什么?过时了么?一文讲明白! - 一枫说码 开了 TUN 模式还是直连?90% 的人都踩过这个坑 AScript扩展多种脚本语言 - rockey627 AI 学习笔记:Agent 的记忆机制 你能被装进一个文件里吗?——7 万人把同事"蒸馏"成了 AI - 我没有三颗心脏 Claude Code 通关手册(七):给 AI 装上技能包——Skills 完全指南 - 暮色之狐 在浏览器中快速编辑代码:VSCode Web 集成实践 - Newbe36524 蒸馏自己 skill?基于 Deepseek 的蒸馏器,丐版蒸馏方式,简单便捷 - To_Carpe_Diem Spring AI Aliababa和AgentScope,哪个更好? - 苏三说技术
Leader 不参与读请求?etcd 线性读实现揭秘
胡云Troy · 2026-06-22 · via 博客园_首页

在很多分布式系统中,读请求通常都会转发给 Leader。

原因很简单:只有 Leader 才知道哪些日志已经提交(committed),哪些数据是最新的。

但 etcd 允许 Follower 节点直接处理线性一致读(Linearizable Read)。

这不禁让人产生几个疑问:

  • Follower 如何知道自己读到的是最新数据?
  • Leader 如何向 Follower 证明某个状态已经提交?
  • 为什么 ReadIndex 不需要写日志,却依然能够保证线性一致性?

本文将结合 etcd 源码,分析线性一致性读的完整实现。

线性读和可串行化读区别

在介绍源码前先讲下线性读和可串行化读的区别。

线性读满足 CAP 理论中 C 的读模式,意味着只要写“成功”(commited)的数据,线性读一定可以读到该数据,否则将不可用,它满足 CAP 理论的 CP 模式。

可串行化读不会与 Leader 进行额外交互,因此可能读到落后的数据。
它牺牲了一致性要求,换取更低的延迟和更高的可用性。

leader/follower 节点可自然的提供可串行化读服务,它们的处理逻辑是一样的,都是根据请求从本地存储引擎读数据。

而线性读因为是“最新”的数据,当 follower 接收到线性读请求时可以把请求转发给 leader 处理(因为 leader 知道哪些是“最新”的),这也是很自然的。
但是线性读请求太多(etcd 用线性读还是很普遍的,比如 kubernetes)会造成 leader 节点负载过大(leader 只有一个)严重的话可能会造成服务不可用。

etcd 解决这种问题的方案是 follower 节点也可以处理线性读请求。follower 节点通过和 leader 节点交互获取“最新”数据,然后在本地存储引擎读取最新数据。这种方式大大减轻了 leader 节点的压力。笔者觉得这是非常巧妙的解决方案。

func (s *EtcdServer) Range(ctx context.Context, r *pb.RangeRequest) (*pb.RangeResponse, error) {  
    ...
  
	// 1. 判断是否开始线性读
    if !r.Serializable {  
       // 2. 进入线性读处理逻辑
       err = s.linearizableReadNotify(ctx)  
       ...
       if err != nil {  
          return nil, err  
       }  
    }  
    ... 
  
    // 3. get 用于读存储引擎中的数据
    get := func() { resp, _, err = txn.Range(ctx, s.Logger(), s.KV(), r) }  
    // 4. 可串行化读流程,会回调 get 获取数据
    if serr := s.doSerialize(ctx, chk, get); serr != nil {  
       err = serr  
       return nil, err  
    } 
    // 5. 返回 resp 数据 
    return resp, err  
}

整个读请求流程由 EtcdServer.Range 处理,注意 leader 或 follower 都可以处理该请求。

线性读和可串行化读的区别在于是否进入 2 线性读处理逻辑。这里 2 是核心,我们在进入 2 之前先把可串行化读逻辑看一遍,毕竟这部分是共用的。

可串行化读

可串行化读主要由 3 和 4 处理,3 是一个闭包函数,通过 4 回调。我们看下 4 在干嘛:

func (s *EtcdServer) doSerialize(ctx context.Context, chk func(*auth.AuthInfo) error, get func()) error {  
    // 处理认证鉴权
    ai, err := s.AuthInfoFromCtx(ctx)  
    if err != nil {  
       return err  
    }  
     
    ...
    // 回调 get 函数 
    get()  
    ... 
    return nil  
}

不难理解,如果请求通过认证鉴权就会进入 get 函数处理该读请求:

func Range(ctx context.Context, lg *zap.Logger, kv mvcc.KV, r *pb.RangeRequest) (resp *pb.RangeResponse, trace *traceutil.Trace, err error) {  
    ...
    // 调用 mvcc kv.Read 获取读事务
    txnRead := kv.Read(mvcc.SharedBufReadTxMode, trace)  
    defer txnRead.End()  
    // executeRange 处理读请求
    resp, err = executeRange(ctx, lg, txnRead, r)  
    return resp, trace, err  
}

func executeRange(ctx context.Context, lg *zap.Logger, txnRead mvcc.TxnRead, r *pb.RangeRequest) (*pb.RangeResponse, error) {  
    ...
    // 根据请求组装 mvcc.RangeOptions 结构
    ro := mvcc.RangeOptions{  
       Limit: limit,  
       Rev:   r.Revision,  
       Count: r.CountOnly,  
    }
    
    // 读事务读请求的数据
    rr, err := txnRead.Range(ctx, r.Key, mkGteRange(r.RangeEnd), ro)  
	if err != nil {  
	    return nil, err  
	}
	
	// 对请求数据 rr 进行处理得到 resp 并返回
	return resp, nil
}

核心工作流程是通过读事务的 Range 方法获取存储引擎中的数据。

这是可串行化读的流程,我们从之前的分析可知,线性读需要和 leader 节点交互获取“最新”数据,然后等本地更新最新数据之后才能走可串行化读流程,这部分就是在 linearizableReadNotify 做的。

线性读

是时候看看期待已久的 linearizableReadNotify 在做什么了。

func (s *EtcdServer) linearizableReadNotify(ctx context.Context) error {  
    s.readMu.RLock()  
    nc := s.readNotifier  
    s.readMu.RUnlock()  
  
    // signal linearizable loop for current notify if it hasn't been already  
    select {  
    case s.readwaitc <- struct{}{}:  
    default:  
    }  
  
    // wait for read state notification  
    select {  
    case <-nc.c:  
       return nc.err  
    case <-ctx.Done():  
       return ctx.Err()  
    case <-s.done:  
       return errors.ErrStopped  
    }  
}

方法并不复杂,不过逻辑很有意思。首先理清它的流程才能知道这个方法到底在干嘛。

方法先把 s.readNotifier 赋给变量 nc,注意这里用的是读锁,意味着读请求都将获得 s.readNotifier
接着进入 select,它将 struct{}{} 发给 s.readwaitc 通道,该通道是一个有缓冲,容量为 1 的通道:

s.readwaitc = make(chan struct{}, 1)

这个 select 是在做什么呢?我们想象下,第一个请求进来往 s.readwaitc 发通知,如果 s.readwaitc 还在处理请求,那么同时来的其它请求会进入 select:default 分支。
最后请求都会阻塞在 nc.c 等待处理结果。

大致知道发送端的流程,我们看消费端是怎么消费 s.readwaitc 的:

func (s *EtcdServer) linearizableReadLoop() {  
    for {  
       // 生成下一个请求的 id
       requestID := s.reqIDGen.Next()  
       select {  
       ... 
       // 阻塞在 readwaitc 通道
       case <-s.readwaitc:
       ...
       }
       
       // 生成下一个 notifier
       nextnr := newNotifier()
       // 加写锁  
	   s.readMu.Lock()  
	   // 将 s.readNotifier 赋值给变量 nr
	   nr := s.readNotifier  
	   // 将下一个 notifier 赋值给 s.readNotifier
	   s.readNotifier = nextnr  
	   s.readMu.Unlock()
	   
	   confirmedIndex, err := s.requestCurrentIndex(leaderChangedNotifier, requestID)  
	   if isStopped(err) {  
		   return  
	   }  
	   if err != nil {  
		   nr.notify(err)  
		   continue  
	   }
	   
	   // 获取本节点应用索引
	   appliedIndex := s.getAppliedIndex()  
       // 如果本节点应用索引小于 confirmed 索引,说明请求的数据还没应用到本节点
       // 需要继续等
       if appliedIndex < confirmedIndex {  
          select {  
          case <-s.applyWait.Wait(confirmedIndex):  
          case <-s.stopping:  
             return  
          }  
       }  
       // 如果本节点应用索引大于等于 confirmed 索引,说明请求的数据已应用到本节点
       // 直接返回即可  
       nr.notify(nil)  
       ...
    }  
}

可以把 readwaitc 理解为一个“批处理触发器”。

第一个到达的读请求负责通知后台协程执行一次 ReadIndex。后续同时到达的读请求不会再次触发 ReadIndex,而是挂到同一个 notifier 上等待结果。

因此,1000 个同时到达的线性读请求,最终只会触发一次 Leader 交互。

这正是 etcd 线性读高性能的重要原因。

我们也可以用 sync.Cond 来实现,如果多个请求发现已经有请求在处理了则休眠,等待请求处理完被唤醒。但是 sync.Cond 不能天然的表示分批的概念,不如通道实现来的优雅。

etcd 用这种方式高效且巧妙的解决了高并发分批读请求。

linearizableReadLoop 接收到读请求通知后会和 leader 交互换取现在最新的 committed index,后续根据该 committed index 从本地读数据。如果本地应用 index 已经大于等于 committed index,则表示本地已经是最新了,可以直接走可串行化读。如果本地应用 index 小于 committed index,表示本地不是最新数据,需要等待本地 raft 更新应用 index 到 committed index 才能开始可串行化读。

这里的重点在:

// requestCurrentIndex 获取 confirmed index(也就是 leader 的 committed index)
confirmedIndex, err := s.requestCurrentIndex(leaderChangedNotifier, requestID)

func (s *EtcdServer) requestCurrentIndex(leaderChangedNotifier <-chan struct{}, requestID uint64) (uint64, error) {  
    // sendReadIndex 发送 ReadIndex 消息给 leader
    err := s.sendReadIndex(requestID)  
    if err != nil {  
       return 0, err  
    }  
  
	...
  
    for {  
    select {  
    case rs := <-s.r.readStateC:  // 阻塞等待 raftNode.readStateC 通道
       requestIDBytes := uint64ToBigEndianBytes(requestID)  
       // 判断返回的响应是否是本次请求的响应
       gotOwnResponse := bytes.Equal(rs.RequestCtx, requestIDBytes)  
       if !gotOwnResponse {  
          ...
          // 如果不是,继续阻塞等本次请求的响应
          continue  
       }  
       // 如果是返回本次请求的 committed index
       return rs.Index, nil
       ...
    }
    ...
}

requestCurrentIndex 的逻辑包含两块:

  1. sendReadIndex 发送 ReadIndex 消息给 leader:
func (s *EtcdServer) sendReadIndex(requestIndex uint64) error {  
    ctxToSend := uint64ToBigEndianBytes(requestIndex)  
  
    cctx, cancel := context.WithTimeout(context.Background(), s.Cfg.ReqTimeout())  
	// 调用本机 raft 发送 ReadIndex 消息给 leader
    err := s.r.ReadIndex(cctx, ctxToSend)  
    cancel()  
    ..
    return nil  
}

func (n *node) ReadIndex(ctx context.Context, rctx []byte) error { 
	// 发送 MsgReadIndex 给 leader
    return n.step(ctx, pb.Message{Type: pb.MsgReadIndex, Entries: []pb.Entry{{Data: rctx}}})  
}
  1. 阻塞等待本节点 raftNode.readStateC 通道获取 leader 的响应;

requestCurrentIndex 的核心目标只有一个:向 Leader 询问当前已确认的 committed index。

leader 处理线性读请求

follower 将 pb.MsgReadIndex 消息发送给 leader,leader 是如何处理的呢?我们继续看:

func stepLeader(r *raft, m pb.Message) error {
	switch m.Type {
	case pb.MsgReadIndex:
		// 判断是否只有一个投票节点(leader 自己),如果是的话 leader 直接返回它的 committed index
		if r.trk.IsSingleton() {  
		    if resp := r.responseToReadIndexReq(m, r.raftLog.committed); resp.To != None {  
		       r.send(resp)  
		    }  
		    return nil  
		}  
		  
		// 检查 leader 是否是新上任的 leader且当前 term 还没有写 index  
		if !r.committedEntryInCurrentTerm() {  
		    // 如果是新上任 leader 且没有写 index 则进入 pendingReadIndexMessages
		    r.pendingReadIndexMessages = append(r.pendingReadIndexMessages, m)  
		    return nil  
		}  
		  
		// 如果当前 leader 的 term 已经有 index 则返回
		sendMsgReadIndexResponse(r, m)  
		  
		return nil

leader 的处理逻辑分三块:

  1. 判断集群是不是只有一个 leader,如果是直接返回;
  2. 如果不是,则判断该 leader 在当前 term 下有没有写 index,如果没有写则进入 raft.pendingReadIndexMessages 处理;
  3. 如果当前 term 有写 index,则返回;

这里为什么要判断 leader 在当前 term 有没有写 index 呢?

实际上对应 Raft 论文 Figure 8 的经典场景。
Raft 证明了: 旧 Term 的日志即使已经复制到多数节点,Leader 也不能直接认为它已经 committed。因为未来仍然可能出现新的 Leader,并覆盖这些旧日志。

因此,Leader 必须先提交一条当前 Term 的日志。一旦当前 Term 的日志被提交,根据 Raft 的安全性证明,其之前的所有日志也必然已经 committed。

这也是 committedEntryInCurrentTerm 存在的原因。

committedEntryInCurrentTerm 如下:

func (r *raft) committedEntryInCurrentTerm() bool {  
    // 判断当前 term 有没有提交 index 
    return r.raftLog.zeroTermOnOutOfBounds(r.raftLog.term(r.raftLog.committed)) == r.Term  
}

如果当前 term 已经发过 index 了,则直接处理 ReadIndex 响应:

func sendMsgReadIndexResponse(r *raft, m pb.Message) {  
    case ReadOnlySafe:  
       r.readOnly.addRequest(r.raftLog.committed, m)   
       r.readOnly.recvAck(r.id, m.Entries[0].Data)  
       r.bcastHeartbeatWithCtx(m.Entries[0].Data)  
    case ReadOnlyLeaseBased:  
       if resp := r.responseToReadIndexReq(m, r.raftLog.committed); resp.To != None {  
          r.send(resp)  
       }  
    }  
}

这里响应前需要确保当前 leader 是有效 leader。有两种响应类型,一种是走一遍心跳,如果当前节点得到多数节点响应则返回 committed index。另一种是根据 ReadOnlyLease 判断是否是 leader,它不需要走一遍心跳。优点是快,缺点是它在时间窗口内确定自己是不是 leader,如果时间同步有问题则无法确定自己是不是 leader(几乎不可能出现这种情况)。

leader 返回给 follower 的是 pb.MsgReadIndexResp 消息:

func (r *raft) responseToReadIndexReq(req pb.Message, readIndex uint64) pb.Message {  
    ... 
    return pb.Message{  
       Type:    pb.MsgReadIndexResp,  
       To:      req.From,  
       Index:   readIndex,  
       Entries: req.Entries,  
    }  
}

如果 leader 在当前 term 没有提交过 index,则将 MsgReadIndex 消息加入 raft.pendingReadIndexMessages

if !r.committedEntryInCurrentTerm() {  
    r.pendingReadIndexMessages = append(r.pendingReadIndexMessages, m)  
    return nil  
}

加入到 pendingReadIndexMessages leader 就返回,并未给 follower 返回响应消息,那 leader 中是哪里在处理 pendingReadIndexMessages 数组呢?

由于 leader 中当前 term 还没有提交 index,leader 中的 raft 会提交一个 index:

func stepLeader(r *raft, m pb.Message) error {
	switch m.Type {
	case pb.MsgAppResp:
		pr.RecentActive = true
		if m.Reject {
			// 处理拒绝逻辑
			...
		} else {
			if pr.MaybeUpdate(m.Index) || (pr.Match == m.Index && pr.State == tracker.StateProbe) {
			...
			if r.maybeCommit() {
				// 当前 term 已提交 index,进入 releasePendingReadIndexMessages
				releasePendingReadIndexMessages(r)  
				r.bcastAppend()
			}
			...
		}
		...
	}
	...	
}

提交完 index 后进入 releasePendingReadIndexMessages

func releasePendingReadIndexMessages(r *raft) {
	// 判断是否有 ReadIndex 请求需要发送  
    if len(r.pendingReadIndexMessages) == 0 {       
       // 如果没有则返回
       return  
    }  
    // 判断当前 term 是否已经提交 index,理论上是已经提交了
    if !r.committedEntryInCurrentTerm() {  
       r.logger.Error("pending MsgReadIndex should be released only after first commit in current term")  
       return  
    }  
  
	// 清空 pendingReadIndexMessages 数组
    msgs := r.pendingReadIndexMessages  
    r.pendingReadIndexMessages = nil  
  
	// 遍历消息,将 ReadIndexResp 消息发给 follower 节点
    for _, m := range msgs {  
       sendMsgReadIndexResponse(r, m)  
    }  
}

可以看到,在 raft 内部提交当前 term 的 index 后会去判断 pendingReadIndexMessages 中的数组是否有消息,如果有,则遍历数组返回 MsgReadIndexResp 的响应给 follower。

至此,我们知道 leader 是如何处理并响应 follower 的 MsgReadIndex 请求。那么,follower 又是处理 leader 的发来的 MsgReadIndexResp 响应的呢?

follower 处理 MsgReadIndexResp 消息

leader 发来的响应包括请求 id 和 committed index 的对应关系。follower 在 raft 状态机处理 leader 发来的 MsgReadIndexResp 响应:

func stepFollower(r *raft, m pb.Message) error {
	switch m.Type {
	...
	case pb.MsgReadIndexResp:  
       ...
       // 将返回的 committed index 和 request id 组成 ReadState 放入 raft.readStates 数组
       r.readStates = append(r.readStates, ReadState{Index: m.Index, RequestCtx: m.Entries[0].Data})  
    }  
    return nil  
}

follower 会将 ReadState 发给 raft.readStates 数组。raft 会将数组组成 ready 消息发给上层应用状态机:

func (r *raftNode) start(rh *raftReadyHandler) {
	go func() {
		...
		for {
			select {
			case rd := <-r.Ready():
			if len(rd.ReadStates) != 0 {  
			    select {  
			    // 将响应发给 raft.readStateC 通道
			    case r.readStateC <- rd.ReadStates[len(rd.ReadStates)-1]:
			    ...
			}
			...
		}
	...
	}
	...
}

终于经过 follower -> leader -> follower 这一圈获取到 请求 id 对应的最新的 committed index,并且 index 写入到 follower 节点的 raft.readStateC 通道。

requestCurrentIndex 会监听该通道并处理,后面的逻辑就不复杂了,篇幅有限就不介绍了。

小结

etcd 线性读流程示意图如下:

sequenceDiagram participant C as Client participant F as Follower participant L as Leader C->>F: Range Request F->>L: MsgReadIndex L->>L: Confirm Leadership L-->>F: ReadIndexResp(index=100) F->>F: applyWait.Wait(100) F->>F: wait applyIndex >= 100 F->>F: MVCC Read F-->>C: Response

etcd 线性读的核心思想可以概括为三步:

  1. Follower 通过 ReadIndex 向 Leader 获取当前已确认的 committed index;
  2. 等待本地 applyIndex 追赶到该 committed index;
  3. 在本地 MVCC 中执行普通读请求。

整个过程中:

  • 不需要将读请求转发给 Leader;
  • 不需要写入额外日志;
  • 多个并发读请求还能合并为一次 ReadIndex 操作。

因此 etcd 在保证线性一致性的同时,也获得了极高的读性能。这也是 ReadIndex 机制最精妙的地方。