1. 为什么读etcd源码要先啃这两个结构体我最初接触raft协议时犯过一个典型错误对着论文反复背流程画了一堆状态迁移图自以为懂了结果一翻开etcd的raft实现源码就卡壳——找不到Leader向Follower发消息对应的代码搞不清日志复制的索引到底存在哪里。后来才意识到问题不在于协议本身有多难而在于我没能先把代码里的两个核心数据结构理清楚raftLog和raft。如果你有类似的感觉那我建议你在看任何流程代码之前先花两三个小时把这两个结构体逐字段过一遍。因为etcd的raft实现里几乎所有的状态流转、消息发送、日志提交最终都会落到这两个结构体的字段上。协议描述里那种Leader收到过半确认后提交日志的计算机科学语言对应到代码里其实就是某个字段跳变到一个新值然后触发了一次commit推进。raftLog负责管理节点的日志条目即我的日志仓库存了哪些数据、哪些已经安全持久化、哪些已经交给应用层去执行而raft结构体则是一个完整的节点状态机负责记录我是Follower还是Candidate还是Leader、当前任期是多少、我该向谁发起投票、心跳计时走到哪了、我要给哪些节点发什么消息。两者是协作关系raft决定做什么raftLog记录历史依据和结果。为了让后面的字段拆解有落脚点我先把它们之间的关系画在脑子里关注维度raftLograft核心职责日志追加、查询、提交角色切换、选举、消息调度核心数据已提交索引、已应用索引、不稳定日志当前任期、投票对象、节点状态、消息队列对外主要交互向应用层输出待应用日志通过网络消息与对端通信论文对应物Log持久化日志与易失状态State Machine 选举计时器这张表不是严谨的架构图但能帮你建立第一印象你看到的raft.raftLog字段是raft结构体内部的一个成员它把日志存取这件事单独封装起来了。再说句题外话很多人看源码喜欢直接从Step()函数进入追着消息的case分支一路走结果整个调用链太长追到一半就迷路了。我的建议是反过来先静态地把结构体字段全认一遍再回头追流程。这两小时的前置投入能省下后面十倍的时间。2. raftLog日志仓库的内部构造逐段拆解2.1 大框架stable与unstable的分层设计raftLog在逻辑上把日志分成了两层已经安全持久化在存储引擎里的部分和仍然只存在于内存中的部分。这么分的根本原因是etcd的raft实现把持久化视为一个相对昂贵的异步动作日志写入内存的速度远比落盘快。如果在网络消息处理路径上同步等待每一条日志都持久化完成整个集群的吞吐会被磁盘IO拖死。代码里对应的两个核心成员分别是storage Storage和unstable unstable。storage是一个接口向raftLog屏蔽了底层存储的差异只要实现InitialState、Entries、Term、LastIndex、FirstIndex、Snapshot这些方法raftLog就可以统一访问持久化日志。unstable则是专门缓存还没写进storage的日志条目和快照。unstable这个命名会让人有点困惑——它不是说日志不稳定、不可靠而是指这些日志还没有稳定地落到storage中。在etcd的代码里一旦storage成功保存了某条日志对应的unstable区间就会被收缩。理解了这个分层的动机你再看下面关于offset的解释就顺了。2.2 unstable的offset为什么是核心中的核心unstable结构体有三个关键字段entries []pb.Entry内存中待持久化的日志条目offset uint64entries[0]的日志索引号snapshot *pb.Snapshot尚未持久化的快照第一次读这段代码的人很容易只看表面以为offset只是一个起始序号。实际上offset决定了整个unstable区间在全局日志序列中的位置任何对unstable的操作都离不开它来换算下标。举个例子。假设你的日志索引1到10已经持久化在storage里了索引11和12刚被LeaderAppend进来还只存在于unstable的entries中那么offset就是11entries[0]对应索引11entries[1]对应索引12。此时如果要取索引11的日志代码会先判断11是否落在[offset, offsetlen(entries))范围内是的话才从entries里取否则回到storage里查。这个换算逻辑几乎是所有raftLog方法的公共前奏。什么时候会踩坑呢当unstable中存在多条日志但你又恰好从中间某条开始取时如果你忘了减去offset取出来的结果会整体错位。我在调试模拟项目X的日志回放功能时就遇到过节点之间日志始终对不上最后定位到就是某处索引换算少减了一个offset。这个问题不直观但排查思路其实很简单把所有读取入口都过一遍找到那个没有经过entries[offset]换算的地方。2.3 committed与applied两个容易混淆的索引raftLog里还有两个uint64字段几乎每次面试都会被问也是实际问题排查中最容易搞混的committed和applied。committed表示当前节点已知的安全提交水位。这里的安全意思是集群中大多数节点都已经拥有了这条日志。一条日志只有达到这个水位才有资格被应用层执行。applied则指向已经被应用层实际执行完的那条日志。两者的关系用一句大白话概括committed是可以执行applied是已经执行。在实际运行中applied永远不可能大于committed差值越大说明应用层消费日志的速度越慢状态机堆积的待执行日志越多。如果你在监控里看到committed - applied持续增长往往意味着应用层处理出现了瓶颈而不是raft协议本身的问题。这是排查集群延迟问题的一个非常实用的切入点。raftLog向外提供的nextEnts()方法返回的就是(applied, committed]这个区间内的日志条目供应用层调用。应用层每成功应用一条就会把applied推进到对应位置。整个机制说穿了就是生产者消费者模型committed是生产者写指针applied是消费者读指针。2.4 raftLog还管了什么除了上面这些核心字段raftLog还承担了几类关键操作追加日志条目append、查询日志任期、计算日志与主节点的冲突位置。这些方法各自负责协议流程中的一环。比如Leader在收到Follower的日志复制响应后需要推进该Follower的match索引。推进过程中一旦发现Follower的心跳周期内离线过久Leader就需要回到上一个可能匹配的索引重新发送日志。这个回到哪个索引的判断就需要用到raftLog的Term查询和冲突定位能力。还有一类的应用场景是快照当Follower落后太多、Leader手里的日志已经被截掉时两者之间的日志无法通过增量复制补齐就得直接发快照。这个快照的获取和索引判断同样依赖raftLog。所以你看raftLog表面上是存日志的地方实际上所有涉及日志位置判断的算法细节都藏在它内部。这也是这个结构体的实现显得比预想更复杂的原因——它不是一个被动容器而是一个参与一致性计算的重要成员。3. raft状态机引擎的字段全景图3.1 身份、任期与投票最基础的政权字段再看raft结构体。它字段很多但可以按功能分组来记。第一组是身份和任期相关id是节点自己的IDTerm是当前任期号Vote是本任期投给了谁。这三个字段放在一起看就构成了节点的政治身份——我在哪个任期、我支持了谁、我是谁。这里有个细节值得注意在etcd raft实现里Vote并不是一个简单的缓存它直接参与节点重启后的状态恢复。一个节点重启后raftLog会从storage中读出Term和Vote恢复成原来的选举状态。这是为了防止同一个节点在同一个任期里给不同候选人投票导致出现两个Leader的严重问题。我在阅读选举确定性相关issue时发现很多初学者会忽略Vote在重启后的重要作用总觉得重启后反正会重新选举。但如果一个节点重启后不记住自己的投票记录一旦网络分区场景出现旧任期的选票和现任期的选票混在一起就可能在极端时序下破坏选举唯一性。接着是raftState它表示节点当前处于StateFollower、StateCandidate还是StateLeader。这三个状态的切换是整个raft协议的骨架。lead字段记录的是当前任期的LeaderID对于Follower来说它代表了我现在听谁的指挥。还有一个leadTransferee字段专门记录Leader转移的目标节点ID这是一个不那么起眼但很重要的字段Leader转移过程中如果目标节点迟迟没有追平日志转移就可能超时失败而leadTransferee就是这次转移的记录凭证。3.2 选举计时与心跳驱动状态转换的节拍器raft结构体里有一组看起来特别定时器的字段electionElapsed、heartbeatElapsed、heartbeatTimeout、electionTimeout以及一个randomizedElectionTimeout。在每个tick周期里节点都会递增electionElapsed和heartbeatElapsed。Follower如果发现自己的electionElapsed超过了randomizedElectionTimeout就会发起选举Leader则会在heartbeatElapsed达到heartbeatTimeout时广播心跳刷新electionElapsed从而压制Follower的选举计时。这里有一个非常精细的设计electionTimeout是一个基础值但每次选举前会用随机因子生成一个更大的randomizedElectionTimeout。这个随机化不是为了实现公平而是为了从概率上避免多个节点在同一时刻同时超时、同时发起选举、选票被分散导致一直选不出Leader。你可以把electionElapsed想象成一个倒计时闹钟每个节点的响铃时间略有差异这样集群就不会陷入每次都在同一瞬间抢着发言的混乱局面。我读源码时一度很困惑为什么randomizedElectionTimeout不是保存在storage里的持久化状态后来想明白它只是运行时的随机量根本不需要持久化节点重启后重新随机一个值就好。这个认知帮我避免了一个压根不存在的持久化设计问题。3.3 消息管道与只读请求raft的对外收发窗口raft结构体里有两个和网络交互直接相关的字段msgs []pb.Message和readStates []ReadState。msgs是节点在本地状态机上处理后产生的待发送消息队列。这里的关键认知是raft模块本身不负责实际发送消息它只把该发的消息结构体塞进msgs队列由上层应用把这些消息取走通过RPC发给对应节点。如果你在源码里看到某个流程发送一个AppendEntries它实际的操作几乎就是构造一条pb.Message然后append到msgs里。readStates则处理的是线性一致性读请求。raft实现读请求时通常有两种方式一是Leader把自己的读请求作为一条日志写入并提交开销较大二是通过ReadIndex机制在处理请求的当前时刻记录提交水位等确认自己仍然是Leader以后在本地读状态机。readStates就是为第二种方案服务的存放需要等待确认的读请求状态等确认完成后返回结果。很多人读到这里会误解成raft直接把读给持久化了其实它只是暂时挂起了读请求等一个安全时点再放行。3.4 进度跟踪Leader视图里的每节点最新情况raft结构体里还有一个很庞大的成员从结构体定义上看可能只是一行字段但它是Leader视角下最重要的基础设施prs tracker.ProgressTracker。ProgressTracker内部管理着集群中每一个节点的进度信息核心是两个索引Match和Next。Match表示我确认该节点已经复制到的最大日志索引Next表示我下一次准备发给该节点的日志索引。Leader决定给某个Follower发什么日志、发多少条全靠这两个值。你可以这么理解Leader就像一个老师Next是我这次应该讲到第几页Match是每个学生上次已经确认掌握到第几页。当Follower回复我收到了截止到某个索引Match就前移到那里Next也顺势前移。当学员反馈自己落后太多老师就得翻到前面重新讲也就是把Next回退。初学者特别容易忽略的一点是ProgressTracker不仅存进度还承担了动态成员变更时的节点身份管理——某一类节点是Voter还是Learner完全由它区分。我见过不少人在看配置变更的代码时一头雾水根因就是没意识到prs里还存有Learner信息。4. 选举与日志复制两个结构体如何被流程驱动4.1 选举流程中的数据流转光看结构体还不足以形成完整画面我建议把raftLog和raft放进流程里走一遍。选举是理解状态机的最好入口。起初节点以Follower身份运行心跳超时被触发后raft结构体的step函数被切换到stepCandidate分支。节点立刻把自己的Term加一Vote改成自己然后清空旧Leader的lead记录同时构造一批RequestVote消息放入msgs队列向所有其他节点广播。注意这个消息并不需要持久化到raftLog因为它是易失性的网络消息。当其他节点收到RequestVote以后会检查自己的日志是否满足候选人的日志完整度要求。这里就要用到raftLog的Term和LastIndex了如果候选人的日志还没有本节点新那么投票请求就会被拒绝即使任期更高也一样。这个检查逻辑就是raft协议里日志完整性约束的实现。所以你看选举不光是谁先发起谁赢raftLog里的日志新旧程度才是决定性因素。如果候选人收到超过半数的选票它的step函数会被切换成stepLeader进入Leader状态。这里还有一个容易忽略的细节Leader上任后会立刻追加一条空日志到raftLog里并通过日志复制把这条空日志广播出去。这条空日志是所有已提交日志的分水岭——只有包含当前任期的日志被提交上一任期的日志才算真正被间接提交。如果你在看代码时发现刚成为Leader时多了一条奇怪的空日志别奇怪这是协议设计的必然产物。4.2 日志复制时的协同动作日志复制是raft实现里最频繁的流程。Leader收到上层提交的新指令后先把指令封装成Entry调用raftLog.append写入unstable。然后遍历prs为每个Follower按Next索引从raftLog中取出需要补齐的日志条目组装成AppendEntries消息放进msgs队列。Follower收到AppendEntries后首先用raftLog检查是否和Leader存在冲突。如果发现自己有一段日志和Leader发来的日志任期不一致就要删除从冲突点开始的本地日志并用Leader发来的日志覆盖。这个删除替换操作同样发生在unstable和storage之间。我有一个比较深的印象很多人以为Follower只是不停地追加日志其实它可能还需要回滚本地日志这个回滚动作在raftLog的实现里表现为对unstable的截断以及对storage的覆盖存储。Follower处理完以后会回复一条AppendResponse消息里包含当前Follower的match索引。Leader收到后更新对应该节点的Match和Next然后检查是否有某个日志索引在大多数节点中都已match。如果都达到了raftLog.committed就会推进到该索引。这里是raft协议一致性和可用性的交汇点也是你观察集群推进提交状态时最值得盯住的地方——committed每前进一步都意味着集群在多数派之间达成了一个共识。4.3 只读读请求如何借用这两个结构体还有一个常见场景ReadIndex线性一致性读。它巧妙地借用了raftLog.committed做判断但没有走日志写入的路径算是一个特别有趣的机制。Leader收到读请求后在当前状态记录下raftLog.committed的值作为readIndex同时把这次读请求的信息写入readStates。随后Leader广播心跳并等待一次多数派确认。当确认结束后Leader判定自己仍是合法Leader此时再从底层状态机读取数据返回给客户端。这个方案避免了把只读操作写入日志的巨大开销但仍然满足了线性一致性的要求。整个过程里raftLog只提供当前提交水位是什么这个读数raft负责我是不是还合法这个资格判断。一旦某一个条件不满足读请求就会受到延迟或者被重定向给新的Leader。理解了这两个结构体各自承担的职责你会发现读请求的实现代码其实没有你想象的那么玄妙——它只是把共识过程中的中间状态复用了。5. 啃源码时我踩过的坑以及一份实用自查清单5.1 最容易想当然的地方unstable和storage的边界我整理了几个自己反复吃亏的点希望你少走这些弯路。第一是不要把unstable和storage彻底割裂看待。它们的边界不是固定不变的每当存储完成一次写入unstable就会被裁剪已稳定的区间交给storage接管。如果你在调试中打印日志时总是从raftLog.entries之类的方法里取数据你会观察到返回的数据一会儿来自storage一会儿来自unstable这是正常的不需要怀疑代码写错了。第二是raft.raftLog里applied的初始值问题。在集群刚启动、日志为空的时候applied和committed都是0。但一旦有快照恢复的场景applied会被直接初始化到快照包含的最后一条索引位置。很多人容易忘记这一点导致在测试快照恢复后的日志重放时把快照里已有的日志又应用了一遍。这是个隐蔽的重复执行bug排查起来非常费劲。我的习惯是在应用层判断日志应用逻辑时永远以raftLog.applied为基准而不是自己去推算起点。5.2 调试与阅读的实用方法我还想分享两类阅读技巧。第一类是状态跟踪式的阅读方式。在调试模拟项目X的过程中我在Step()函数入口和消息处理函数出口各加过一个调试打印输出Term、Vote、raftState、committed、applied、msgs长度。这个做法看似笨拙却极其有效——它能把每一个消息处理前后的状态变化完整记录成一条时间线复现bug时照着时间线逐帧检查比盯着代码猜要快得多。第二类是善用单测里的MockStorage。etcd raft模块自带了一整套测试用的存储接口实现你完全可以用它来构造某个节点只收到了索引2和4丢了3这类极端场景验证冲突回滚逻辑。我在自己复现网络分区恢复后日志补齐的流程时靠的就是这类mock存储来构造定向边界否则想生产环境凑齐旧日志和新日志交错的情况纯靠运气。5.3 一个低成本的入门路径最后给刚入门的同学一套低成本路径先读raftLog的append、nextEnts、findConflict这几个方法再读raft结构体的becomeCandidate、becomeLeader、handleAppendEntries这几个流程函数最后再把ProgressTracker里的Match/Next推进逻辑过一遍。这三块看完你对这两个结构体的理解基本就到位了。读的过程中你还会反复遇到Term、Index的捆绑比较——不要嫌啰嗦这在raft协议里是刻意设计的。任期号和日志索引一旦分开判断就很容易在实现时引入漏洞。而你把raftLog和raft字段都吃透以后再回看这类比较会发现每一条都有清晰的数据来源不再是死记硬背的代码片段。以我个人读源码的经验来说数据结构永远是理解算法的第一把钥匙。如果你能把这个习惯带到其他分布式系统的源码阅读里收获的绝不会只是这一篇文章的量。