共识算法给服务提供一个可靠排序和提交的基础,但用户看到的是 API。API 是否线性一致、watch 能否恢复、租约到期意味着什么,仍需要额外机制。判断一个系统时,应从调用的实际契约出发,而不是把“内部使用 Raft”直接翻译成“所有请求都强一致”。
把服务拆成几个职责
写请求经校验进入复制日志,协议确定提交前缀,状态机应用命令并更新可查询数据。持久日志支持故障恢复,索引支持高效查找,MVCC 支持历史版本与快照,watch 把变化传给订阅者。这些结构服务于不同目的,不能把应用数据库文件当成全部共识状态。
同理,业务状态变化和外部效果不是同一回事:共识决定“任务分配给 A”,不会让一个暂停后恢复的旧工作进程自动停止对对象存储的写入。
etcd:比较读取、revision 与 watch
etcd v3 的普通 KV 请求提供严格可串行化的契约;Range 可以显式选择较弱的 serializable 读取,允许从本地状态返回旧值。这里的选项名称容易与数据库的隔离级别术语混淆,应按 etcd API 定义理解。etcd 3.6 API 保证
线性一致读取需要确保自己没有站在失去权限的旧视图上,并等待状态机追上所需位置。下图说明通用的 ReadIndex 类思路,具体前置条件由共识实现负责。
revision 表示 KV 状态变更的版本序列,一个修改事务可以使多个 key 共享同一 revision。它不是 Raft term,也不能简单等同于日志 index。把两者混用会使缓存恢复或故障诊断出现错误。
典型缓存同步先获取某 revision 的快照,再从后续 revision 建立 watch。客户端需要保存处理进度;若所需历史已经被 compact,必须重新读取快照。watch 是可持续接收事件的接口,不是每隔一段时间查询一次的普通 long polling,也不保证业务回调永远不会重试。etcd API:Watch 与版本
Kubernetes 客户端通常通过 API server 的 list/watch 接口观察资源,而不是直接连接 etcd。把应用契约与底层存储区分开,有助于理解鉴权、资源版本和缓存所在的位置。
确认了权限,为什么读取还在等?
不要把一个永久缓存的“我是 leader”布尔值当成本轮读取证据。下面将有效的本轮确认和本地 lastApplied 分开放置:只推进应用,不能弥补权限依据;只收到多数确认,也不能让尚未应用的值出现在查询视图中。图中位置都是同一条日志上的 index,不是 KV revision。
正在呈现知识画面
确认了权限,为什么读取还在等?ReadIndex 类读取既要建立本轮的有效权限依据,也要等待状态机应用到安全位置。日志已提交不代表查询视图已经应用。
{"id":"read-index-barrier","title":"确认了权限,为什么读取还在等?","summary":"ReadIndex 类读取既要建立本轮的有效权限依据,也要等待状态机应用到安全位置。日志已提交不代表查询视图已经应用。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"two\"><label class=\"check\"><input type=\"checkbox\" id=\"b\"><span data-i18n=\"ackB\"></span></label><label class=\"check\"><input type=\"checkbox\" id=\"c\"><span data-i18n=\"ackC\"></span></label></div><p class=\"hint\">A + B / C</p><div class=\"control\"><label for=\"applied\" data-i18n=\"position\"></label><output for=\"applied\" id=\"applied-value\"></output><input id=\"applied\" type=\"range\" min=\"9\" max=\"12\" value=\"10\" step=\"1\"></div><div class=\"metrics\"><div><span data-i18n=\"authority\"></span><strong id=\"authority\"></strong></div><div><span data-i18n=\"view\"></span><strong id=\"view\"></strong></div></div><div class=\"actions\"><button id=\"read\" data-i18n=\"read\"></button></div><div class=\"copy\"><p id=\"explanation\" role=\"status\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"returned\"></p><p class=\"reserve\" aria-hidden=\"true\" data-i18n=\"waiting\"></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","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)));}function draw(){const n=1+Number($('#b').checked)+Number($('#c').checked),p=+$('#applied').value;set('applied-value',p);set('authority',n+' / 2');set('view',p+' / 12');$('#read').disabled=n<2||p<12;set('explanation',t('waiting'));}document.querySelectorAll('input').forEach(e=>e.oninput=draw);$('#read').onclick=()=>set('explanation',t('returned'));draw();","audio":false,"strings":{"ackB":"B 本轮确认","ackC":"C 本轮确认","authority":"权限屏障","heading":"确认了权限,为什么读取还在等?","intro":"安全读取位置固定为 12。分别操作本轮多数确认和本地应用,观察两道门槛。","no":"等待","position":"本地 lastApplied","read":"返回安全读取","reset":"重置","returned":"已返回位置 12 的状态;这不是任意旧快照。","scope":"当前任期已有提交;三节点固定配置,A 自身计一票,B/C 的按钮表示带本轮上下文的有效回复。切换副本确认是独立读取快照,不代表收回历史 ACK。","scopeLabel":"模型范围与约定","view":"应用屏障","waiting":"两道门槛都满足后,才允许返回。","yes":"满足"}}
Consul:成员探测与目录提交是不同过程
Consul 的 server 通过 Raft 维护需要共同决定的状态,Gossip 用于成员发现和故障探测等工作。客户端 agent 向 server 提交请求,并不因此成为一个 Raft 投票成员。
需要避免把“Gossip 报告某节点可达”理解为“该节点上所有服务都就绪”。成员探测与具体服务检查可以相互影响,但观察对象不同;服务检查结果、目录状态和查询缓存也各有传播路径。
Consul 的读取具有不同一致性模式,默认、consistent 与 stale 不能混用为一个承诺。是否跨 quorum 验证、是否允许 follower 返回以及失败期间的行为,按所选接口与模式解释。Consul 共识与一致性模式
因此,一份通过共识维护的目录仍可能被允许陈旧的查询模式读取。共识负责状态决定,查询模式负责这次读允许使用哪个视图。
ZooKeeper:全局更新顺序与本地读取并存
ZooKeeper 的 ZAB 为更新提供有序广播与恢复基础;它不是只保证“同一个客户端的请求顺序,不管不同客户端”。ZooKeeper 同时描述更新顺序及客户端顺序方面的保证,但普通读取可以由连接的服务器本地处理,因此不同客户端在同一时刻可能看到不同进度。
这与 Raft 的区别也不是“Raft 所有日志时刻相同、ZAB 日志允许不同”。正常复制、网络延迟和恢复都会产生日志进度差异;重点是已提交历史和可见语义。
ZooKeeper 的 session 和 ephemeral znode 常用于成员注册与协调。暂时断连不等于 session 已过期,客户端应区分连接状态与会话生命周期。对于传统一次性 watch,收到通知后要重新读取并按需注册;一次通知不能当作所有中间变化的完整事件日志。其他 watch 模式应按具体版本 API 理解。ZooKeeper 程序员指南
一次通知之后,为什么直接读到了第三个值
假设客户端用 getData(path, watch=true) 读到版本对应的值 1。服务端随后把值改为 2,触发一次 NodeDataChanged 通知并消费掉这次 watch;客户端还没来得及重新读取,值又变为 3。此时客户端收到的是“数据发生变化”的通知,重新读取的结果可以直接是 3。不能从收到一次通知推断只发生过一次写入。
下面用顺序调用压缩这段交错。read_watch() 表示一次带 watch 的读取,通知队列刻意只存事件类型。它没有模拟网络顺序、会话重连或完整 ZooKeeper API。
class OneShot:
def __init__(self, value):
self.value = value
self.watched = False
self.notifications = []
def read_watch(self):
self.watched = True
return self.value
def set(self, value):
self.value = value
if self.watched:
self.notifications.append("NodeDataChanged")
self.watched = False
node = OneShot(1)
assert node.read_watch() == 1
node.set(2)
node.set(3)
assert node.notifications == ["NodeDataChanged"]
assert node.notifications.pop(0) == "NodeDataChanged"
assert node.read_watch() == 3 node.set(4)
assert node.notifications == ["NodeDataChanged"]
assert node.read_watch() == 4
这决定了消费方式:把通知用作缓存失效信号,重新读取并重建当前状态。若业务要求逐条审计 1→2→3 的变更,需要保存可重放的变更记录;靠一次性 watch 的回调计数无法补出历史。
重新读取时应使用提供 watch 参数的读取操作。若先普通读取,随后再用另一操作注册,两个操作之间的更新可能落在空窗内。即便使用正确的带 watch 读取,也仍可能跳过两次读取之间的中间值;它解决注册与读取的关联问题,不提供全部历史。持久 watch 的注册生命周期不同,也不能据此把数据变化通知解释为完整值日志。ZooKeeper Watch 语义与注意事项
租约与分布式锁:锁服务成功不等于外部动作安全
考虑工作进程 A 获得锁后暂停,租约到期,B 获得新锁并开始工作。A 恢复时可能仍沿用旧认知。仅让锁服务中的记录过期,无法阻止 A 写入不检查锁状态的外部资源。
常见保护是分配单调 fencing token,由实际资源拒绝比已接受 token 更旧的操作。token 的比较必须作用于相关资源范围,且资源检查与修改要原子关联。若目标 API 不支持这种约束,就要选择其他能隔离旧执行者的方式。
这说明共识与租约提供协调依据,外部系统仍要落实权限边界。不是获得一次锁以后,就能在任意暂停、超时和重试下无条件继续执行。
运维时关心 quorum,也关心恢复时间
在基本多数方案中,3 个投票者需要 2 个,5 个需要 3 个。多一台不一定提高容错:4 个需要 3 个,仍只能容忍 1 个投票者不可用。还要看这些节点是否共享机架、电源或网络故障域。
用故障域分布核算,而不只看机器总数
以下按固定配置的普通多数派计算,假设一个可用区完全失效,其余投票者互通。A、B、C 是可用区,表格中的数量只计投票者;不参与投票的只读副本不能填补 quorum。
| 投票者布局 | 多数门槛 | 丢失 A 后剩余 | 能否承受任意一个区完全失效 |
| A:2,B:1 | 2 | 1 | 否,A 故障失去多数 |
| A:1,B:1,C:1 | 2 | 2 | 是 |
| A:3,B:1,C:1 | 3 | 2 | 否,A 仍是集中故障点 |
| A:2,B:2,C:1 | 3 | 3 | 是,丢失 B 剩 3,丢失 C 剩 4 |
def zone_survival(layout):
if not layout or any(n <= 0 for n in layout.values()):
raise ValueError("每个列出的故障域至少有一个投票者")
total = sum(layout.values())
quorum = total // 2 + 1
return quorum, {
zone: total - count >= quorum for zone, count in layout.items()
}
assert zone_survival({"A": 2, "B": 1}) == (2, {"A": False, "B": True})
assert all(zone_survival({"A": 1, "B": 1, "C": 1})[1].values())
assert not zone_survival({"A": 3, "B": 1, "C": 1})[1]["A"]
assert all(zone_survival({"A": 2, "B": 2, "C": 1})[1].values())
例如 A:2、B:2、C:1 已失去 A 时,只剩恰好三个投票者;此时再停 B 的一个节点维护,就只剩两个。故障容忍预算要扣除当前不可用节点,而不是每次变更都从“最多允许两台故障”重新计算。跨区部署还会把部分跨区往返纳入提交路径,因此容错布局与写入延迟需要一起衡量。
上面的计算只说明剩余票数足够;能否恢复 leader、磁盘是否可用、网络是否最终稳定,仍决定服务是否实际推进。成员变更期间的配置规则也不能用这张固定配置表替代。
quorum 存活也不等于延迟健康。慢盘、长暂停和应用积压可以导致请求超时、选举频繁或读取等待。应观察 leader 变化、持久化延迟、复制进度、已提交与已应用差距,以及客户端失败率,而不是只看进程是否在运行。
备份恢复和成员变更尤其要遵守产品协议。把多个旧数据目录同时重新启动,不能替代一个明确的集群恢复过程。
用途速查
| 需求 | 共识如何参与 | 仍需什么 |
| 配置、目录与元数据 | 共同决定更新顺序 | 读模式、缓存与 watch 恢复 |
| 分片数据库 | 每个复制组决定日志 | 分片路由、事务、快照与流控 |
| 任务分配或锁 | 决定当前持有者 | fencing、重试与结果去重 |
| 成员健康观察 | 可提交某些控制决策 | 探测仍只能提供带不确定性的观察 |
大容量存储也可以使用共识复制,通常通过多个复制组扩展。数据多不等于“应该改用 Gossip”;Gossip 是信息传播方式,不能替代用户要求的提交与读取语义。
参考
Keywords: etcd, Consul, ZooKeeper, ZAB, ReadIndex, revision, watch, lease, fencing