共识在实际系统中的实践

本页目录

共识算法给服务提供一个可靠排序和提交的基础,但用户看到的是 API。API 是否线性一致、watch 能否恢复、租约到期意味着什么,仍需要额外机制。判断一个系统时,应从调用的实际契约出发,而不是把“内部使用 Raft”直接翻译成“所有请求都强一致”。

把服务拆成几个职责

写请求经校验进入复制日志,协议确定提交前缀,状态机应用命令并更新可查询数据。持久日志支持故障恢复,索引支持高效查找,MVCC 支持历史版本与快照,watch 把变化传给订阅者。这些结构服务于不同目的,不能把应用数据库文件当成全部共识状态。

同理,业务状态变化和外部效果不是同一回事:共识决定“任务分配给 A”,不会让一个暂停后恢复的旧工作进程自动停止对对象存储的写入。

etcd:比较读取、revision 与 watch

etcd v3 的普通 KV 请求提供严格可串行化的契约;Range 可以显式选择较弱的 serializable 读取,允许从本地状态返回旧值。这里的选项名称容易与数据库的隔离级别术语混淆,应按 etcd API 定义理解。etcd 3.6 API 保证

线性一致读取需要确保自己没有站在失去权限的旧视图上,并等待状态机追上所需位置。下图说明通用的 ReadIndex 类思路,具体前置条件由共识实现负责。

线性一致读取:建立安全边界,再读取已应用状态 确认读取所需的权威性 例如 quorum 确认;租约方式另有时间假设 1 确定安全读取位置 不能只相信隔离旧 leader 的本地认知 2 等待本地状态机应用到该位置 复制完成与可查询状态存在差距 3 执行读取并返回 按 API 定义处理超时与重试 4 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 类读取既要建立本轮的有效权限依据,也要等待状态机应用到安全位置。日志已提交不代表查询视图已经应用。

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。不能从收到一次通知推断只发生过一次写入。

一次性 Watch:通知使缓存失效,重新读取获得当前值 读 1 + 注册 watch 已生效 写 2 发通知,watch 消费 写 3 尚未重新注册 读 3 + 注册 恢复观察 客户端观察:值 1 → 一次变化通知 → 值 3 传统一次性 watch;示意仅包含数据更新,不包含删除、重建与会话事件。

下面用顺序调用压缩这段交错。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  # 中间的 2 没有作为数据交付给客户端
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:121否,A 故障失去多数
A:1,B:1,C:122是
A:3,B:1,C:132否,A 仍是集中故障点
A:2,B:2,C:133是,丢失 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