Raft 用于复制状态机:多个节点对一串命令形成一致的已提交顺序,再按同样顺序执行确定性的状态转换。它容忍的是协议假设范围内的宕机、重启和网络异常,不是任意恶意节点。
学习时先分清三个事实:日志已经存到某个节点、日志已经被协议提交、命令已经应用到状态机。这三个时刻通常不同。Raft 保证已提交历史不会被后来的合法 leader 改写,允许未提交尾部暂时分歧并在恢复时被替换。
任期与角色:新鲜度不是物理时间
term 是单调递增的逻辑任期号。Follower 在选举超时后进入 Candidate,增加 term 并发起投票;取得该配置要求的多数票后成为 Leader。随机超时降低同时竞争的概率,不保证一次选举必成功,也没有适用于所有网络的固定毫秒数。
节点收到更高 term 的协议消息后更新任期并退回 follower。隔离的旧 leader 未必立即收到该消息,可能仍自认为在任;安全性依赖它无法取得所需 quorum 完成新的合法提交,而非所有节点立即知道谁是 leader。外部资源访问还需要相应 fencing,不能只依靠进程里的角色变量。
必须保留哪些状态
| 状态 | 用途 | 恢复要求 |
| currentTerm | 拒绝旧任期请求 | 不能重启后倒退 |
| votedFor | 本任期投票对象 | 防止同任期给不同候选者投票 |
| log:index、term、command | 复制的命令序列 | 已确认的持久日志不能被遗忘 |
| commitIndex | 已知提交位置 | 可由协议恢复或推进 |
| lastApplied | 状态机应用位置 | 与状态机持久化和恢复方式保持一致 |
| nextIndex、matchIndex | leader 跟踪各副本复制进度 | 可重新建立 |
持久状态发生变化时,要在依赖该变化的成功应答之前满足持久化要求;不等于每个无状态变化的心跳都必须单独 fsync。若状态机已经持久保存结果,恢复时不能把对应命令盲目执行第二次。
选举:一人一票还不够
候选者发送末尾日志的 (lastLogTerm,lastLogIndex)。投票者在任期与投票条件满足时,还要确认候选日志至少与自身一样新:先比较最后一条日志的 term,term 相同再比较 index。单看日志长度不够,一份更长的旧任期尾部不一定更有资格。
同任期每个节点最多投给一个候选者,两个多数集相交,因此同任期不可能有两位都获得合法多数票的 leader。跨任期已提交日志的保留,则还需要日志匹配、选举限制以及提交规则一起证明。不能把“日志更新”直接当作“必然包含任意旧条目”。
日志复制:先找到共同前缀
Leader 的 AppendEntries 携带前一条日志的 index 与 term。Follower 只有在该前缀检查通过后才接收后续条目;遇到同 index 不同 term 的冲突时,删除冲突条目及其之后的尾部,再追加新内容。
拒绝后 leader 调整 nextIndex 重新寻找共同前缀,实现可以用冲突任期等信息加速回退。不能每收到一个较短的心跳就截断 follower:没有条目冲突时,消息中没有包含某段尾部并不是删除它的理由。
日志匹配性质是:若两份日志在同一 index 具有相同 term,则此前的前缀一致。这使 leader 能以简短的前缀检查逐步建立一致日志,而不用每次比较整个历史。
空心跳为什么不能顺手删除尾部
以下只模拟 AppendEntries 的日志与提交位置部分,假设请求任期和 leader 身份已经通过协议检查。日志用 (term, command) 表示,位置 0 放一个哨兵;没有实现选举、持久化或快照后的索引换算。
def append_entries(log, committed, prev_index, prev_term, entries, leader_commit):
if not 0 <= prev_index < len(log) or log[prev_index][0] != prev_term:
return False, list(log), committed
updated = list(log)
for index, entry in enumerate(entries, start=prev_index + 1):
if index < len(updated) and updated[index][0] != entry[0]:
if index <= committed:
raise ValueError("合法 Raft 历史不能冲突到已提交前缀")
del updated[index:]
if index == len(updated):
updated.append(entry)
elif updated[index] != entry:
raise ValueError("同 index、同 term 却不同命令,违反输入假设")
matched_end = prev_index + len(entries)
new_commit = max(committed, min(leader_commit, matched_end))
return True, updated, new_commit
log = [(0, "sentinel"), (1, "a"), (2, "b"), (2, "c"), (2, "d")]
ok, same, commit = append_entries(log, 1, 2, 2, [], 4)
assert ok and same == log and commit == 2
ok, fixed, commit = append_entries(log, 2, 2, 2, [(3, "new-c")], 3)
assert ok and fixed == log[:3] + [(3, "new-c")] and commit == 3
assert append_entries(fixed, 3, 2, 2, [(3, "new-c")], 3) == (True, fixed, 3)
assert append_entries(log, 2, 2, 99, [], 4) == (False, log, 2)
第一次调用只证明位置 2 的前缀匹配,既不能因为消息没有携带 3、4 就删除它们,也不能因为 follower 本地恰好有 3、4 就把提交推进到 4。第二次携带了位置 3、term 3,才与本地 term 2 产生真正冲突,于是删掉 3 及之后的旧尾部,再追加新条目。
重复第二条请求不会追加第二份命令。实现需要区分“已经存在的匹配条目”“新条目”和“冲突条目”,不能用收到的整个后缀无条件覆盖本地日志。上述处理对应 Raft Figure 2 的 AppendEntries 规则。
提交:为什么必须看当前任期
在基本多数配置下,leader 可以选取 N 推进 commitIndex,条件包括:N 大于当前提交位置、至少多数节点的 matchIndex≥N,并且 log[N].term == currentTerm。提交 N 同时提交其前面的日志前缀。
旧任期条目即使暂时存在于多数节点,也不能仅凭当前看到的副本数直接宣布提交。原因是投票按末尾 term 优先比较;另一个带有更高任期尾部的候选者,仍可能在某些执行中赢得后续选举并覆盖未提交条目。当前任期条目多数复制提供了安全推进提交前缀所需的附加条件。
一个实际可读的例子:新 leader 当前 term 为 7,继承 index 40、term 6 的条目。它将 term 7 的 no-op 写入 index 41 并安全提交后,index 40 随前缀一起提交。不需要把旧命令换成新 term 重新执行。严格论证与反例见 Raft 论文第 5.4 节和 Figure 8。
多数派位置到了 40,也可能仍只能提交到 39
五节点 leader 当前任期为 7,日志 40 属于任期 6,41 是任期 7 的 no-op。已提交到 39。如果复制进度是 [41,40,40,39,38],虽然 40 有三个副本,当前任期条件仍不满足;等 41 也到达多数派后,才能连同 40 一起推进。
def advance_commit(committed, current_term, terms, matches):
majority = len(matches) // 2 + 1
eligible = [index for index, term in terms.items()
if index > committed and term == current_term
and sum(position >= index for position in matches) >= majority]
return max([committed] + eligible)
terms = {39: 6, 40: 6, 41: 7}
assert advance_commit(39, 7, terms, [41, 40, 40, 39, 38]) == 39
assert advance_commit(39, 7, terms, [41, 41, 41, 39, 38]) == 41
assert advance_commit(41, 7, terms, [41, 41, 41, 39, 38]) == 41
assert (7, 10) > (6, 100)
这里 matches 包含 leader 自身且来自同一固定五节点配置,没有遗漏节点或把重复 ACK 当成多个副本。若正在成员变更,不能继续套用一个固定多数人数公式,而要使用当前配置的 quorum 规则。这个筛选函数展示推进条件,不承担完整安全性证明。
复制到了多数,提交线为什么还没动?
把复制证据画在提交条件旁边,可以避免把“副本够多”误当作完整证明。下面各滑杆选择一份独立的复制进度快照,修改时重新从已提交的 39 计算;它不表示真实节点丢失了已经确认的持久日志。应用按钮再单独展示:可提交前缀扩大后,状态机仍需执行这些命令。
#{i:matchIndexi≥n}≥3∧term(n)=7
正在呈现知识画面
复制到了多数,提交线为什么还没动?推进提交前缀需要多数复制的当前任期条目;提交它也提交之前的旧任期前缀。应用是后续独立动作。
{"id":"raft-commit-frontier","title":"复制到了多数,提交线为什么还没动?","summary":"推进提交前缀需要多数复制的当前任期条目;提交它也提交之前的旧任期前缀。应用是后续独立动作。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"track\"><div class=\"cell\">39<br>term 6</div><div class=\"cell\">40<br>term 6</div><div class=\"cell gold\">41<br>term 7</div></div><p class=\"hint\">A = 41</p><label class=\"replica\">B<input aria-label=\"B\" id=\"p0\" type=\"range\" min=\"38\" max=\"41\" value=\"40\"><output id=\"p0v\"></output></label><label class=\"replica\">C<input aria-label=\"C\" id=\"p1\" type=\"range\" min=\"38\" max=\"41\" value=\"40\"><output id=\"p1v\"></output></label><label class=\"replica\">D<input aria-label=\"D\" id=\"p2\" type=\"range\" min=\"38\" max=\"41\" value=\"39\"><output id=\"p2v\"></output></label><label class=\"replica\">E<input aria-label=\"E\" id=\"p3\" type=\"range\" min=\"38\" max=\"41\" value=\"38\"><output id=\"p3v\"></output></label><div id=\"copies\" class=\"track\"></div><div class=\"metrics\"><div><span data-i18n=\"commit\"></span><strong id=\"commit\"></strong></div><div><span data-i18n=\"applied\"></span><strong id=\"applied\"></strong></div></div><div class=\"actions\"><button id=\"apply\" data-i18n=\"apply\"></button></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"blocked\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"advanced\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.replica{display:grid;grid-template-columns:18px minmax(0,1fr) 28px;gap:10px;align-items:center;margin:18px 0;font-size:13px}.replica input{width:100%}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let applied=39,commit=39;function draw(reset=false){if(reset)applied=39;const p=[41,...[0,1,2,3].map(i=>+$('#p'+i).value)];for(let i=0;i<4;i++)set('p'+i+'v',p[i+1]);const c40=p.filter(x=>x>=40).length,c41=p.filter(x=>x>=41).length;commit=c41>=3?41:39;set('commit',commit);set('applied',applied);cells('copies',[{text:'40: '+c40+' / 5',cls:c40>=3?'on':''},{text:'41: '+c41+' / 5',cls:c41>=3?'gold':''}]);$('#apply').disabled=applied===commit;set('explanation',t(commit===41?'advanced':'blocked'));}document.querySelectorAll('input').forEach(e=>e.oninput=()=>draw(true));$('#apply').onclick=()=>{applied=commit;draw();};draw();","audio":false,"strings":{"advanced":"41 属于任期 7 且达到多数;40 随同整个前缀一起提交。","applied":"已应用到","apply":"应用提交前缀","blocked":"40 虽可有多数副本,但它属于任期 6;41 尚未获得多数,提交仍停在 39。","commit":"可提交到","heading":"复制到了多数,提交线为什么还没动?","intro":"任期固定为 7。调整各副本持久日志的末尾位置,观察 40 与 41 的证据不同。","position":"持久末尾","reset":"重置","scope":"固定五节点配置,leader A 已持久化到 41,39 已提交且已应用。滑杆表示独立复制快照,修改会重新计算提交条件;不模拟完整选举与持久化。","scopeLabel":"模型范围与约定"}}
应用与客户端重试
节点按已提交顺序推进 lastApplied。状态机执行必须确定:随机数、当前时间和外部查询结果若影响状态,应通过明确协议纳入命令或一致输入,不能让每个副本自由取不同值。
即使 Raft 日志安全,客户端仍可能在命令已经提交后丢失响应,再次提交同一业务操作。复制状态机通常需要请求 ID 与结果去重。Raft 本身不把两个重复业务请求自动视为同一条命令。
线性一致读取:自认为是 leader 不够
隔离的旧 leader 可能拥有过时状态。为了安全读取,需要确认本轮读取所依赖的 leader 权限仍有效,并等待状态机应用到相应的安全提交位置。
基于 quorum 的读取方式通常在 leader 已确认当前任期的提交边界后,联系多数节点建立读取屏障,再等待本地 lastApplied 达到安全位置。租约读取可以减少部分通信,但其正确性需要协议规定的时间与租约假设。详见实际系统。
快照:保留状态和它对应的历史位置
快照记录某个已应用前缀的状态,以及 lastIncludedIndex、lastIncludedTerm 和恢复所需的配置等元数据。快照可靠保存后,才能按协议回收对应旧日志。
落后 follower 若所需日志已被回收,可通过 InstallSnapshot 恢复前缀,再追赶后续日志。安装不能让节点回退到比已应用状态更旧的视图;是否保留后续日志也要检查边界是否匹配。快照不是任意复制一份正在变化的数据目录。
成员变更:quorum 的定义也在变化
直接把旧集合切成新集合,可能让两个互不相交的多数各自行动。Joint consensus 用联合配置要求旧集合多数与新集合多数同时满足,再过渡到新配置。它不是把两组节点合在一起后,只取总人数的一个多数。
另一类实现将变化限制为安全的单成员步骤,并规定配置在何时生效。具体实现可能支持不同接口,不能仅看到“单节点变更”就跳过其应用顺序和恢复规则。新增副本也常先以不投票的 learner 追赶,避免一个尚无数据的新投票者立即提高 quorum 门槛。
联合配置不是五个人中任选三个
旧配置 {A,B,C},新配置 {C,D,E}。旧多数 {A,B} 与新多数 {D,E} 可以完全不相交,说明“两个配置各自都用多数”还不够。联合阶段要求一次决定同时获得旧配置至少两个、新配置至少两个节点的确认。
from itertools import combinations
old, new = set("ABC"), set("CDE")
def majority(acks, members):
return len(acks & members) >= len(members) // 2 + 1
def joint(acks):
return majority(acks, old) and majority(acks, new)
assert majority(set("ABC"), old | new) assert not joint(set("ABC")) assert joint(set("ACD")) assert not (set("AB") & set("DE"))
all_sets = [set(nodes) for size in range(6) for nodes in combinations("ABCDE", size)]
valid = [acks for acks in all_sets if joint(acks)]
assert all(a & b for a in valid for b in valid)
共享节点 C 的确认可以同时计入两边,但不能把旧侧多出来的 A、B 确认当成新侧的 D、E。这个规则会影响可用性:旧组能组成多数、新组不能时,联合阶段仍不能完成需要联合 quorum 的决定。配置条目何时生效及如何恢复还需遵循完整重配置协议,不能只替换一个多数判断函数。Raft 成员变更
五张票中有三张,仍可能不够
联合条件是两个谓词的“并且”,不是把节点并集当成一个新多数问题。选择 A、B、C 时总共已有三票,但新配置只有 C 一票;再加入 D,才同时满足两侧。反过来,C、D、E 也缺旧配置的一票。原静态图给出配置关系,下面用可选确认集合检验这些边界。
∣A∩Cold∣≥2∧∣A∩Cnew∣≥2
正在呈现知识画面
五张票中有三张,仍可能不够联合阶段同时要求旧配置多数和新配置多数;共享节点 C 可计入两边,某一边的额外确认不能补另一边的缺口。
{"id":"raft-joint-quorum","title":"五张票中有三张,仍可能不够","summary":"联合阶段同时要求旧配置多数和新配置多数;共享节点 C 可计入两边,某一边的额外确认不能补另一边的缺口。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div id=\"nodes\" class=\"choices\"></div><div class=\"track-row\"><span data-i18n=\"old\"></span><div id=\"oldrow\" class=\"track\"></div></div><div class=\"track-row\"><span data-i18n=\"new\"></span><div id=\"newrow\" class=\"track\"></div></div><div id=\"count\" class=\"formula\"></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"yes\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"no\"></p></div><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n\n.choices{grid-template-columns:repeat(5,minmax(0,1fr))}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}let ack=new Set(['A','B','C']);for(const n of 'ABCDE'){const b=document.createElement('button');b.textContent=n;b.dataset.node=n;b.onclick=()=>{ack.has(n)?ack.delete(n):ack.add(n);draw();};$('#nodes').append(b);}function draw(){const old=[...'ABC'].filter(n=>ack.has(n)).length,ne=[...'CDE'].filter(n=>ack.has(n)).length;for(const [id,ns]of [['oldrow','ABC'],['newrow','CDE']])cells(id,[...ns].map(n=>({text:n,cls:ack.has(n)?'on':'empty'})));document.querySelectorAll('[data-node]').forEach(b=>b.setAttribute('aria-pressed',String(ack.has(b.dataset.node))));set('count',old+' ≥ 2 ∧ '+ne+' ≥ 2');set('explanation',t(old>=2&&ne>=2?'yes':'no'));$('#count').dataset.valid=String(old>=2&&ne>=2);}draw();","audio":false,"strings":{"heading":"五张票中有三张,仍可能不够","intro":"旧配置 ABC,新配置 CDE。点击确认节点,分别看它在两份配置中贡献了什么。","new":"新配置 CDE","no":"至少一边不足 2 票;总票数过半不能替代这个条件。","old":"旧配置 ABC","reset":"重置","scope":"仅展示联合配置的集合判定。确认属于同一决定;配置条目的复制、生效和恢复规则仍由完整协议规定。","scopeLabel":"模型范围与约定","yes":"两边都至少 2 票:满足联合 quorum。"}}
速查:几条不能省掉的限制
- 多数持久化的直接提交判断,要满足当前任期条件。
- 选举比较日志的末尾 term,再比较 index。
- Leader 身份不能独自证明本地读足够新。
- 快照、去重状态与成员配置属于完整恢复的一部分。
- 安全性可在通信异常时保持,持续进展仍需要可通信的 quorum 和合适的时间条件。
参考
Keywords: Raft, term, election, log matching, commitIndex, ReadIndex, snapshot, joint consensus