ZooKeeper:等待自由的协调,不是又一套 Chubby 说明书
ZooKeeper 2010: Wait-free Coordination for Internet-scale Systems
2010 年 6 月,USENIX ATC 上,Yahoo Grid 的 Patrick Hunt、Mahadev Konar 与 Yahoo Research 的 Flavio P. Junqueira、Benjamin Reed 交出的不是又一套锁服务说明书,而是已经在 Yahoo 生产里跑着的协调内核。论文英文标题是 “ZooKeeper 2010: Wait-free Coordination for Internet-scale Systems”。接口看起来像简化过的文件系统:UNIX 式路径、整节点读写、子节点列表。他们把目标写得很窄。服务端不实现锁、队列或选主这些具体原语(编号比较那一套见 Garcia-Molina 1982),只暴露一组等待自由的数据对象,让应用在客户端自己拼原语。读可以由客户端当前连上的那台副本本地回答;改状态的写要过原子广播,保证线性化。目标负载是读写比 2:1 到 100:1,他们报告在这种形状下能到每秒数万到数十万次事务。
对象是 2010 年这篇工程报告,不是 Chubby。Chubby 页已经写明 ZooKeeper 是另一份对象:Google 内部的劝告式锁、小文件广告和会话租约,不在 ATC 这篇的范围内。它也不是 etcd 的产品史——CoreOS、CNCF 与 Kubernetes 那条时间线属于另一页。更不是 《兼职议会》 的定理页。Paxos 给出选定值不能被替换的安全声明;ZooKeeper 把写做成 leader 提出的事务,用 Zab 排请求顺序,读则故意不走那条全序。几台机器怎么组成 ensemble、超时填多少毫秒、Java 客户端怎么连,都不在这篇对象里。
破除误解
误解
**误解一:ZooKeeper 就是开源版 Chubby,读这篇等于学会那套锁服务。实情** 论文自己对照过 Chubby。两边都有文件系统外形,都用共识协议复制副本。差别从接口就开始。Chubby 把劝告式锁、open 与 close 写进服务;ZooKeeper 没有锁方法,也没有句柄,每次请求带完整路径。Chubby 由 master 直接管理客户端缓存,更新前要让可能持有缓存的客户端失效,慢客户端会拖住更新,租约只是给这份拖累加上界。ZooKeeper 用一次性 watch:服务承诺在返回过的信息发生变化时通知,但不在更新路径上堵住别人。客户端可以连任意一台服务器,不只连 leader。论文把这一点写成性能来源:读和 watch 都由本地副本服务。把两个名字叠成一个词,会把等待自由的数据对象换成粗粒度锁与 KeepAlive。
误解二:等待自由就是不用等、读也线性一致,协调可以很弱。 等待自由说的是对象本身不阻塞:一个慢的或故障的客户端,不能让别的客户端的操作停住。它不是说系统没有顺序。论文给出两条基本顺序保证。所有改状态的请求可串行化,并尊重先后关系,他们称为写的线性化;同一客户端发出的请求按发送顺序执行,称为 FIFO 客户端顺序。因为允许一个客户端同时有多个未完成操作,他们把这份性质叫做 A-linearizability,并声称满足它的系统也满足 Herlihy 与 Wing 原来的线性化。读不走原子广播,因而可以略旧。需要追上当前写的应用,在读之前发一条 sync:它让所连服务器把当时已挂起的更新先应用完,代价接近一次轻量写,而不是再广播一次完整事务。协调仍然在,只是把阻塞从服务内核里拿掉,放到客户端用 watch 去等事件。
误解三:既然能建锁,ZooKeeper 就是锁服务,也能当通用存储。 论文明确说自己不是锁服务。锁、读写锁、双重屏障、会合点,全部是客户端用 API 拼出来的配方,服务对此一无所知。最简单的锁用“锁文件”:抢到短暂节点的人持锁,别人 watch 删除。它会有羊群效应,许多等待者在释放时一起抢。改进写法是给每次请求一个顺序短暂节点,只 watch 自己前面那一个,释放时只叫醒一个。这些段落是在证明内核够用,不是一份教人上线锁集群的作业。数据模型也不是通用存储。znode 对应协调用的元数据,默认单节点上限 1 MB,整节点读写。层次命名空间方便给不同应用划子树、设访问权,并不等于再做一套文件系统。把它当数据库用,会在第一次把业务表写进去、或第一次让所有客户端堵在同一把锁上时,把这条协调路径打满。
误解四:本地读等于没有一致性,watch 可以当发布—订阅总线。 本地读放弃的是读的先后顺序,不是写的持久与全序。多数派活着且能通信,服务可用;一次成功的变更在多数派最终能恢复的前提下会留下来。客户端换到新服务器时,新服务器必须至少赶上该客户端已经见过的 zxid,否则先不重建会话——客户端只见过已经复制到多数派的变更,所以一定找得到足够新的副本。watch 是一次性触发器,告诉你“变了”,不把新内容推过来。若在 getData("/foo", true) 之后节点被改了两次,客户端只收到一次事件。会话断开一类事件也会送到 watch 回调,好让客户端知道通知可能被推迟。论文用 ready 节点说明:新主先删 ready、再改一批配置、最后建回 ready;看见 ready 存在的进程,凭 FIFO 与通知顺序,必须已经看见那些配置。这是给协调用的缓存失效,不是一条可以随便订阅的消息总线。分布式系统 里最贵的往往是谁必须出现在热路径上;ZooKeeper 把全序关在写路径里,把读留在本地内存。
znode、watch 与会话
ZooKeeper 给客户端的抽象是一棵数据树。树上的节点叫 znode,用 UNIX 路径指称,例如 /A/B/C。每个 znode 都存数据;除短暂节点外都可以有子节点。客户端创建时可标两类:常规节点由客户端显式增删;短暂节点既可显式删除,也会在创建它的会话结束时被系统清掉,无论是主动关闭还是被判定故障。还可以打开顺序标志:新名字后面跟上一个单调递增的计数器,该值不会小于同一父节点下曾经创建过的任何顺序节点。论文用 /app1 下每个进程建一个短暂子节点,说明组成员关系:进程活着节点在,进程没了节点没了。时间戳和版本号附在节点上,条件更新按期望版本提交,版本为 −1 则不做版本检查。
watch 让客户端不必轮询。带 watch 标志的读照常完成,服务器额外承诺:返回过的信息一旦改变就通知。watch 绑定会话,触发一次或会话关闭后注销。它只表示发生了变化。会话事件与数据事件走同一条回调,避免客户端在连接抖动时仍把旧观察当成最新。论文强调,Chubby 那种由服务端直接管缓存、更新前集体失效的做法,会让慢客户端拖住写;租约只是把拖累封顶。watch 把问题拿掉:写不必等所有观察者确认。客户端库管理与服务器的连接,但不在服务器上为每个打开的文件留句柄。没有 open 与 close,服务器少一份要在故障间保持的额外状态。
会话在客户端连上时建立,带超时。超时内收不到该会话的任何消息,ZooKeeper 视客户端故障。会话结束于显式关闭,或于故障判定。会话让客户端在 ensemble 里从一台服务器透明迁到另一台。写请求被转到 leader,leader 先算出应用后的未来状态,再变成一条幂等事务;条件 setData 若版本对得上,事务里直接带新数据、新版本和新时间戳,对不上则生成错误事务。Zab 负责把这些改状态的事务做成原子广播。论文把它写成 leader 提出、多数派决定的协议,并写明它比普通原子广播多一条顺序:同一 leader 发出的变更按发送序交付,而且前任 leader 的变更必须在新 leader 开始广播自己的变更之前交付完毕。这是请求顺序的证据,不是一份教人如何布置 Zab 集群的配方。读不进 Zab。处理读的那台服务器给应答打上自己见过的最后一个事务的 zxid,于是读相对于写有一份偏序。需要“慢读”时,sync 被排到该服务器与 leader 之间那条请求队列的末尾;队列里若还有待提交事务,follower 不必怀疑 leader 还在。
复制库整棵树放在内存里。为了可恢复,更新先写磁盘上的预写日志,刷到介质之后才应用到内存。快照是“模糊”的:做深度优先扫描时不锁整棵树,逐个原子读取 znode 的数据与元数据。模糊快照可能对不上历史上任何一个完整时刻,但事务幂等且按序重放,崩溃后仍能回到崩溃前的状态。服务器处理写时按序进行,不与其他读写并发,并在本地清掉对应的 watch 通知。通知只由客户端当前连着的那台服务器跟踪。这些选择加在一起,让等待自由的对象仍然能排出客户端需要的因果:新主异步发出大约 5000 次配置更新时,论文估单次变更延迟约 2 毫秒,串行要大约 10 秒,流水线后可以不到 1 秒;别人一旦看见 ready,就必须看见这批更新。
论文量到了什么
生产负载先钉住读多写少。Yahoo 的抓取服务(Fetching Service)是爬虫子系统,当时已在生产里跑,抓取数十亿网页。主进程给抓取进程配置,抓取进程回写状态与健康。他们用 ZooKeeper 管配置元数据,并用它选主,好在主故障后恢复、让客户端只把请求打到仍健康的服务器。对一台被该服务使用的 ZooKeeper 服务器,论文画了约三天的每秒操作数。读远高于写。速率超过每秒 1000 次的时段里,读写比在 10:1 与 100:1 之间。读操作按出现频率从低到高是 getData、getChildren、exists。Katta 是 Yahoo 之外的分布式索引器,用 ZooKeeper 跟踪从节点与主节点的组成员、处理主故障切换,并把分片分配当作配置传播。Yahoo Message Broker 把成千上万个主题散到一组服务器上,每个主题用主备复制到两台机器。broker 在 nodes 下为每台活着的服务器建短暂节点,写入负载与状态;shutdown、migration_prohibited 一类节点被全体监视,用来做集中控制;每个主题下的主备子节点既是发现机制,也承担选主与崩溃处理。
饱和实验在 50 台机器的集群上进行:每台双核 2.1 GHz Xeon、4 GB 内存、千兆以太网、两块 SATA。35 台机器模拟 250 个同时在线的客户端,异步 API,每客户端至少 100 个未完成请求,每次读写 1 KB。服务器把系统内并发请求上限配成 2000,防止内存被打爆。读吞吐高于写,因为读不走原子广播;增加服务器会抬高读、压低广播。饱和两端的数字是:13 台服务器时每秒约 46 万次纯读、8 千次纯写;9 台约 29.6 万与 1.2 万;7 台约 25.7 万与 1.4 万;5 台约 16.5 万与 1.8 万;3 台约 8.7 万与 2.1 万。若强迫所有客户端只连 leader——论文用来对照 Chubby 那种全部请求打向 master 的设计——读多时吞吐明显下降,写多时也更差:服务客户端额外占用的 CPU 与网络会拖累 leader 提议。原子广播组件单独打满时会变成 CPU 受限;真实服务还要做客户端通信、ACL 检查和请求到事务的转换,吞吐低于孤立的广播组件。
故障注入用 5 台服务器、写占比固定 30%,他们称为对预期负载偏保守的比例。单个 follower 故障并很快恢复时,吞吐大致只掉那台原来分担的读。leader 选举在他们的观察里不到 200 毫秒;服务器会停服务一小段,但按秒级采样看不到吞吐掉到零。follower 恢复较慢时,吞吐仍能再抬起来。客户端只在连接断开时才换 follower,所以故障后负载可能暂时不均,要等到 leader 再故障、连接被打破,才会重新散开。延迟实验模仿 Chubby 的 create 基准:一个 worker 发 create,完成后再异步删掉,然后下一发;节点带 1 KB 数据,而不是 Chubby 基准里的 5 字节。即便如此,论文称吞吐仍高于已公布的 Chubby 数字三倍以上。单 worker 时平均请求延迟:3 台服务器约 1.2 毫秒,9 台约 1.4 毫秒。屏障实验里客户端彼此等待,enter/leave 大约每秒 1950 到 3100 次,折合 ZooKeeper 操作每秒约 1.07 万到 1.7 万,远低于饱和曲线上超过 4 万的原始吞吐。这些数字是 2010 年文本里的证据,不是一份教人如何选定副本数、如何部署客户端或如何攻击会话超时的手册。
跨域连接
- 可靠性工程:故障单元是“创建短暂节点的会话是否仍被 ensemble 承认”,不是“这台客户端机器是否还能 ping 通”。会话超时被判定后,短暂节点消失,组成员和持锁身份一起失效;watch 只保证通知曾经登记过的变化,连接丢失时必须当作观察可能已经过期。多数派活着服务才可用,成功的写在多数派最终能恢复时才算持久,这是把失效模式写进接口,而不是保证每台机器都一直在线。模糊快照加上幂等按序重放,换的是崩溃后能回到一致状态,不是运行中锁住整棵树去拍一张完美底片。读走本地内存,写走刷盘后的广播,是在读多写少的负载上把故障窗口留给写路径。
- 信息不对称:各副本的读可以略旧,于是客户端各自握着一份可能落后的私有观察。FIFO 与 watch 顺序把“我看见 ready”变成对配置已经完成的公共证据:看见 ready 的人不能继续使用自己手里那份未完成的私有配置。
sync是付费把私有观察对齐到发出时已挂起的写,而不是假定每台副本脑子里的树都一样。换服务器时核对 zxid,拒绝把客户端已经见过的更新当成未知,等于禁止用更旧的副本冒充同一份知识。watch 只说“变了”、不推送新值,是承认通知本身也是不完整信息,下一步仍要再读。 - 立法机构:真实议会用法定人数防止空场立法,用会议纪要防止会后改口。ZooKeeper 的多数派像法定人数,Zab 对写的全序像按届次归档的纪要:新议长开口之前,必须先宣读完前任已经通过的决议。ready 节点像一次表决结束的公示牌,牌未立起则草案不得当法律引用。顺序短暂节点排队取锁,更接近发言名单而不是警察:内核不管谁该说话,只保证名单上的次序可观察。议会比喻能讲清出缺与议程顺序,不能把这份协调内核读成一份可采购的议事程序,也不能读成 Chubby 那种由议长直接发放的锁。
- 认识论:树上此刻的字节是世界里的事实;客户端“知道自己看见的仍有效”,是另一件知识。一次性 watch 把知识状态分成三种:我持有一份带通知承诺的观察、我收到过期信号但尚未再读、我已失去会话因而所有观察作废。新主用删除再创建 ready 来发布配置,靠的是别人不能在没收到通知的情况下读到新状态——通知先于新事实进入该客户端的知识。本地读允许略旧,等于明确承认“真尚未知”可以短时间存在;需要把它收成公共知识时,才额外付一次
sync。工程事故常发生在把本地缓存当成已被全序确认的公共知识。 - 概率论:饱和实验里的每秒数十万次读,是到达过程落在本地内存路径上的吞吐,不是“副本越多越可靠”的组合计数。写路径过多数派广播,增加服务器会压低纯写吞吐:13 台时每秒约 8 千次纯写,3 台时约 2.1 万,经验曲线与“多一台就多一份独立故障保险”不是同一件事。相关故障会让“多数派还活着”的组合立刻失效,论文只用崩溃故障模型,并写他们在生产里尚未见到需要全拜占庭协议才能挡住的故障。leader 选举不到 200 毫秒是一次观察,不是独立故障乘积公式里的期望。屏障实验里客户端彼此等待,吞吐从饱和曲线上掉到每秒一两万,说明原语的时间被同步点上的到达顺序支配,而不是被随机噪声支配。
参考文献
- Hunt, P., Konar, M., Junqueira, F. P. & Reed, B. ZooKeeper: Wait-free Coordination for Internet-scale Systems. USENIX ATC, 2010.(本文对象)
- Burrows, M. The Chubby Lock Service for Loosely-Coupled Distributed Systems. USENIX OSDI, 2006.(论文自己对照的锁服务,非本文对象)
- Reed, B. & Junqueira, F. P. A simple totally ordered broadcast protocol. ACM LADIS, 2008, pp. 1–6.(论文所称 Zab 的出处,讲请求顺序,非集群配方)
- Herlihy, M. Wait-free synchronization. ACM TOPLAS 13(1), 1991.(等待自由对象的理论前身)
- Herlihy, M. & Wing, J. Linearizability: A correctness condition for concurrent objects. ACM TOPLAS 12(3), 1990.(论文 A-linearizability 所参照的定义)
- Lamport, L. The Part-Time Parliament. ACM TOCS 16(2), 1998, pp. 133–169.(写路径所用共识的安全声明,非本文对象)
延伸阅读
- Birman, K. P. Replication and fault-tolerance in the ISIS system. ACM SOSP, 1985。(论文把
sync比作 ISIS 的 flush;虚拟同步是相关工作,非本文对象) - 与 Chubby 对读:锁服务、会话租约与小文件广告是 2006 年那一页;本文是等待自由的 znode、watch 与写全序。
- 与 etcd 对读:产品史与 Raft 工程属另一页,不要把 2010 年的 Yahoo 协调内核读成那条时间线。
- 与 兼职议会与 Paxos、共识算法、视图戳复制 对读:定理页保证选定值不能被替换;Viewstamped 把主副本与视图更换写成另一套管;本文是客户端 API 与读可略旧的工程分工。