
有个场景我猜你迟早会撞上一套跑得好好的三副本集群容量或者读写压力顶不住了需要扩到五副本。你打开管理接口把两台新机器加进成员列表点确认。表面上一切正常但接下来几十秒里客户端开始间歇性超时监控上出现两条互相矛盾的写入记录有的节点认为 leader 是 A有的节点认为 leader 是 E。等集群自己缓过来已经是十几分钟之后中间丢掉的那几条写入得靠业务侧对账来补。这个问题的名字叫“成员变更”membership change / reconfiguration。它是 Raft 算法里最容易被跳过、但在生产上最容易翻车的一块。Raft 的选主、日志复制、安全性这几章读起来很顺因为直觉上“多数派”这个概念很好懂但一到“集群成员列表本身也要变”这件事多数派的前提就被撬动了你的集群在同一时刻存在两套“多数派”定义而这两套定义很可能互不相交。成员变更要解决的就是这个问题——在成员列表发生变化的过程中如何保证任意时刻最多只有一个 leader、并且已提交的日志不会被覆盖。这篇东西面向两类人一类是正在自己实现 Raft日志复制已经跑通卡在 conf change 这一章的开发者另一类是不写算法但天天维护一致性集群需要知道扩容缩容到底该怎么操作、为什么文档里反复强调“一次只加一台”的运维。我会把单节点变更、联合共识、learner 预加入、配置条目生效时机、幽灵选举这些坑逐个拆开最后给一份可以直接照着做的三扩五操作流程。Raft 成员变更的核心矛盾、联合共识的双多数派机制、以及工程实现里那些论文不会写的细节都会覆盖到。1. 成员变更到底难在哪为什么“改个配置”能搞崩集群1.1 先明确一件事配置本身也是一份要复制的数据很多人第一次实现成员变更时会本能地把集群成员列表当成“配置文件”——启动时读一次运行中改了就写到本地磁盘再靠某种管理接口广播出去。这个做法在单机时代没问题在分布式环境里是灾难的起点。原因很直接Raft 的全部安全性建立在“日志 多数派”这套机制上。leader 之所以敢把一条日志标记为已提交是因为它确认过“这条日志已经在多数节点上落盘就算我挂了新选出来的 leader 也一定包含它”。这个推理链条依赖一个隐含前提所有节点对“多数派是哪几个节点”有完全一致的认知。一旦成员列表可以绕过日志单独传播这个前提就没了——你的节点可能用旧名单算多数派我的节点用新名单算两边都觉得自己凑够了票数。所以正确的心智模型是成员列表不是配置是状态是必须走日志复制、必须参与提交协议的一份特殊数据。它和普通业务日志的唯一区别是它被应用时会改变“谁有投票权”这个元信息。业内成熟的实现无一例外都把它做成一种独立的日志条目类型通常叫 ConfChange / Configuration Entry和普通 Entry 混在同一条日志流里共享 index 和 term。这个设计决策看着平淡但它带来了一个极其重要的性质配置变更有明确的“生效点”也就是这条日志被提交并 apply 的那一刻。在这之前全世界都还在用旧配置在这之后全世界都切到新配置。中间没有“一半人用新一半人用旧”的合法状态。提醒如果你的实现把成员列表存在日志之外比如单独的 meta 文件、注册中心、数据库表请立刻停下来重新设计。这不是“实现不够优雅”的问题而是安全性缺口。1.2 两套配置的多数派可能互不相交这才是真正的雷把配置塞进日志只是第一步它解决的是“配置变更的传播一致性”没解决“变更过程中两套配置并存”的问题。来看具体数字。假设集群现在是三节点 A、B、C你想扩到五节点加入 D、E。在某个瞬间一部分节点已经知道新配置{A,B,C,D,E}另一部分还停留在{A,B,C}。现在算一下两边的多数派门槛配置成员数多数派门槛一个合法的多数派示例旧配置 C_old32{A, B}新配置 C_new53{C, D, E}看这两个例子{A,B}是旧配置下的合法多数派{C,D,E}是新配置下的合法多数派它们的交集是空集。这意味着什么意味着在同一个任期附近的时间窗口里A 和 B 可以选出一个 leaderC、D、E 可以选出另一个 leader两个 leader 各自都“合法地”认为自己拿到了多数票各自都能提交写入。等网络恢复两条分叉的日志撞在一起只能丢掉一边。这就是成员变更的本质风险直接用新配置替换旧配置会破坏“任意两个多数派必有交集”这个 Raft 安全性的基石。单节点变更和联合共识本质上都是在用不同的方式把这个交集补回来。顺便说清楚为什么“一次加两台”特别危险。上面这个 3 扩 5 的例子恰恰就是一次加两台。如果你老老实实一次只加一台3→44→5交集永远不会是空的——下一节给推导。1.3 安全性和可用性是两个独立的维度别混着谈我在 review 别人写的成员变更代码时发现一个高频误解把“变更过程中集群可能短暂不可用”和“变更可能导致数据丢失”当成一回事然后为了“减少不可用时间”去做一些激进优化结果把安全性搞没了。这两件事必须分开评估安全性safety任何情况下不会出现两个 leader已提交的日志不会被覆盖。这是红线不能妥协不能为了性能让步。可用性availability变更过程中是否会出现短暂的写入失败、选举空窗、请求超时。这是体验问题可以通过流程设计来优化。单节点变更之所以在工程上大受欢迎就是因为它在保证安全性的前提下把可用性损失压到了最小——通常只是配置条目提交时的那一次 RTT外加可能的短暂 leader 抖动。而联合共识的安全性更“厚重”能一次换多个代价是变更期间集群要同时满足两套多数派可用性窗口明显变窄任何一套配置失去多数派整个集群就停摆。记住这条判断标准任何“为了让变更更快”而放松多数派要求的优化都应该被否决。宁可多花十分钟做五次单节点变更也不要花十秒钟做一次批量变更。2. 单节点变更一次只动一台的稳妥解法2.1 为什么“只动一台”就能让不相交问题消失单节点变更single-server change的核心约束简单到不像话每次配置变更只增加或只删除一个节点。Raft 论文里给了一个很短但很漂亮的论证我用更直白的方式重推一遍。设旧配置有 n 个节点新配置是旧配置加上一个节点 x共 n1 个节点。旧配置的多数派门槛Q_old ⌊n/2⌋ 1新配置的门槛Q_new ⌊(n1)/2⌋ 1。现在任取一个旧多数派和一个新多数派它们都是那 n1 个节点的子集。只要满足Q_old Q_new n 1根据鸽笼原理两个子集必然有交集。逐一代入旧规模 nQ_old新规模 n1Q_newQ_old Q_newn1是否必相交112232是223243是324354是435365是536476是647487是n 从 1 到 6 全部成立而且这个不等式对所有 n 都成立可以简单验证Q_old Q_new ≥ n/2 (n1)/2 2 - 1 n 1.5 n 1。删除一个节点是对称的把上面表格反过来看就行。对照一下一次加两台的情况n3 时Q_old2、Q_new3相加等于 5而节点总数也是 5不满足严格大于所以存在不相交的构造——就是 1.2 节那个例子。这个“差一点点”特别有迷惑性因为 3 扩 5 看起来只是多加了一台而已。所以单节点变更的安全性不是靠工程技巧保证的是靠多数派的算术性质保证的。这也是为什么它成为工业界的默认选择没有额外的协议阶段没有双配置的心智负担正确性来自一个不等式。2.2 单次变更必须守住的四个约束不等式只保证了“任意一次单节点变更”是安全的实际实现里还有四个约束必须同时满足漏一个就会出事。这四条我在代码 review 里见过每一条被违反的版本。约束一串行化同一时刻只允许一个未完成的配置变更。这条最容易被忽略。假设你连着发起两个变更先把 D 加入再把 E 加入。如果第一个配置条目还没提交第二个就进来了那么在日志里就会出现两条相邻的配置条目。这时候“任意两个多数派必相交”的论证是在“一次变更”的假设下做的一旦有两处变更同时在途交集论证就断了——本质上又回到了“一次加两台”的处境。工程上的做法是每个节点维护一个“是否有未 apply 的配置条目”的标志通常叫 pending conf 标志有就拒绝新的变更请求返回一个明确的错误让上层重试。约束二必须用新配置算 leader 选举的票且要用“最新已知配置”。节点在投票时用的成员列表必须是它日志里最新的那份配置不管是否已提交取决于你选的生效策略见 4.1 节。如果一个节点已经 apply 了新配置却还按旧配置计票那它就可能和另一个按新配置计票的节点选出两个 leader。约束三leader 被移出配置时必须先让位。假设你要下线节点 A而 A 恰好是当前 leader。配置条目提交后A 从成员列表里消失了它就不该再当 leader。但在它真正退位之前它还得把这次配置变更复制给其他节点——如果它一发现自己被移除就立刻停止工作配置条目可能永远提交不了集群卡死。正确顺序是配置条目在 A 上 apply 之后A 再主动转移 leadership 或者退位。更稳妥的做法是先做一次 leadership transfer把 leader 挪到不会被下线的节点上然后再发起移除这样从根上避免这个尴尬窗口。约束四一次变更的目标列表要基于“最新已生效配置”计算不能基于缓存。这条是给上层管理接口的。我见过一个 bug管理服务缓存了集群成员列表两个运维同时发起扩容各自基于同一份旧缓存算出目标列表一个算出{A,B,C,D}另一个算出{A,B,C,E}。如果服务端不做版本校验比如带上配置的 index 做乐观锁两条冲突的配置条目都可能被写入。正确做法是所有变更请求都带上期望的基准配置 index服务端不匹配就拒绝。实操心得把“当前配置的日志 index”当成一个类似版本号的字段暴露给管理 API。所有成员变更请求都必须携带这个 base index服务端做比对后再写入。这一条能挡掉绝大多数并发误操作。2.3 单节点变更什么时候不够用单节点变更听起来完美但它有两个明确的边界踩到了就得换方案。边界一效率。从 3 台扩到 30 台单节点变更意味着 27 次串行的配置变更每次都要等日志提交、等心跳传播、等上层确认。按每次 500ms 到 2s 算整轮下来十几秒到一分钟而且中间每一次变更都是一个新的故障注入点每次都有一个短暂的 leader 可能抖动的窗口。你在云上做弹性伸缩、按负载自动扩缩容的时候这个成本是没法接受的。这种场景通常是先批量“冷启动”新节点用 learner 方式预加入见 4.4 节再一次性完成配置切换这时候就需要联合共识或者干脆用新集群替换。边界二从 1 节点到 3 节点的起步。这是单节点变更的经典陷阱。1 台到 3 台看起来只是加两台按约束应该拆成 1→2→3 两步。但 2 节点集群的多数派门槛是 2意味着任何一台挂掉整个集群就完全不可写这个中间状态的可用性比 1 节点还差1 节点至少自己活着就能写。好在 1→2→3 只需要几百毫秒到几秒实践中风险可以接受但如果你的环境网络抖动严重建议直接在初始化阶段就启动 3 个节点而不是先起 1 个再扩。还有一个经常被忽略的细节偶数规模集群没有额外收益。4 节点集群和 3 节点一样容忍 1 台故障多数派门槛都是 3但成本多一台6 节点和 5 节点同理。所以在规划扩容路径时3→4→5 这两步虽然单节点变更是安全的但 4 这个中间态在实际运营中没什么价值。如果追求最小风险就老老实实走 3→4→5如果追求最小操作次数可以考虑联合共识一次从 3 到 5但要接受可用性窗口变窄。3. 联合共识一次换多台的重武器3.1 C_old,new把两套多数派焊在一起联合共识joint consensus的思路非常“数学”既然问题出在“两套配置的多数派可能不相交”那就在过渡期要求同时满足两套配置的多数派。只要两边都过半数任何一个决策都必然同时触及两边的多数派交集自然存在。具体做法是引入一个中间配置C_old,new它同时包含旧成员列表和新成员列表。在这个阶段的日志条目、选票、提交判定全部按“双多数派”规则处理一条日志要被提交必须在 C_old 的多数派上落盘并且在 C_new 的多数派上落盘一个节点要当选 leader必须拿到 C_old 的多数票和 C_new 的多数票。用一个结构体表示配置状态type Config struct { Old []NodeID // 旧配置成员非联合阶段为空 New []NodeID // 新配置成员 Joint bool // 是否处于联合共识阶段 } func quorum(n int) int { return n/2 1 } // 联合共识期间任何一个决策都要同时满足两套多数派 func satisfied(votes map[NodeID]bool, c Config) bool { if !c.Joint { return count(votes, c.New) quorum(len(c.New)) } return count(votes, c.Old) quorum(len(c.Old)) count(votes, c.New) quorum(len(c.New)) }注意这里的关键satisfied是个“与”关系不是“或”。写错成“或”联合共识就退化成了“两套配置各玩各的”安全性直接归零。这类 bug 很难在测试中暴露因为正常无故障运行时两套多数派通常都会被满足只在网络分区时才会分叉。3.2 两阶段切换C_old → C_old,new → C_new完整的联合共识切换分两个阶段中间要跨过两个提交点。第一阶段leader 收到变更请求后在日志里追加一条C_old,new配置条目并且从这条条目被追加的那一刻起leader 就开始用双多数派规则做决策注意这里用的是“追加即生效”策略原因见 3.3 和 4.1。这条条目被提交后集群进入联合状态此时所有节点都已经知道自己处于过渡期。第二阶段leader 在日志里追加C_new条目。这条条目一旦被提交联合状态结束集群正式切换到新配置旧配置里被移除的节点开始停止参与投票。为什么中间必须经过C_old,new这个状态因为如果直接从C_old跳到C_new就回到了 1.2 节的不相交问题。而C_old,new的存在使得“任何在这个窗口内做出的决策”都必须同时获得两边的认可从而保证与前后两个阶段的决策都有交集。三阶段的配置序列形成一个链条C_old与C_old,new有交集因为都包含 C_oldC_old,new与C_new有交集因为都包含 C_new所以任意跨阶段的决策都有交集。日志视角大致长这样termindex类型内容状态7100Normalset x1已提交7101ConfChangeC_old{A,B,C}, C_new{A,B,C,D,E}, jointtrue已提交7102Normalset y2已提交双多数派7103ConfChangeC_new{A,B,C,D,E}, jointfalse已提交7104Normalset z3已提交新配置多数派第 102 条是重点它落在联合状态的区间里所以它必须双多数派确认才能提交。如果 101 已经提交但 103 还没提交leader 挂了新 leader 在第 102 条附近选出来新 leader 必须同样满足双多数派条件——因为它在 101 到 103 之间的日志区间内任何节点想当选都得同时讨好两边。3.3 联合共识的代价以及那个“隐式提交”的坑先说代价说清楚了你就知道为什么这东西在工程上用得少。代价一可用性窗口变窄。联合期间集群要同时满足两套多数派。3 扩 5 的场景下旧配置要 2 票新配置要 3 票实际要求是“A、B、C 里至少 2 个 A、B、C、D、E 里至少 3 个”。如果恰好 A、B 同时故障旧配置只剩 C 一票不满足 2 票——即使新配置里 C、D、E 三台都活着能凑够 3 票集群照样停摆。也就是说联合期间集群的容错能力会下降这对本身就已经处于扩容压力下的系统来说很尴尬。代价二实现复杂度。所有需要判断多数派的地方选举计票、日志提交判定、ReadIndex 的 quorum 检查、leader lease 计算、快照的合法性校验都要改成双多数派版本。漏改一处就是一个潜在的分叉源。而且这套逻辑只在过渡期的几秒钟内生效测试覆盖率天然不足。代价三那个“隐式提交”的坑。这是联合共识最微妙的地方。Raft 论文里明确说leader 在追加C_old,new条目后如果它挂了新 leader 可能已经拥有这条条目但尚未提交。此时新 leader 必须基于这条未提交的配置来做决策也就是立即进入联合状态而不是等它提交。这叫做“配置的隐式提交”——配置条目在被追加时就影响决策而不必等到提交。为什么必须这样假设新 leader 坚持“等到提交才用新配置”那它就用旧配置计票可能选出一个只满足旧配置多数派、但被新配置多数派视为非法的 leader。这个漏洞在论文的原始讨论里被专门指出过。而一旦采用“追加即生效”又会带来另一个复杂性节点可能在收到配置条目时它还远远没有被提交就要按它行事如果这条条目最终被覆盖比如被另一个 leader 的日志截断节点还得能正确回退。这个“回退”逻辑写起来很别扭因为大部分实现假设日志一旦写入就不会删除。踩坑记录我见过一个实现配置条目在“追加”时就更新内存中的成员列表但日志截断时忘了回滚内存状态。结果在一次 leader 切换后某节点内存里是新配置、磁盘上回退到旧配置重启后行为和运行时不一致花了两天才定位。4. 工程实现里那些论文不会写的细节4.1 配置条目什么时候生效追加即生效 vs 提交即生效这是实现者要做的第一个关键决策而且两个流派都有人在用各有各的道理。流派一追加即生效。节点一收到配置条目写进日志立刻更新内存中的成员列表和投票逻辑。优点是严格遵循论文的隐式提交结论不会出现“日志里有新配置但还在用旧配置决策”的空档。缺点是日志截断时要能回滚配置状态实现麻烦。流派二提交即生效。配置条目必须被提交在多数派上落盘之后才更新成员列表。优点是实现简单天然不会出现“未提交配置影响决策”的状态日志截断只需丢弃日志条目、不用动内存。缺点是理论上存在一个窗口节点日志里有未提交的新配置但它仍按旧配置投票如果此时集群状态特殊可能和另一个已按新配置投票的节点产生分歧。工程实践上流派二更常见因为它配合“pending conf 限制”一次只允许一个未提交的配置变更之后那个理论窗口变得非常难以触发如果配置条目未提交就不允许再有新的配置变更集群的配置状态只有“正在提交一个变更”这一种情况可控性很高。而且必须注意即使是流派二选举计票用的成员列表也应当以日志中最新的配置条目为准而不是已提交的否则可能选出旧配置下的 leader 却要求新配置的多数派认可造成选举僵局。这个细节不同实现有不同取舍读源码时要特别留意。我的建议是如果你在从零写先用“提交即生效 pending conf 选举用最新日志配置”这个组合它兼顾了正确性和实现成本。等你有余力做形式化验证时再考虑严格对齐论文的追加即生效。4.2 快照里漏掉配置用户数据看起来完全正常但迟早出事Raft 用快照来做日志压缩新节点或落后太多的节点靠 InstallSnapshot 追赶。这里有个非常容易踩的坑快照的元数据里必须包含“生成这个快照时生效的配置”。为什么因为快照代表的是“index ≤ N 的所有日志的压缩结果”如果快照里不包含配置节点通过快照恢复到 index N 之后它的日志里就没有配置条目了它无法知道自己属于哪个配置。这时候它可能会用启动时的默认配置比如空配置或者配置文件里写死的旧名单参与投票而其他节点的配置早就变了。这类 bug 有个讨厌的特征在集群成员一直没变的情况下完全测不出来因为默认配置恰好和实际配置一致。只有当成员变更过、并且成员变更的日志已经被快照压缩掉之后才会暴露。正确的快照元数据至少要有snapshot metadata: last_included_index: 1024 last_included_term: 9 config: # 关键不能省 voters: [A, B, C, D, E] learners: [F] config_index: 980 # 这份配置对应的日志 indexconfig_index这个字段有实际用途节点恢复快照后如果本地的日志里有比config_index更新的配置条目正常情况下不会有因为快照包含了 1024 之前所有日志要能正确处理。加上它可以让一致性检查更严格。注意事项如果你用过一些可嵌入的 Raft 库并且只用了它的日志复制部分、自己管理快照一定要确认快照里带配置。这个坑我在两个不同项目里各遇到过一次一次表现为“新节点加入后集群选不出 leader”另一次表现为“缩容后集群周期性抖动”排查起来都极其费时因为问题现象和根因隔了十几层。4.3 被踢掉的节点为什么还在捣乱幽灵选举与 Pre-Vote这个现象几乎所有做 Raft 的人都遇到过你从五节点集群里移除了一台 D配置变更成功集群运行正常。但过了一段时间集群开始出现莫名的 leader 抖动日志里能看到 term 莫名其妙地跳跃。根因是这样的D 被移除后其他节点不再给它发心跳。D 的选举超时到期它认为自己还是集群成员它的本地配置可能还没更新或者它固执地认为自己能当选于是term并向 A、B、C、E 发 RequestVote。A、B、C、E 收到一个比自己当前 term 更大的请求按 Raft 规则必须先把自己的 term 提升到 D 的 term、转为 follower然后再判断要不要投票。它们当然不会投给 DD 的日志不够新或者 D 不在自己的配置里但它们的 term 已经被抬高了而当前 leader 的 term 比这个新 term 小被迫退位触发一轮新的选举。D 因为收不到那么多心跳超时后又会再来一次。如此循环集群周期性抖动。Raft 论文给出了两个应对手段现代实现基本都是两个一起用。手段一Pre-Vote预投票。节点在真正发起选举之前先发一个“预投票”请求问其他节点“如果我发起选举你们会投我吗”。预投票不增加任期号只有收到多数派肯定回复后才真正开始选举。D 被孤立后它的预投票请求拿不到多数派认可其他节点知道 D 不在配置里或者 D 的日志落后所以它永远不会真正发起选举term 也就不会被抬高。手段二CheckQuorum法定人数检查。leader 定期检查“最近一个选举超时周期内我有没有收到多数派的响应”。如果没有说明自己可能已经被隔离或者失去了多数派支持主动退位转为 follower。这个机制的意义在于即使某个角落里有节点在乱喊 term一个健康的 leader 不会因为收到一个高 term 的 RequestVote 就无脑退位——它会先确认自己还有没有多数派支持。配合 Pre-Vote 使用可以大幅减少无谓的选举。手段三补充被移除的节点自我感知。一些实现会在配置条目 apply 时检查“我在不在新配置里”如果不在主动转成 learner 状态或者干脆停止参与选举。这个做法不能替代上面两个机制因为节点可能在 apply 之前就超时了但能缩短问题窗口。另外leader 在移除某个节点后可以继续给这个节点发一段时间心跳让它安静地收到自己被移除的消息。这三个机制的组合顺序建议先上 CheckQuorum成本最低效果最明显再上 Pre-Vote彻底解决孤立节点捣乱最后补自我感知减少不必要的网络流量。4.4 learner 节点先上车后补票如果你要往集群里加一台全新节点按最朴素的做法它会立刻成为有投票权的成员。这带来两个问题。问题一新节点日志落后会拖慢提交。leader 提交一条日志需要多数派确认。如果新节点日志落后几千条它在短时间内无法确认新日志那么对于一些规模较小的集群比如 3 扩 4多数派门槛从 2 变成 3leader 就必须等这个新节点追上才能提交任何东西。在这段时间里集群的写入延迟会被这一台慢节点直接拖死。问题二虚增了故障风险。一个还在追日志的新节点本质上是个不稳定因素。让它立刻拥有投票权等于把一个还没准备好的成员拉进了多数派计算。learner也叫 non-voting member、观察者就是为解决这个设计的它接收日志、可以被计入复制进度但不参与投票、不计入任何多数派门槛。标准流程是以 learner 身份加入新节点 F配置变更只影响 learner 集合不影响 voter 集合因此不改变多数派门槛零可用性风险。等待 F 的复制进度追上 leader常用判据F 的 match index 与 leader 的 last index 差距小于某个阈值比如 1000 条或者连续 N 秒内差距稳定在阈值内。用一次单节点变更把 F 从 learner 提升为 voter。重复以上步骤加入下一台。这套流程的好处是把“数据追赶”和“改变投票结构”这两件风险不同的事拆开了。第 1 步无论 F 多慢都不会影响集群第 3 步是一个瞬间完成的小操作。线上扩容基本都应该走这条路。缩容的场景反过来用先把要下线的节点降级为 learner单节点变更减少一个 voter等它不再参与投票后再把它从 learner 列表里删掉。这样也避免了“移除 voter 导致多数派门槛变化瞬间的抖动”。5. 一次完整的三节点扩五节点的操作手册5.1 动手之前必须确认的七件事我把这部分做成清单因为扩容出问题绝大多数不是算法问题是前置条件没确认。每一条都是我或者同事真实踩过的。检查项判断标准不满足的后果集群当前健康所有节点存活无持续 leader 切换日志应用延迟正常变更过程中故障叠加难以区分根因当前配置无 pending 变更不存在未提交的 ConfChange 条目新变更被拒绝或者更糟并发出两个变更已有可用备份至少一份近期快照 日志备份可恢复万一真的分叉没有退路磁盘水位各节点磁盘使用低于 70%快照传输 日志追赶上量后写满节点反复重启网络时延新节点到现有节点的 RTT 稳定无丢包日志追赶慢心跳超时误判版本一致新节点二进制版本、配置参数与现有节点一致序列化不兼容、超时参数不一致导致误选举变更窗口避开业务高峰预留至少 30 分钟中途被迫中断留下中间状态第七条我要多说一句。成员变更本身耗时可能只要几秒但新节点追日志的时间是不可控的取决于数据量、磁盘 IO 和网络带宽。一个几十 GB 状态的集群新节点通过快照追赶可能要十几分钟。这个时间要算在窗口里。5.2 逐步执行与观察指标假设集群是 A、B、C 三节点要加入 D、E。全程只做单节点变更一次一个。第一步把 D 以 learner 身份加入。# 伪命令实际接口依你的实现而定 curl -X POST http://A:8080/cluster/member/add \ -d {id:D,addr:10.0.0.4:9000,role:learner,base_index:1024}base_index是 2.2 节说的乐观锁字段值应当是当前配置条目的日志 index。如果服务端返回 base index 不匹配说明配置在你读取之后变化了重新拉取再试。观察指标D 的复制进度match_index是否在稳定增长。可以用 leader 上的调试接口看curl http://A:8080/debug/replication # 输出示例 # A match1024 stateleader # B match1024 statefollower # C match1024 statefollower # D match0312 statelearner - 还在追第二步等 D 追上。判据我给个具体值leader.last_index - D.match_index 1000且这个状态持续 10 秒以上。不要用“看起来差不多了”来判断一定要有量化阈值。追平之后D 的match_index会基本和 leader 同步跳动。第三步把 D 提升为 voter。这是一次真正的配置变更会改变多数派门槛3→4门槛从 2 变成 3。curl -X POST http://A:8080/cluster/member/promote \ -d {id:D,base_index:1103}这一步完成后立刻检查集群是否还在正常提交写入leader 有没有变化如果有短暂的写入失败几十毫秒属于正常如果超过 2 秒持续失败说明有问题准备回退。第四步把 E 以 learner 加入追平提升为 voter。重复 1-3 步。完成后集群是 5 个 voter多数派门槛 3。第五步可选如果同时要下线老节点先转移 leadership。假设要下线 A而 A 是 leadercurl -X POST http://A:8080/cluster/transfer-leader \ -d {target:B}确认 B 成为新 leader、A 变成 follower 后再发起对 A 的移除。移除同样是一次单节点变更curl -X POST http://B:8080/cluster/member/remove \ -d {id:A,base_index:1201}第六步清理。被移除的 A 应该被安全关闭。在此之前建议保持 A 运行一段时间比如 5 分钟让它能收到并应用“自己被移除”的配置。如果 A 上有本地数据需要保留先做备份再关。整个过程中我会持续盯这几个指标leader 的任期号是否稳定不应该频繁跳变、提交延迟的 P99不应该出现持续超过 1 秒的尖峰、各节点的applied_index差值不应该持续扩大、以及每秒的选举次数应该是 0。任何一个指标异常就停下来不要再等等看。5.3 失败了怎么退以及什么时候不能退回退的难度取决于失败发生在哪一步这一点必须提前想清楚。情况一learner 阶段失败第一步、第四步前半。最好办。learner 不影响投票直接移除即可零风险。情况二voter 提升后集群异常。可以做反向的单节点变更把 D 降回 learner 或者移除。只要集群还能选出 leader、还能提交日志反向变更就是安全的因为单节点变更的安全性不依赖方向。情况三集群已经选不出 leader。这时候任何变更接口都调不通因为变更本身需要 leader。这时候只能靠人工介入从各节点导出日志找到分叉点人工决定保留哪一边然后用某种方式强制恢复。这是最坏情况恢复过程可能需要业务侧配合补数据。这也是为什么 5.1 的检查清单里“备份”那条不能省。情况四配置条目已经写入但未提交集群卡住。这是最别扭的状态。配置条目在日志里但因为缺少多数派无法提交而所有新的配置变更又因为 pending conf 被拒绝。这种情况下有些实现提供了“强制回滚未提交配置”的管理接口有些没有只能靠重启节点触发重新选举来解决。设计阶段就该想好这个逃生口。6. 成员变更常见问题速查6.1 问题现象与排查路径对照表现象最可能的根因排查动作处理方式变更后集群周期性 leader 抖动被移除节点发起幽灵选举抬高 term看日志里 term 跳变时哪个节点发起 RequestVote开启 Pre-Vote CheckQuorum或临时关停被移除节点变更请求一直返回“配置变更进行中”前一个配置条目未提交pending conf 卡住查 leader 日志尾部是否有未提交的 ConfChange检查集群是否失去多数派恢复多数派后自动推进新节点加入后写入延迟飙升新节点日志严重落后被计入多数派对比各节点 match_index改用 learner 方式加入追平后再提升扩容后集群反而更容易停写扩到了偶数规模容错没变成本变高确认成员数是否为 4 或 6规划成奇数规模或继续扩到下一个奇数节点重启后投票行为异常快照元数据里缺配置检查快照 metadata 是否含 config 字段修复快照格式重建快照同时段两个变更请求都成功缺少 base index 版本校验检查管理接口是否有乐观锁加上基准配置 index 校验拒绝过期请求缩容后无法提交任何写入移除节点时把多数派削掉了数一下剩余节点的 quorum 是否还能凑齐逐个移除确保每步剩余规模仍为奇数且 ≥3变更后部分节点配置不一致配置存在日志之外或快照丢了配置对比各节点日志中的配置条目统一改为配置走日志修复快照6.2 我自己踩过的两个坑和一个建议第一个坑是“一次加两台”的诱惑。当时是在做一个测试环境的快速扩容想着三台到五台就多两台机器合并成一次操作省事。测试环境没开严格的一致性校验跑了两天没发现问题直到某次做了个网络分区的混沌实验两个 leader 同时写入了不同数据。这个 bug 的隐蔽性在于它在正常运行时几乎不可能触发因为正常运行时两套多数派大概率会重叠同一批节点既是旧配置的多数派又是新配置的多数派只有在特定的分区拓扑下才会分叉。所以千万不要拿“我们跑了一年没出事”来论证某种做法安全。第二个坑是快照里丢配置前面 4.2 节提过。表现是新节点通过快照加入后集群偶尔选不出 leader重启一次又好了。排查了很久才发现是新节点恢复后不知道当前配置用了一份陈旧的默认名单参与投票。修复只加了几行代码但定位花了两天。最后给一个建议把成员变更当作一次有仪式感的操作而不是一次普通的管理调用。写清楚检查清单、执行步骤、观察指标、回退方案固化成一个可以重复执行的流程文档。成员变更的频率通常很低一个月几次甚至更少低频率意味着没有机会形成肌肉记忆也意味着每次操作都可能是“第一次”。这种情况下文档和清单的价值远大于所谓的“经验”。