📝 术语别名
  • handle: Chubby 里 client open 一个 path 后拿到的, 绑定 session 的远程对象引用; 不是 session 本身, 是 session 内部的一个对象级句柄;
  • znode: ZooKeeper 状态树上的一个节点, 既能当目录又能存一小坨 data, 带 version;
  • ephemeral / sequential: 临时节点 (session 一断自动删) / 顺序节点 (创建时由 ZK 追加一个全局单调的编号), 选主 recipe 的两块积木;
  • Zab: ZooKeeper Atomic Broadcast, ZK 内部的 leader-based 原子广播协议, 跟 Paxos / Raft 同级但不是同一个;
  • A-linearizability: ZooKeeper 自己定义的一致性, 写线性 + 同一 client 的请求按发送序进入全局顺序 (per-client FIFO);
  • wait-free: 一个 client 可以同时挂多个 in-flight 请求, 不用等上一个回包就发下一个 (能 pipeline);
  • control plane / data plane: 控制面 (谁是 master, 配置是啥, 谁还活着) / 数据面 (真流量怎么转发);

定位: 三家放一起, 真正的分水岭在哪

这篇接着 11.chubby12.etcd 往下说; 把 Chubby, ZooKeeper, etcd 摆到一张桌子上, 第一直觉会想"它们内部共识协议不一样啊, Paxos / Zab / Raft", 但这恰恰不是最该盯的地方; 三家内部都是同一套路子: leader-based replicated state machine, 都要全局顺序 + quorum commit 来护住自己那点 metadata;

真正把它们区分开的, 是对外暴露的协调抽象:

系统 内部共识 对外抽象 心智
Chubby Paxos / Multi-Paxos file + handle + lock coordination as ownership
ZooKeeper Zab znode + version + watch + ephemeral/sequential coordination as ordered state
etcd Raft MVCC KV + revision + watch + lease + txn/CAS coordination as revisioned KV state

一句话先甩在这: Chubby 给你一把钥匙, 谁攥着钥匙谁有权开门; ZooKeeper 给你一块强一致的公告板, 板上的状态按顺序变, 大家看公告行事; 这篇就是把"为什么是钥匙 vs 公告板"这件事拆开讲清楚;

Chubby 的 handle 到底是什么

ZooKeeper 论文专门说自己不用 Chubby 那套 handle-based method, 所以得先搞懂 handle 是个啥; 一个常见误解是把 handle 当成 session, 其实不是, handle 是 session 内部的一个对象级引用:

1
2
3
4
session
├── handle1 → /foo
├── handle2 → /bar
└── handle3 → /service/master

分工是这样的:

  • session 管"你还活着吗": lease 有没有过期, failure detection, client 整体是否在线;
  • handle 管"你正在操作哪个对象, 你对它有啥状态": 打开的是哪个 path, open mode, 是否持锁, 缓存状态, stale-handle 检测的 metadata;

所以 handle 最准的一句描述是: 一个 client 打开某个 Chubby path 后拿到的, 绑定 session 的远程对象引用;

handle 跟 Unix file descriptor 像不像

很像, 但 handle 是 fd 的分布式增强版; 调用形态几乎一模一样:

1
2
3
open(path) → fd / handle
read / write(fd / handle)
close(fd / handle)

区别全在"分布式"那几个字上:

维度 Unix fd Chubby handle
所在环境 单机 OS kernel 分布式服务
绑定什么 进程 client session / lease
distributed lease
stale client 保护
master epoch / generation 可能有
直接表达分布式锁状态 fd 本身不表达 (flock/fcntl 是额外机制) handle 可绑定 lock state

所以更准的类比是: Chubby handle ≈ distributed file descriptor + lease/session 绑定 + 一致性 metadata;

ZooKeeper 为什么不要 handle

Chubby 的 API 心智是 object/handle 为中心: 先 open 一个对象拿 handle, 之后所有操作都通过 handle 走;

1
Chubby:  open(path) → handle → lock(handle) → read/write(handle)

ZooKeeper 偏不让你先 open 再操作, 而是每次请求都直接把要操作的 path 报出来:

1
2
ZooKeeper:  create(path) / delete(path) / setData(path)
getData(path) / exists(path, watch=true)

这就导致状态挂的地方完全不同:

状态挂在哪 应用关心什么
Chubby handle / session / object 谁持有某对象的 ownership? 谁拿到了 lock? 这个 handle 还有效吗?
ZooKeeper znode tree / version / watch / 全局顺序 这个 znode 在不在? version 多少? watch 触发没? 这些写的全局顺序是啥?

Chubby 是 object-centric, ZooKeeper 是 path / order-centric;

连 path 的含义都不一样

两边表面都长得像文件系统路径, 但语义重心是反的:

  • Chubby path 指向 cell 里一个 file / directory / lock object, 比如 /bigtable/master; 应用把它解读成"某个外部资源或角色的 ownership 位置", 拿到手第一件事是 openlock; 所以它表示我要打开/加锁/读写的远程对象;
  • ZooKeeper path 指向 znode tree 上的一个状态节点, 比如 /config/serviceA/ready, /election/candidate-00000001; 应用直接对它 create/setData/watch; 所以它表示控制面状态树上的一个状态事实;

收口: Chubby path = object to open/lock, ZooKeeper path = state node to create/watch/version;

两种用法 pattern: 抢钥匙 vs 看公告

两家都能干 leader election / service discovery / config 管理, 但 pattern 是两条路:

  • Chubby: acquire lock → become owner → operate resource; 它回答的是"谁有权做事? 谁是唯一 owner?";
  • ZooKeeper: write state → watch/read state → infer what to do; 它回答的是"系统状态现在是啥? 推进到哪个 phase 了? 某配置 ready 没? 某成员还在不在?";

拿 leader election 当标尺最清楚:

1
2
3
4
5
6
7
Chubby 风格:
open("/leader") → handle → lock(handle)
谁拿到 lock, 谁是 leader # 一把锁定输赢

ZooKeeper 风格:
create /election/candidate-0000000X ephemeral sequential # 各自建带序号的临时节点
编号最小者是 leader, 其余人 watch 排在自己前面那个

Chubby 靠 lock ownership, ZooKeeper 靠 znode state + sequence order; 这跟 11.chubby 里讲的"Chubby 大方直接发锁, ZK 抠门只给积木"是同一件事的两个侧面;

先别急着比, 它们到底是不是一类东西

是, 三家都是 control-plane metadata / coordination system, 干的活就那几样: leader election, distributed lock, membership, service discovery metadata, configuration state, 小块 metadata 存储;

但有个常见误会要先掐掉: Envoy 不是同类; Envoy 是 data-plane proxy, 干的是 HTTP/gRPC routing, load balancing, retry, circuit breaking, mTLS 这些真流量上的活; 两者的关系是上下游, 不是替代:

1
2
3
ZooKeeper / etcd / Consul   →  存控制面状态 (谁是 leader, 谁还活着, 配置版本几号)
Control Plane → 把状态生成路由/服务发现配置
Envoy → 照着配置转发真实流量

内部其实都长一个样: leader-based RSM

把对外抽象的壳剥掉, 三家内部都是 leader-based 的复制状态机; 以 ZooKeeper 的 Zab 为例走一遍:

1
2
3
4
5
6
7
8
client write
→ (follower 收到就转发给 leader)
→ leader 分配 zxid
→ leader 向 followers 发 proposal
→ followers 持久化 + ACK
→ leader 收到 quorum ACK → commit
→ leader 发 COMMIT
→ replicas 按 zxid 顺序 apply

所以 ZooKeeper 内部照样有 leader, replicated log (proposal sequence), quorum commit, state machine apply, 只是对外暴露的不是 raw log, 而是这条 log 顺序投影出来的 znode tree;

这里要顺手拆一个误会: 别以为"ZooKeeper 有 log, Chubby 没 log, master 直接 apply"; Chubby 内部必须也有 replicated Paxos log, 否则 master 本地写完就挂, 新 master 不知道这笔写发生过, 一致性当场崩盘; Chubby 内部状态 (file contents, directory metadata, lock ownership, session state, handle metadata, ACL) 全靠 Paxos log 护着; 真正的区别不是有没有 log, 而是:

  • ZooKeeper: log / order 是对外编程模型的地基, 直接摆给你看;
  • Chubby: log / order 更像内部实现细节, 对外只露 handle / lock / ownership;

比 Chubby 和 ZooKeeper, 别盯着内部共识, 盯对外抽象;

wait-free + per-client FIFO: A-linearizability 的命根

ZooKeeper 标榜 wait-free, 意思是一个 client 可以同时挂多个 in-flight 请求, 不必等上一个回包再发下一个, 这样能 pipeline, 把吞吐拉上去; 比如要刷一组配置:

1
2
3
4
5
6
delete /ready
set /cfg/1
set /cfg/2
...
set /cfg/5000
create /ready # 这些请求一股脑异步发出去, 不逐个等

但你一旦允许 pipeline, 就得保证同一个 client 的请求不乱序, 否则 create /ready 可能比 set /cfg/5000 先生效, 灾难; 所以 ZK 给了一条硬保证:

per-client FIFO ordering: 同一个 session 发出的请求, 虽然异步 pipeline, 进入全局顺序时仍按发送序排列;

写本身线性一致 + 这条 per-client FIFO, 合起来就是 ZooKeeper 的 A-linearizability; (读那一侧能走任意 follower, 只保 sequential consistency 的事, 在 11.chubby 的"动刀点 3"里讲过, 这里不重复)

ready pattern: 它解决什么, 又埋了什么坑

配置更新往往不是一个 key, 而是一坨 metadata 要原子发布:

1
/config/a   /config/b   /config/c   /config/ready

直接挨个 set /config/a|b|c, reader 可能读到半新半旧的配置; ZooKeeper 的经典解法是立一个 commit marker:

1
2
3
4
5
delete /config/ready     # 开工: 先把 ready 撤了
set /config/a
set /config/b
set /config/c
create /config/ready # 收工: 配置齐了才重新挂 ready

应用约定: ready 不在 = 配置不可用/正在更新; ready 在 = 配置完整可用; 靠 per-client FIFO + 全局写顺序, reader 一旦看到 ready, 就能推断它前面那串 config 写都已经按序落地了;

但 ready 不是锁, 多 writer 一上就漏

这是个真实漏洞, 得讲清楚: Zab 只保证单个写进全局顺序, 不会把你这几步自动打包成一个不可插入的大事务; 两个 writer 同时跑这套 recipe, 操作会交错:

1
2
3
4
5
6
7
8
A delete ready
B delete ready
A set a
A set b
A create ready # ← A 这里 create 了 ready
B set a
B set b
B create ready

A create ready 之后, reader 看到 ready 就以为配置齐了, 可 B 还在写; ready 只是个 commit marker, 既不是锁也不是事务; 要堵这个坑, 得在它之上再叠一层:

  • single writer / leader election / writer lock (谁能写);
  • epoch / fencing token / version check (CAS) (写出来的东西怎么互斥);

更稳的做法是 versioned config, 写新版本 + 最后 CAS 切指针:

1
2
3
/config/versions/41/...
/config/versions/42/...
/config/current = 42 # writer 先把 42 整版写完, 再 CAS 把 current 从 41 翻到 42

reader 要么读到旧版要么读到新版, 永远读不到混合版本;

ZooKeeper 只管 substrate 对不对, 不管你业务语义对不对

顺着上面那个坑往上抽象一层: ZooKeeper 保证的是 coordination substrate correctness, 它不懂你的 application semantic correctness;

它会替你保证的:

  • path 操作是否合法, version check 过没, ACL 准不准;
  • write 是否进了全局 zxid 顺序;
  • ephemeral node 是否随 session 失效而删;
  • watch event 是否按规则触发;

压根不知道的:

  • /config/ready 表示"配置完整"这件事;
  • 谁是业务上合法的 leader;
  • reader 该不该等 ready;
  • 当前状态在业务上算不算可用;

ZooKeeper guarantees ordering, not meaning; 业务语义得由你的应用协议, 搭在它的 ordering / version / watch / session 之上自己建;

两种原子性: log ordering vs mutex critical section

退一步看个更普适的取舍: 用 log 顺序做原子, 和用传统 mutex 临界区做原子, 不是一回事:

mutex / lock 原子性 log ordering / ready marker 原子性
关注 谁能进 critical section? 谁是唯一 updater? 状态变化以啥顺序可见? reader 啥时候能认状态已 commit?
适合 leader election, 单 owner job, 外部资源/shard ownership config publish, membership change, barrier, workflow phase 推进
强项 心智简单, 直接表达 ownership, 适合护外部资源 适合表达状态机推进, watch/notification, 让 reader 认 committed state
软肋 只保证"别人进不来", 不保证进去那位中途 crash 后状态完整, 不自动解决 partial update 唯一 writer 不自动保证, ready/epoch/version/CAS 得自己设计, pattern 写错就出语义漏洞

所以最实用的不是二选一, 而是两层叠着用: lock / election 负责"谁能写", log ordering / ready / version 负责"写出来的状态啥时候可见"; 这也正好解释了上一节那个多-writer 坑的修法 —— ready 这层 ordering 原子性, 底下得垫一层 lock/election 的 ownership 原子性;

顺带把另一个直觉修一下: 不能简单说"Chubby 的锁只护外部资源, ZooKeeper 的 log 只护内部 metadata"; 两家内部 metadata 都各自靠自己的共识护着 (Chubby 用 Paxos 护 file/lock/session, ZK 用 Zab 护 znode tree); Chubby 那把 lock 不是给 Chubby master 写内部 metadata 用的, 而是给外部 client/application 当协调原语用的; 更准的说法只是: Chubby 更自然地表达 ownership, ZooKeeper 更自然地表达 shared state transition;

两层 leader 别看串了

既然 Chubby 内部已经有 master, 为啥还要 lock? 因为这是两层不同的 leader:

  • Chubby internal master: 5 个 replica 用 Paxos 选出来的, 负责处理 client 请求, 给内部操作排序, 护 Chubby metadata;
  • application leader: 比如 Bigtable 那几台 server, 通过抢 Chubby 的 /bigtable/master 这把 lock 来选自己的 master;

Chubby lock 是给外部应用用的, 不是 Chubby 内部为了写自己 metadata 而用的; 外部系统的 data plane 还能自带任意架构 (single master / per-shard leader / primary-backup / leaderless quorum), Chubby 只帮它定"谁是 master, 谁拥有 shard-17", 真正的数据更新协议是外部系统自己的事;

📝 三家心智模型总结

把这条线浓缩成一张图:

  1. 内部 —— 三家都是 leader-based replicated state machine: Chubby = Paxos / Multi-Paxos, ZooKeeper = Zab, etcd = Raft; 都要全局顺序 + quorum commit 护 metadata, 这层不是它们的分水岭;
  2. 对外 —— Chubby = file + handle + lock, 心智是 coordination as ownership; ZooKeeper = znode + version + watch + ephemeral/sequential, 心智是 coordination as ordered state transition; etcd = MVCC KV + revision + watch + lease + txn/CAS, 心智是 coordination as revisioned KV state transition;
  3. ZooKeeper 不直接暴露 raw transition log, 而是暴露 log 顺序投影出来的 znode 状态树;
  4. wait-free 允许 pipeline, 靠 per-client FIFO 兜住乱序, 合成 A-linearizability;
  5. ready pattern 是 commit marker 不是锁/事务, 多 writer 必须再叠 lock/election + epoch/version/CAS, 或干脆走 versioned config + CAS 切指针;
  6. ZooKeeper 只保 substrate correctness (ordering), 业务 meaning 得应用自己建; 实战里 lock 管"谁能写", ordering 管"何时可见", 两层叠着用;
  7. 最短一句: Chubby 是 ownership-first, ZooKeeper 是 ordered-state-first, etcd 是 revisioned-KV-state-first; 内部同源, 对外分流;