ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

分布式协议(一)RAFT协议

分布式协议(一)RAFT协议 Raft 分布式一致性协议详解一、背景Raft 解决了什么问题分布式系统里多个节点需要就状态达成一致这样才能容忍部分节点故障CP 系统。业界先后提出了 Paxos、Zab、Viewstamped Replication 等协议Paxos被公认为一致性协议的鼻祖但论文只讲了单个提案怎么达成没交代复制状态机需要的 Multi-Paxos 细节实现难度高。Zab用在 Zookeeper 里应用广泛但没有抽象成通用库。Viewstamped Replication提出很早却一直没流行起来实现也很少。Raft来自斯坦福 RamCloud 项目最大的卖点是容易理解、容易实现。它简化了协议的状态和交互被 etcd 等大量项目采用。下表是几大协议的对比能力Multi-PaxosRaftZabViewstamped选主有有有有日志复制有有有有日志修复不确定有有有日志压缩不确定有有有成员变更不确定有无有容易理解难容易中等中等协议细节描述无有有有实现数量少多多少二、Raft 的核心思想1. 节点的三种角色Leader领导者接收客户端请求并复制日志任意时刻只有一个 Leader。Follower追随者被动接收各种 RPC 请求。Candidate候选者参与竞选新 Leader。2. 角色如何流转Follower 长时间收不到 Leader 的心跳就会变成 Candidate发起选主。Candidate 拿到多数节点的投票后成为 Leader。Leader 定期向其他节点发送心跳维持自己的统治地位。无论是 Leader 还是 Candidate只要收到任期Term比自己大的消息就降级为 Follower。通俗理解整个系统像一个议会大家靠投票选出主席主席定期报平安一旦主席失联就重新投票。3. 任期TermRaft 把时间切分成一段段的Term用于选主。每个 Term 最多只有一个 Leader但某些 Term 可能因为没凑够多数票最终没有 Leader。三、选主Leader ElectionCandidate 选主时先把本地 Term 加一再向其他节点发 RequestVote 请求。其他节点按以下规则决定是否同意还在心跳保护期内离上次收到 Leader 数据更新时间太近忽略请求。请求的 Term 比本地小忽略。请求的 Term 比本地大本地接受并提升 Term如果自己是 Leader/Candidate降级为 Follower。Term 相同且本地还没投过票或之前投的就是这个 Candidate并且请求方日志不旧于本地就同意。Term 相同但本地已经投给了别人拒绝。请求方最后一条日志的 Term 小于本地说明对方数据更旧拒绝。这些规则本质就是 Paxos 的少数服从多数、后者认同前者。按照这套规则选出来的 Leader 一定是多数节点中日志最新的那个。选主超时与活锁Leader 的心跳间隔要小于 Follower 的超时时间一般小于超时时间的一半避免 Leader 被误判失联。活锁问题两个节点同时竞选谁都凑不够多数于是反复重选。Raft 引入随机超时时间让节点不要在同一时刻发起竞选有效规避了活锁。一个关键细节日志新旧怎么比判断日志新旧用的是最后一条日志的 lastLogTerm 和 lastLogIndex而不是本地 currentTerm 和 lastLogIndex。原因本地 Term 只用来忽略旧投票、提升自己任期不参与数据新旧的判断。否则会出现一个孤立节点不断投票、不断抬高 Term网络恢复后把 Leader 逼得 StepDown反而引发不必要的重选。四、网络分区Raft 最考验的故障场景与改进1. 对称分区Symmetric Partition问题原论文规定Leader 只要收到更高 Term 的 RequestVote 就 StepDown退位下台。可如果发起投票的节点已经被 RemovePeer 移出了集群俗称幽灵节点它恢复上线后照样能把现任 Leader 拉下台破坏当前租约让复制组不可用。改进方案Leader 对 RequestVote 做身份过滤——属于当前 PeerSet 的节点走正常逻辑遇到更高 Term 就 StepDown这类节点最终能通过 AppendEntries 的 term 检查被处理。不属于 PeerSet 的节点幽灵节点Leader 永远忽略它的 RequestVote不让它干扰集群。如果分区是因为节点故障而非被移除稳定的多数派不会收到更高 Term 的应答Leader 不 StepDown故障节点恢复后能安静追上日志、重新加入集群。2. 非对称分区Asymmetric Partition举例S1、S2、S3 分处三个机房S1 和 S2 之间网络不通其他都通。假如 S1 当上 LeaderS2 一直超时发起选主S3 被迫提升 Term、打断当前租约S1 就无法稳定写入。改进方案给每个 Follower 加一个时间戳记录最近一次收到 Leader 数据更新的时间。只有在超过 ElectionTimeout 之后才允许接受投票请求。这样 S1 在位时 S3 收到的心跳永远不会超时S1能维持稳定的多数派集合S2 始终选不上主这样可以防止抖动。但是也需要有告警和监控介入。3. StepDown主动/被动退位原协议规定Leader 收到任何更高 Term 的请求就 StepDown。实际工程中应在做改进以下时刻触发收到 AppendEntries 失败应答且对方 Term 更大一个 ElectionTimeout 内没能成功写入多数靠逻辑时钟检查一个超时周期约 10 个心跳Leader 发现自己的 RemovePeer 日志被 Commit 后自己已不在节点列表里——此时还要顺便 Shutdown。4.详细解释bRaft Leader StepDown 的工程改造原始Raft论文规则只要Leader收到任意RPC消息RequestVote / AppendEntriesResp消息里的term leader.currentTermLeader立刻stepdown降级成Follower。 这个规则在理论上满足Raft安全模型但放到工程实现有副作用幽灵节点已经被RemovePeer踢出集群的旧节点网络恢复后带着更高term发RequestVote直接把正常Leader拉下台集群发生不必要的重新选举业务抖动。所以工程实现不会无脑执行“收到任何更高term就退位”而是缩小触发StepDown的场景只在下面3种情况才执行StepDown下面逐条拆解。前置概念StepDownLeader放弃Leader身份切换为Follower状态停止处理客户端写请求不再发送日志复制。ElectionTimeout选举超时Follower多久没收到Leader心跳就变成Candidate发起选举一般几十~几百ms。逻辑时钟这里不是硬件时间是Leader内部计数每发送一次心跳AppendEntries心跳就1用来判断一段时间内写入多数是否持续失败。RemovePeer日志成员变更日志一条特殊的LogEntry含义是「从集群节点列表移除某个节点」。这条日志同样需要复制到多数节点commit之后新的集群PeerSet才生效。1. 收到 AppendEntries 失败应答且对方Term更大 → StepDown场景Leader发送AppendEntries日志复制/心跳给FollowerFollower返回失败响应并且响应中携带的resp.term leader.currentTerm。原理Follower收到Leader的AppendEntries如果Follower本地term更大会直接拒绝这条AppendEntries并且把自己更大的term放在返回结果里。这个Follower属于当前集群PeerSet集群合法成员合法集群节点拥有更高term代表集群里已经发生了新的选举产生了更新的任期。说明当前Leader已经过期不再是集群合法Leader必须退位。2. 一个 ElectionTimeout 内没能成功写入多数靠逻辑时钟检查一个超时周期约10个心跳场景Leader一直尝试复制日志但是连续一段时间一个ElectionTimeout周期持续无法拿到多数节点的ACK。举例ElectionTimeout300ms心跳间隔30ms一个超时周期刚好约10次心跳。为什么需要这个规则原始Raft论文里Leader不会主动判断自己是否还持有有效的quorum多数派。 理论上Leader只要持续发心跳Follower就不会发起选举。 但有一种故障场景Leader还活着但是网络单向断连Leader能发消息给Follower但是Follower的ACK回不到Leader。Leader以为自己还是Leader持续接收客户端写请求但是没有办法把日志复制到多数节点写请求永远无法commitFollower收不到Leader的ACK回执不单向断连Follower收到Leader心跳Follower不会发起选举但是Leader收不到Follower的ACK。结果集群卡住客户端写请求一直超时Leader不会主动退位集群僵死。工程实现逻辑逻辑时钟Leader内部维护计数器每发送一次心跳/AppendEntries就计数1。每一轮写成功收到多数ACK重置这个计数器如果连续10次心跳一个ElectionTimeout周期都无法达成多数ACKLeader判定自己已经失去多数派无法提交日志主动StepDown。注意不是靠系统时钟系统时间容易漂移而是心跳计数的逻辑时钟避免时间漂移带来误判只是写/复制多数失败读请求可以继续如果实现了Lease读主动退位之后集群重新选举选出能连通多数节点的新Leader解除集群僵死。一句话总结Leader持续一段时间无法把日志复制到多数主动退位避免集群卡死无法写入。3. Leader发现自己的RemovePeer日志被Commit后自己已不在节点列表里 → StepDown并且Shutdown场景集群执行成员变更删除当前Leader自己。 比如集群3节点{A,B,C}A是Leader执行RemovePeer(A)也就是把Leader A从集群剔除。Raft成员变更规则 RemovePeer是一条日志必须复制到多数节点并Commit之后新PeerSet才生效。在这条日志Commit之前A仍然是集群Leader继续工作当这条RemovePeer日志成功Commit新的PeerSet生效A不再属于集群成员。为什么必须StepDown Shutdown一旦日志commit集群的有效节点列表已经不再包含A。A继续当Leader是非法的A不再拥有集群的读写权限不能继续处理客户端请求、复制日志。所以A必须立刻StepDown放弃Leader身份。额外Shutdown直接停止Raft实例而不是留在集群当Follower。原因A已经被移出PeerSet集群后续不会再把日志发给A如果A继续运行它就是幽灵节点网络恢复后可能再次发起RequestVote干扰集群。安全做法退位之后直接关闭Raft实例不再参与集群任何选举和复制。时序重点 ✅ 不是收到RemovePeer请求就立刻退位 ✅ 必须等到RemovePeer这条日志被commit新配置生效之后才执行StepDownShutdown。 因为在commit之前成员变更还没有生效旧配置仍然有效LeaderA仍然是合法Leader要负责把这条RemovePeer日志复制到多数。一句话总结当“删除自己”这条变更日志成功提交集群配置生效自己已经被踢出集群。Leader退位并关闭实例防止变成幽灵节点干扰集群。整体对比原始协议 VS 这套工程改进场景原始Raft论文行为工程改进方案幽灵节点已RemovePeer发RequestVoteterm更大Leader收到更高termStepDown集群抖动忽略RequestVote不触发StepDownPeer内Follower返回AppendEntriesRespterm更大StepDownStepDown保留Leader连续超时周期无法写入多数Leader不会主动退位集群卡死主动StepDownRemovePeer(自己)日志commit自己不在PeerSet无专门处理会继续运行变成幽灵节点StepDown Shutdown核心设计思想区分RPC类型只信任AppendEntries响应里的更高term不再把RequestVote作为触发退位的依据解决幽灵节点问题增加主动健康检测Leader主动监控自己能不能写多数防止单向网络故障导致集群僵死成员变更安全收尾Leader被删除时等配置生效后自动下线避免遗留幽灵节点。五、日志复制Log Replication选主完成后Leader 负责处理客户端请求。流程是客户端请求到达Leader 把指令追加成一条 Log Entry。通过 AppendEntries RPC 并行发给其他节点。其他节点校验无误后复制成功回 ACK。Leader 收到多数节点的 ACK 后把该日志提交Commit给状态机再给客户端返回结果。如果 Follower 宕机或丢包Leader 会不断重试 AppendEntries。日志的组织结构每条 Log Entry 包含指令要交给状态机执行的命令Term该日志是哪个任期被 Leader 写入的用来判断日志间的不一致Index日志在整条日志流中的位置。什么才算已提交Committed一条日志复制到了大多数节点就算 Committed。如果待提交日志前面还有未提交的旧日志只要它们也已经在多数节点上就一次性按顺序全部提交。Leader 每次 AppendEntries包括心跳都带上最新已提交的 Index让其他节点知道哪些日志已提交从而在本地状态机上也应用它们。节点重启后怎么恢复节点先加载最近的Snapshot快照里的数据一定已提交加载是安全的再加入复制组。但日志不一定已提交——因为系统没有持久化 CommittedIndex无法确定日志是否 Committed所以不能直接加载日志。这样虽然延迟了新节点加入的时间但能保证节点一旦成为 Leader能较快加载完全量数据提供服务。Follower 收到 InstallSnapshot 后也是先接收并加载完快照再回复 Leader。六、日志修复Log Recovery日志修复要保证两点已提交的数据不丢失、未提交的数据最终变成已提交或按规则丢弃且不会因修复中断重启而破坏一致性。当前任期修复current Term解决 Follower 重启或新节点加入时日志落后的问题Leader 给 Follower 补齐缺失的日志。如果需要的日志已被日志压缩清掉Leader 就把上一个 Snapshot 其后的日志一起发给 Follower。在 Leader 存活期间只要一条日志复制到多数节点就变为 Committed。上一任期修复prev Term关键是保证 Leader 切换前后数据一致。Raft 靠选主规则保证新选出的 Leader 一定包含旧 Leader 已提交的数据抽屉原理Leader 是多数中日志最新的。但它可能也带有旧 Leader未提交的日志这部分要转成 Committed 就麻烦些。Raft 加了一条约束旧任期的未提交日志即便修复到多数节点也还不能算 Committed必须在新的 Term 下至少有一条新日志被复制或修复到多数节点后这些旧日志才算真正 Committed。简单说未提交的日志多数节点有了只是必要条件还差一个新任期有一条日志达成多数的确认信号才算落地。Leader 如何定位 Follower 的缺失位置Leader 为每个 Follower 维护一个nextIndex表示下一条要发给它的日志位置。Follower 收到 AppendEntries 后做一致性检查如果指定的 lastLogIndex 对不上就返回失败。Leader 收到失败后把nextIndex减一重新发直到成功——这个回溯过程本质是找到 Follower 上最后一个 Committed 位置再补齐之后的日志。由于这种不一致一般只出现在 Leader 切换后的少量日志上回溯窗口通常很小。优化如果 Follower 只是缺数据前缀一致可以让它直接告诉 Leader 从 Index5 开始重传一次到位若前缀不一致才需要多次回溯。七、日志压缩Log Compaction日志无限增长会带来两个问题占用大量磁盘、启动加载变慢。Snapshot快照是压缩日志的常用方法把系统全部状态写入一个快照持久化后快照点之前的日志就可以删除。快照里存什么除了业务状态机 dump 的数据还要存三条元信息last included index做快照时最后 apply 的日志 Indexlast included term对应日志的 Termlast included configuration当时的节点配置。因为快照点之前的日志会被删除重启后要能恢复 term、index、configuration这三条元信息必不可少。做快照的时机太频繁浪费磁盘带宽太不频繁日志占用大、启动慢。常用策略日志达到一定大小再快照适合日志加载快的场景或每隔一段时间快照适合日志加载慢、怕日志过长的场景。快照怎么做才能不阻塞业务快照耗时长若要它不影响正常的日志同步需要Copy-On-Write写时复制底层用 LSM-Tree 这类支持快照的存储或用系统的 COW 能力如 Linux 的fork()、ZFS 快照等。InstallSnapshot向落后的 Follower 推快照正常情况下 Leader 和 Follower 各自本地做快照。但当两者日志差距太大、Leader 已经做过快照而 Follower 还没跟上时Leader 就需要把快照发给 Follower。Follower 收到 InstallSnapshot 后的处理请求 Term 小于本地 Term直接失败。创建快照并接收后续数据。保存快照元信息删除旧的完成/未完成快照。不盲目删除全部日志如果本地已有日志与快照的 last_included_index/term 一致保留后续日志不一致才删除全部。重新加载快照。之所以不直接删光日志是因为 InstallSnapshot 可能重传、或中途换 Leader新 Leader 的 last_included_index 更小可能还带未提交日志。稳妥做法是只删快照点之前的部分保留后续日志。由于快照可能很大而 RPC 有消息大小限制需要分片传输要么拆成多个 RPC 带 offset 和 data要么用一个 RPC 但拆成多个 Chunk 发送。八、成员变更Membership Management节点故障/扩缩容很常见需要支持动态增删节点且不能影响当前复制、不能出现脑裂。直接变更的危险例如 3 节点扩到 5 节点若直接切换可能出现旧集合S1、S2和新集合S3、S4、S5各成多数、互不相交导致两个决议冲突。方案一Joint-Consensus联合共识分两阶段避免新旧集合形成两个不相交的多数先把新节点追平数据CaughtUp。提交一个新老集合合并的配置日志Coldnew。该日志要被新旧两个集合的多数都应答才算 Commit。再提交一个只含新节点的配置日志Cnew。新集合多数应答后切换完成。关键保障新老集合中任何节点都可能成为 Leader任何决议都需要新旧两个集合的多数共同通过如果 Coldnew 已提交到新旧多数即使流程中断新 Leader 也能看到并继续走完如果没提交就退回按老集合操作。方案二Single-Server Change单节点变更比联合共识简单——每次只增删一个节点就不会出现两个不相交的多数集合。规则Leader 收到 AddPeer/RemovePeer 就立即用新 PeerSet 复制该请求不必等它 Committed。Leader 启动时先发一条 NO_OP 请求把上一个成员变更 Commit并让没复制到当前 Leader 的旧变更失效等 NO_OP Commit 后才安全创建新配置并开始复制。Leader 删除自己时在 RemovePeer 被 Commit 之后关闭。同时放宽两条检查因为成员变更和选主是并行过程节点可以接受并非来自自己 Leader的 AppendEntries节点可以为不在自己节点列表的 Candidate 投票。关于用老集合还是新集合做变更日志的节点集两种做法都不破坏新旧集合多数至少相交一个节点的底线基于老集合省掉 NO_OP被删节点能收到 remove 而自动 shutdown但偶数节点故障一半时无法做变更。基于新集合能解决偶数节点故障一半的情况但需要 Leader 启动时写 NO_OP。关于何时修改本地内存节点集合只要保证不会覆盖已 Committed 日志即可。Raft 论文用新集合 本地写日志即改内存etcd 用老集合 老集合多数 Committed 后再改内存。Leader 启动时写 NO_OP/AddPeer 特别重要如果集群是偶数节点且上一个 Leader 在节点变更时宕机、没 Commit 到多数新 Leader 若一启动就改节点集合可能按新集合误判某些日志为 Committed而旧 Leader 之前已按新集合形成了另一个多数造成脑裂和丢数据。新 Leader 必须先 Commit 一条非节点变更日志才能发起节点变更。Configuration Store配置的持久化配置必须与日志一致节点重启后配置要能恢复到宕机前状态。因此配置的存储和日志的存储必须是原子的、可重入的。存储时机Leader 开始异步写入配置变更日志时就要存Follower 写入成功才存。配置变更前未提交的日志原则上只需老集合多数应答即可实际可约束为老集合和新集合都多数应答简化配置管理。启动时的配置恢复快照里的配置一定已提交安全但日志里的配置可能未提交所以启动时要扫描全部日志找出所有配置不只是最后一个因为最后一个可能是未提交、会被覆盖需要回退到上一个。选主完成后用最后一个配置作为节点列表。定期持久化配置可加快启动扫描。九、安全性SafetyRaft 保证任意时刻以下性质为真选主安全Election Safety一个 Term 内最多只选出一个 Leader。Leader 只追加Leader Append-OnlyLeader 从不覆盖或删除自己的日志只追加。日志匹配Log Matching两条日志若 Term、Index 相同则内容相同且之前的日志也全部一致。Leader 完整性Leader Completeness某条日志在一个 Term 被 Commit则它必然存在于后面所有 Term 的 Leader 中。状态机安全State Machine Safety一个节点已 Apply 了某条日志其他节点不会在相同 Index 下 Apply 不同的日志。核心Leader Completeness 的证明思路假设 Term T 的 Leader 提交了一条日志而 Term UUT的 Leader 却没有它LeaderU 不会覆盖自己日志所以它提交的日志里一定没有这条。LeaderT 把这条日志复制到了多数节点LeaderU 拿到多数投票所以至少有一个 Voter 同时包含这条日志并投给了 LeaderU。该 Voter 是在投票前接受 LeaderT 日志的且 Term (T, U) 之间的 Leader 都包含这条日志所以 Voter 一直保留着它。Voter 投给 LeaderU说明 LeaderU 的日志不旧于 Voter。于是矛盾要么两者 lastLog 相同则 LeaderU 也应包含该日志要么 LeaderU 日志更新按日志匹配原则它同样应包含该日志。两种都推翻了LeaderU 没有这条日志的假设。结论Raft 安全性的关键就是选主时对日志新旧lastLogTerm lastLogIndex的判断。除日志压缩外日志只在 Follower 与 Leader 不一致时被删除且 AppendEntries 保证只删不一致部分。因此已提交的日志永不丢失未提交的日志在转变过程中也不会被篡改 Term 或内容。十、功能完善工程补丁原始 Raft 直接投入生产会遇到几个问题业界普遍做如下补充Pre-Vote预投票防止孤立节点因反复选主把 Term 抬得过高网络恢复后逼退 Leader。Follower 想变成 Candidate 前先探测集群里 Leader 是否存活如果 Leader 还在就不竞选、不抬 Term。Transfer Leadership领导权转移为了数据局部性、降低跨机房延迟主动把 Leader 迁到目标节点。做法先阻塞当前 Leader 写入排空目标节点复制队列让其追平日志再发 TimeoutNow 触发它立即选主。需设超时避免无限阻塞写入。SetPeer强制改节点集多数节点故障时比如只剩 1 个正常多数派机制已无法工作。SetPeer 允许强制把节点列表改成存活节点进入最大可用模式继续读写再后续 AddPeer 修复。指定节点做快照快照和 apply 互斥耗时长会阻塞 apply。业务数据若不支持 COW可指定某个 Follower 做快照完成后通知其他节点来拖快照、截断日志。静默模式复制实例多时心跳包数量指数增长。可关闭主动选主复制组只靠业务 Master 在 Leader 宕机时被动触发选主把心跳数从实例数降为节点数。社区也用 Multi-Raft 把复制组间心跳合并成节点间心跳。节点分级级联复制对没有强一致需求的场景如 bigpipe 的 common broker可将节点分 Level。Level0 参与 Raft 复制LevelK1 从 LevelK 异步复制日志Leader 或外部 Master 可做负载均衡控制。十一、性能优化流水线复制Pipelining默认 Leader 与其他节点是串行 batch同步一个 batch 完成后才能发下一个延迟高。改为流水线复制可有效降低延迟。慢 Leader 优化写Raft 模型只要求日志复制到多数节点即可视为 Committed。可将Leader 写本地和向其他节点复制异步化多数节点已应答时不必等 Leader 本地 IO 完成直接把内存中的日志 Apply。即使造成持久化数据比日志新因节点启动总是先加载快照再加载其后日志不影响一致性。读单客户端模型下把最后写入成功的多数节点列表返回给客户端客户端可从中任选节点发起读Backup Request跳过 Leader。读请求带上 CommittedId即使 Follower 尚未收到心跳/下一条 AppendEntries也能把日志转成 Committed 并 Apply再响应读请求。本地 IO 批量写入逐条 fsync 很费。可采用类似网络 batch 的方式做本地磁盘 IO 批量写入提升吞吐。
返回列表