Skip to content

Chubby ​

标签
分布式/集群与运维
字数
10194 字
阅读时间
39 分钟

Chubby(OSDI 2006)是 Google 的锁服务。它在这条线上有个很难替代的位置:GFS 与 Bigtable 都建在它上面 —— GFS 用一把 Chubby 锁任命 master,Bigtable 用它的 master 来发现受自己控制的服务器、以及让客户端找到 master;两者还都把它当"存一小块元数据的、众所周知且可用的位置"。它们实际上把 Chubby 当成自己分布式数据结构的根。

它服务的环境和规模先说清楚:面向松耦合的分布式系统,由中等数量的中小机器经高速网络互联 —— 一个 Chubby 实例(也叫 Chubby cell)可能服务一万台 4 核机器,跑在 1 Gbit/s 以太网上。多数 cell 限于单个数据中心,但也至少跑着一个副本相隔数千公里的 cell。设计目标里明确写了优先级:可靠性、对中等规模客户端的可用性、以及易理解的语义是首要的,吞吐与存储容量是次要的。

对自己的定位写得很不客气:

构建 Chubby 是一项工程工作,用来填补上面提到的需求;它不是研究。我们不声称提出了任何新算法或新技术。 目的是描述做了什么以及为什么,不是推销它。

为什么是锁服务,而不是 Paxos 库 ​

第一条要回答的质疑是:既然异步共识由 Paxos 解决(附带一句很绝对的话 —— "我们迄今遇到的所有可用的异步共识协议,内核都是 Paxos"),那为什么不做成一个 Paxos 客户端库,而要引入一个中心化的锁服务?四条理由:

① 开发者通常不按高可用来规划。 系统往往以原型起步(负载小、可用性保证宽松),代码不会专门为共识协议而结构化;等服务成熟、有了客户端,可用性变重要了,才往既有的设计里追加复制与选主。用锁服务器更容易保住既有的程序结构与通信模式。

一个把改动量说得多小的实例:要选出一个 master、让它写既有的文件服务器,只需给既有系统加两条语句和一个 RPC 参数 —— ① 取锁成为 master;② 在写 RPC 里多传一个整数(取锁次数 acquisition count);③ 在文件服务器里加一个 if 判断,拒掉 acquisition count 小于当前值的写(防延迟包)。评语是:这比让既有服务器参与共识协议更容易,在需要维持过渡期兼容性时尤其如此。

② 服务需要一个"公告结果"的机制。 许多选主或分区的服务需要一个途径广告自己的结果,这自然要求客户端能存取小量数据(读写小文件)。这本可以用名字服务做,但锁服务自己也合适,因为减少了客户端依赖的服务器数量,而且协议的一致性特性是共享的。

缓存上的一个判断很关键:Chubby 作为名字服务的成功,很大程度上归功于它用了一致性客户端缓存,而不是基于时间的缓存 —— 开发者非常感激不必自己去选一个缓存超时(像 DNS 的 TTL 那样),而 TTL 选得不好会导致 DNS 负载过高或客户端故障切换时间过长。

③ 锁接口对程序员更熟悉。 Paxos 的复制状态机与互斥锁的临界区都能提供顺序编程的假象,但很多程序员以前用过锁、自以为会用 —— 一句不留情面的观察:讽刺的是这些人通常是错的,在分布式系统里用锁时更是如此;很少有人考虑独立机器故障对锁的影响(在异步通信的系统里)。尽管如此,"锁"表面的熟悉度,克服了"说服程序员采用一个可靠的分布式决策机制"这道障碍。

④ 锁服务把"推进所需的最少活节点数"压到 1。 共识算法用法定人数做决定,所以要靠多副本来换高可用 —— Chubby 自己每个 cell 通常 5 个副本,其中 3 个必须在运行 cell 才可用。反过来,用锁服务的客户端系统,哪怕只有一个客户端进程活跃,也能安全取到锁并推进。一个概括的说法是:可以把锁服务看成提供"一个通用选民团",让客户端系统在自身成员不足多数时仍能做出正确决策。

另一条替代路线也被讨论过 —— 提供 consensus service(用一批服务器充当 Paxos 的 acceptor)。它确实也能让客户端在只有一个活跃进程时安全推进,但除非这个服务被专门用来提供锁(那它就退化成锁服务了),否则上面那些问题它一个都解决不了。

由此落下两个关键设计决策:选了锁服务而非共识的库或服务;选了提供小文件,让被选出的 primary 能公告自己与参数,而不是再建一个服务。后面还有一串从使用与环境推出来的决策:公告 primary 的文件可能有数千个客户端观看,所以必须让数千客户端能观察同一文件、且最好不消耗很多服务器;客户端与副本都想知道 primary 何时变化,所以需要事件通知而不是轮询;即便不需要,很多客户端还是会轮询,所以要缓存文件;开发者会被非直觉的缓存语义搞糊涂,所以偏好一致性缓存;以及为了"避免财务损失与牢狱之灾",要提供包括访问控制在内的安全机制。

粗粒度与细粒度:一次完整的取舍对照 ​

粗粒度与细粒度的取舍可以从需求直接反推:

粗粒度锁细粒度锁
持有时长秒以上,典型是小时或天(例如选出一个 primary,由它在很长时间里处理某个数据的全部访问)秒或更短
对锁服务的负载小得多 —— 取锁速率与应用事务率之间只有弱相关大 —— 锁服务的事务率随客户端事务率一起增长
锁服务短时不可用对客户端的延迟影响小(本来取锁就少)可能导致大量客户端停摆
锁迁移的代价客户端间转移锁可能触发昂贵的恢复过程 → 不希望锁服务的 fail-over 导致锁丢失不在锁服务故障时维持锁反而有利(省掉开销),且因持有期短,丢锁的时间惩罚不严重
推出希望锁能在锁服务故障后存活,而这个开销无所谓;数量不多、可用性略低的锁服务器就能服务足够多的客户端需要性能与"随意加服务器"的能力

结论是 Chubby 只提供粗粒度锁。而客户端要自己的细粒度锁也很直接:把锁分组,用 Chubby 的粗粒度锁把"锁组"分配给应用自己的锁服务器;维持这些细粒度锁需要的状态很少 —— 服务器只需保存一个很少更新的、非易失的、单调递增的取锁计数器。客户端可以在 unlock 时得知锁已丢失;若用定长的租约,协议可以简单且开销低。

这个方案最大的好处很明确:客户端的开发者成为"为自己的负载负责供给服务器"的人,同时又免于自己实现共识的复杂度。

系统结构 ​

一个 Chubby cell 由一小批服务器构成(通常是 5 个)称为 replica,摆放上刻意降低相关故障的概率(例如放在不同机架)。replica 之间用共识协议选举 master:

  • master 必须赢得多数 replica 的投票,同时拿到它们"在接下来几秒内不再选别的 master"的承诺 —— 这个间隔叫 master lease;
  • 只要 master 持续赢得多数票,replica 就周期性续这个 lease;
  • replica 各自持有一份简单数据库的副本,但只有 master 会发起对它的读写,其余 replica 只是从 master 复制更新(更新经共识协议送达)。

客户端怎么找到 master:向 DNS 里列出的 replica 发 master 位置请求,非 master 的 replica 回以 master 的身份。找到之后,客户端把所有请求都发给它,直到它不再响应、或它表示自己已不是 master。

写经共识协议传播到所有 replica,在写到达多数 replica 时被确认。读只由 master 单独满足 —— 安全性论证只有一句:只要 master lease 没有过期,就不可能有另一个 master 存在。

master 失败时,其他 replica 在自己的 master lease 过期后运行选举协议,新 master 通常几秒内选出 —— 给的两组数据是:最近两次选举分别用了 6 秒和 4 秒,但也观察到高达 30 秒的。

replica 的替换是一个工程细节:某个 replica 失败且几小时内没恢复时,一个简单的替换系统从空闲池里取一台新机器、在其上启动锁服务器二进制,然后更新 DNS 表,把失败 replica 的 IP 换成新的。当前 master 周期性轮询 DNS 从而察觉变化,接着更新 cell 数据库里的成员列表(该列表经正常复制协议保持一致)。与此同时,新 replica 从文件服务器上的备份 + 活跃 replica 的更新两者结合拿到一份较新的数据库副本;一旦它处理过一条当前 master 正等待提交的请求,它就有资格在新 master 选举中投票。

命名空间与节点 ​

接口是一个类似 UNIX 但更简单的文件系统:严格的树形文件与目录,名字用斜杠分隔。典型名字是

/ls/foo/wombat/pouch

ls 前缀是所有 Chubby 名字共有的,代表 lock service;第二段(foo)是 Chubby cell 的名字,经 DNS 解析到一组 Chubby 服务器;特殊 cell 名 local 表示用客户端本地的 cell(通常是同一栋楼里的、因而最可能可达的那个);剩下的 /wombat/pouch 在 cell 内部解释。

因为命名结构像文件系统,它可以同时用自己专用的 API、以及我们别的文件系统(如 GFS)所用的接口来暴露 —— 这显著降低了编写浏览与命名空间操作工具的工作量,也减少了教育偶发用户的需要。

为了让分布更容易,它和 UNIX 有几处刻意的不同:

  • 不暴露能在目录之间移动文件的操作 —— 这样不同目录的文件就可以由不同的 Chubby master 来服务;
  • 不维护目录修改时间;
  • 避免依赖路径的权限语义 —— 访问由文件自身的权限控制,而不是通往它的路径上各目录的权限;
  • 不暴露最后访问时间 —— 为了更容易缓存文件元数据。

命名空间里只有文件和目录,统称 node。每个 node 在它的 cell 内只有一个名字;没有符号链接也没有硬链接。 节点分永久与临时(ephemeral):任何节点都可以被显式删除,但临时节点还会在"没有任何客户端打开它"时被删除(目录则要求为空)。临时文件既当临时文件用,也当"某个客户端还活着"的指示器用。任何节点都可以充当建议性读写锁。

每个 node 的元数据里有三组 ACL 名(控制读、写、改 ACL 名),创建时默认从父目录继承。ACL 本身就是文件,放在一个众所周知位置的 ACL 目录里,内容是简单的 principal 名字列表(与 Plan 9 的 group 同构)。

还有四个单调递增的 64 位数,用来让客户端容易地检测变化:

数字何时递增
instance number比同名的任何先前节点都大 —— 用来识别"这是不是同一个节点的新化身"
content generation number(仅文件)文件内容被写入时
lock generation number该节点的锁从 free 变成 held 时
ACL generation number该节点的 ACL 名被写入时

另外还暴露一个 64 位的内容校验和,让客户端判断文件是否有差异。

handle(类比 UNIX 文件描述符)三个字段各有用途:

  • check digits —— 防止客户端创建或猜测 handle,于是完整的访问控制检查只需要在创建 handle 时做一次。这里可以做一次对照:UNIX 也是只在 open 时检查权限位、不在每次读写时检查,因为文件描述符无法被伪造;
  • 一个 sequence number —— 让 master 分辨某个 handle 是自己生成的,还是前一个 master生成的;
  • open 时提供的 mode 信息 —— 让 master 在收到一个旧 handle 时能重建自己的状态。

锁与 sequencer ​

每个文件和目录都能当读写锁用:要么一个 handle 以独占写模式持有,要么任意多个 handle 以共享读模式持有。锁是建议性的(advisory):它只与其他"申请同一把锁"的尝试冲突 —— 持有一把叫 F 的锁,既不是访问文件 F 的必要条件,也不能阻止其他客户端访问它。

为什么拒绝强制锁,三条理由:

  1. Chubby 锁常常保护的是其他服务实现的资源,而不只是与锁关联的那个文件 —— 要有意义地实施强制锁,就得对那些服务做大范围改造;
  2. 不想强迫用户为了调试或管理而关掉应用 —— 在复杂系统里,个人电脑上那套"让管理软件径直打断强制锁:叫用户关掉应用或重启"的做法更难用;
  3. 开发者本来就按常规方式做错误检查 —— 写"锁 X 已持有"这样的断言 —— 所以强制检查带来的收益很小;而有 bug 或恶意的进程本来就有很多机会在锁未持有时破坏数据,多出来的守卫没有显著价值。

另外,两种模式的取锁都需要写权限,这样一个无特权的读者无法阻止写者推进。

分布式系统里的锁为什么会出错 ​

失效场景被拆得很干净:持有锁 L 的进程发出请求 R,然后失效;另一个进程取得 L 并做了某些动作,而 R 还没到达目的地;R 之后到达时,可能在 L 的保护之外被处理,还可能在已经不一致的数据上被处理。

已有解法是虚拟时间与虚拟同步这类机制(后者靠保证"消息的处理顺序与每个参与者的观察一致"来回避问题),但在一个既有的复杂系统里给所有交互都引入序号,代价太大。Chubby 的做法是只给用到锁的那些交互引入序号:

锁持有者随时可以申请一个 sequencer —— 一个不透明的字节串,描述"紧接取锁之后"那一刻锁的状态。 它包含锁的名字、取得它的模式(独占或共享)、以及 lock generation number。客户端在期望被锁保护的操作里,把这个 sequencer 一并传给服务器(例如文件服务器)。接收方应当检验这个 sequencer 是否仍然有效、模式是否匹配;不匹配就该拒绝请求。

有效性可以对照服务器自己的 Chubby 缓存检查,或者 —— 如果服务器不想维护一个与 Chubby 的会话 —— 对照它观察到的最近一个 sequencer。这个机制得到的评价是:只需在受影响的报文里多加一个字符串,而且很容易向开发者解释清楚。

lock-delay:给不支持 sequencer 的服务器一个较弱的兜底 ​

重要协议的演进很慢,所以为"不支持 sequencer 的服务器"提供了一个不完美但更容易的机制:

  • 客户端正常释放锁时,锁立即可被别人取用(符合直觉);
  • 若锁是因为持有者失效或不可达而变空闲,锁服务器会在一个叫 lock-delay 的期间里阻止其他客户端取它;
  • 客户端可以指定任意的 lock-delay,上限目前是一分钟 —— 这个上限防止一个有故障的客户端让某把锁(以及某个资源)无限期不可用。

定性很明确:不完美,但它保护了未修改的服务器与客户端免于日常的消息延迟与重启问题。

事件与缓存 ​

客户端创建 handle 时可以订阅一系列事件,事件由 Chubby 库通过 up-call 异步交付:

事件用途
文件内容被修改常用于监控一个通过该文件公告自身位置的服务
子节点被添加 / 删除 / 修改用来实现镜像;顺带一个好处:对子节点返回事件使得"监控临时文件"不必影响它们的引用计数
Chubby master 发生 fail-over警告客户端"其他事件可能已丢失",因此必须重新扫描数据
一个 handle(及其锁)失效通常意味着通信出了问题
锁被取得可用来判断 primary 何时被选出
另一个客户端请求了冲突的锁让"锁的缓存"成为可能

一条交付顺序保证:事件都在对应动作发生之后才交付 —— 所以客户端被告知文件内容已变化时,它随后读该文件保证能读到新数据(或更新的数据)。

最后两个事件被明确否定了:它们很少被使用,回过头看本可以省掉。

  • 选主之后,客户端通常需要与新 primary 通信、而不只是知道"存在一个 primary" —— 所以它们会等一个"文件被修改"事件,那个事件表明新 primary 已把自己的地址写进某个文件;
  • 冲突锁事件在理论上允许客户端缓存别处服务器上的数据、用 Chubby 锁维持缓存一致性(通知会让客户端收尾、把修改刷回原处、丢弃缓存、释放);结论是:"到目前为止,没有人采用这种用法。"

缓存:只失效、不更新 ​

客户端在内存里维护一个一致的、写穿的缓存,缓存文件数据与节点元数据(包括"文件不存在"这个事实)。缓存靠租约机制维持,并靠 master 发出的失效消息保持一致 —— master 会记录每个客户端可能缓存了什么。协议给的保证是硬的:客户端看到的要么是一致的状态,要么是一个错误。

修改路径是:要改文件数据或元数据时,修改会被阻塞,同时 master 向每一个可能缓存过它的客户端发失效;这个机制搭在 KeepAlive RPC 之上。客户端收到失效后清掉对应状态,并通过"下一次 KeepAlive 调用"来确认。 只有当服务器确知每个客户端都已失效其缓存(要么确认了,要么让缓存租约自然过期)之后,修改才继续。

只需一轮失效,理由是:只要失效还没被确认,master 就把该节点视为不可缓存。这个做法的收益是读永远不用等待(慢一点的是写)—— 这很重要,因为读远多于写。一个被否掉的替代方案是:在失效期间阻塞访问该节点的调用(能减少过度积极的客户端在失效期间用未缓存访问轰炸 master,代价是偶发延迟),并说如果这真成了问题,可以设想一个检测到过载就切换策略的混合方案。

而"只失效、不更新"这个选择的理由是:

更新式的协议可能任意低效 —— 一个访问过某文件的客户端可能无限期地收到更新,造成无上界的无用更新。

一致性上的取舍表态也很直接:尽管提供严格一致性有开销,我们还是拒绝了更弱的模型,因为我们认为程序员会觉得它们更难用;类似的、要求客户端在所有报文里交换序号的机制(如虚拟同步)在"既有通信协议五花八门"的环境里也不合适。

除了数据与元数据,客户端还缓存已打开的 handle:所以重复打开一个之前打开过的文件时,只有第一次 Open() 必然产生一次到 master 的 RPC。这个缓存有几处克制,保证不改变客户端观察到的语义:临时文件上的 handle 在应用关闭它之后不能被继续持有;允许取锁的 handle 可以复用,但不能被多个应用 handle 并发使用 —— 最后这条是因为客户端可能用 Close() 或 Poison() 的副作用来取消尚未完成的 Acquire() 调用。

客户端还可以缓存锁 —— 即比严格需要更久地持有锁,希望之后能复用。冲突锁事件就是为此准备的:它让持有者知道别处正需要这把锁,从而在恰好的时刻释放。

会话与 KeepAlive ​

一个 Chubby 会话是"cell 与客户端之间的一段关系",存在一段时间,靠周期性握手 KeepAlive 维持。核心保证是:

除非客户端主动告知 master 相反的情况,只要它的会话有效,它的 handle、锁、以及缓存数据就都保持有效。

客户端首次联系某个 cell 的 master 时请求一个新会话;会话的结束有两种:客户端显式终止,或空闲(没有打开的 handle、也没有调用)满一分钟。

每个会话带一个租约 —— 一段向未来延伸的间隔,在此期间 master 保证不会单方面终止会话;这个间隔的终点叫 session lease timeout。一条不对称的规则:master 可以把这个 timeout 往未来推,但不能往回移。

master 只会在三种情形下推进租约终点:会话创建时、发生 master fail-over 时、以及它应答客户端的 KeepAlive 时。

KeepAlive 的用法是这套设计的枢纽:收到 KeepAlive 后,master 通常会把这个 RPC 阻塞住,直到客户端前一个租约区间快到期,然后才让它返回、把新的租约终点告诉客户端。master 可以把 timeout 延长任意量;默认延长 12 秒,而过载的 master 可以用更大的值来减少自己要处理的 KeepAlive 次数。客户端一收到上一次应答就立刻发起下一次 KeepAlive —— 于是master 上几乎总是堵着一个 KeepAlive 调用。

KeepAlive 的应答同时被用来回传事件与缓存失效;有事件或失效要投递时,master 允许 KeepAlive 提前返回。这个"搭车"设计有两个连带后果,都是想要的:

  • 它保证客户端不可能"只维持会话、却不确认缓存失效";
  • 它使所有 Chubby RPC 都从客户端流向 master —— 这既简化了客户端,又让协议能穿过只允许单向发起连接的防火墙。

客户端自己维护一个保守近似的本地租约终点。它和 master 的值不同,因为客户端必须对两件事做保守假设:KeepAlive 应答在途花的时间,以及 master 时钟推进的速率 —— 为维持一致性,服务器的时钟被要求不得比客户端快过某个已知的常数因子。

jeopardy 与 grace period ​

客户端本地租约终点到期时,它就变得无法确定 master 是否已经终止了会话。 此时它清空并禁用缓存,会话进入 jeopardy(危急) 状态,并再等一段 grace period,默认 45 秒:

  • 若在 grace period 结束前客户端与 master 成功交换了一次 KeepAlive,客户端重新启用缓存;
  • 否则,客户端认定会话已过期。

这么设计的目的很具体:让 Chubby API 调用在 cell 不可达时不会无限期阻塞 —— 如果在通信恢复之前 grace period 就结束,调用会以错误返回。

库可以通过 jeopardy 事件告诉应用 grace period 开始了;会话确定存活时用 safe 事件让客户端继续;若最终超时则发 expired 事件。这些事件的价值在于:它让应用在不确定时先把自己静默下来,并在问题只是暂时的时候不必重启就能恢复 —— 这对启动开销很大的服务避免故障很重要。

还有一条关于失败边界的保证:若客户端持有某个节点的 handle H,而 H 上任何一次操作因为会话过期而失败,则 H 上后续所有操作(除 Close() 与 Poison())都会以同样方式失败。 用处是:客户端据此可以保证网络或服务器中断只造成一串操作的后缀丢失,而不是任意子序列丢失 —— 这让复杂变更更容易做。

master fail-over:九步重建 ​

master 失效或失去 master 身份时,它丢弃关于会话、handle 和锁的内存状态。这里有一个很巧的点:会话租约的权威计时器跑在 master 上,所以在新 master 选出之前,这个计时器是停的 —— 这是合法的,因为它等价于延长客户端的租约。

若选举很快,客户端能在本地(近似的)租约计时器到期前联系上新 master;若选举拖得很久,客户端就清空缓存、边等 grace period 边找新 master。所以:grace period 的作用,正是让会话能跨越"超出正常租约超时"的 fail-over 存活下来。

新 master 起来后要重建前任内存状态的一个保守近似,来源有三:稳定存在磁盘上的数据(经正常数据库复制协议)、从客户端取得的状态、以及保守假设。数据库里记录了每个会话、每个被持有的锁、以及每个临时文件。九步是:

  1. 先选一个新的 client epoch number,客户端必须在每次调用时出示它。master 拒绝使用旧 epoch 号的调用,并把新号给它 —— 这保证新 master 不会去响应一个发给前任 master 的极旧报文,即便前任跑在同一台机器上。
  2. 可以应答 master 位置请求,但起初不处理会话相关操作。
  3. 为数据库里记录的会话与锁建内存结构。会话租约被延长到"前任可能用过的最大值"。
  4. 允许客户端做 KeepAlive,但不允许其他会话相关操作。
  5. 向每个会话发出 fail-over 事件 —— 这使客户端清空缓存(因为可能漏掉了失效),并警告应用其他事件可能已经丢失。
  6. 等待每个会话确认这个 fail-over 事件,或等它的会话过期。
  7. 允许所有操作继续。
  8. 若客户端使用一个 fail-over 之前创建的 handle(靠 handle 里的 sequence number 判断),master 重建该 handle 的内存表示并接下这个调用。这样重建出来的 handle 被关闭时,master 会在内存里记下它,使它在当前 master epoch 内不能再被重建 —— 从而延迟或重复的网络包不可能意外重建一个已关闭的 handle。有故障的客户端可以在未来的 epoch 里重建它,但考虑到该客户端本来就有故障,这无害。
  9. 过一段时间(比如一分钟)后,master 删除没有打开 handle 的临时文件。 客户端应当在 fail-over 后的这段时间里刷新临时文件上的 handle。这个机制有一个不幸副作用:若某个临时文件上的最后一个客户端恰好在 fail-over 期间丢掉了会话,该临时文件可能不会及时消失。

这一节收在一句很好记的评语上:

读者不会惊讶地得知,这段比系统其他部分被行使得少得多的 fail-over 代码,一直是各种有意思的 bug 的富矿。

线上实测 ​

一个 cell 的快照(RPC 速率取自十分钟窗口),这些数字在 Google 的 cell 里是典型的:

指标值
距上次 fail-over18 天
fail-over 时长14 秒
活跃客户端(直连)22k
额外经代理的客户端32k
打开的 handle12k
缓存条目(client-is-caching-file)230k
被缓存的不同文件24k
被负缓存的名字32k
独占锁1k
共享锁0
存储的目录8k(临时占 0.1%)
存储的文件22k,其中 0–1 KB 占 90%、1–10 KB 占 10%、>10 KB 占 0.2%
文件用途命名相关 46%、镜像的 ACL 与配置 27%、GFS 与 Bigtable 的元数据 11%、临时 3%
RPC 速率1–2k/s:KeepAlive 93%、GetStat 2%、Open 1%、CreateSession 1%、GetContentsAndStat 0.4%、SetContents 680 ppm、Acquire 31 ppm

从表里可以读出五件事:大量文件用于命名;配置、访问控制与元数据文件(类比文件系统的超级块)很常见;负缓存占相当比例;230k/24k ≈ 10,即平均每个被缓存文件有 10 个客户端在用;很少客户端持有锁、共享锁几乎不用 —— 这与"锁被用于选主和把数据在副本间分区"的用法一致;以及RPC 流量由会话 KeepAlive 主导,读很少(都是缓存未命中)、写与取锁极少。

故障统计:把"有 master 且愿意服务"乐观地算作 cell 可用,在若干 cell 上几周内记录到 61 次故障,合计 700 cell-天的数据。已排除"关停数据中心的维护",其余全算(网络拥塞、维护、过载、以及操作员、软件、硬件错误)。多数故障在 15 秒或更短,其中 52 次在 30 秒以内 —— 而多数应用对 30 秒以内的 Chubby 故障没有明显受影响的。剩下九次的原因是:网络维护 4、疑似网络连通问题 2、软件错误 2、过载 1。

经验教训 ​

① 开发者很少考虑可用性。 开发者很少考虑故障概率,倾向把 Chubby 当成永远可用。有一个把代价放大到极致的实例:

开发者曾构建一个由数百台机器组成的系统,在 Chubby 选出新 master 时启动耗时数十分钟的恢复过程 —— 这把一次故障的后果在时间与受影响机器数两个维度上各放大了约一百倍。

设计上的期望是开发者按"Chubby 会短暂不可用"来设计,这也是支持粗粒度锁的论据之一。

还有一个更微妙的观察:开发者没能区分"服务是 up"与"服务对我的应用是可用的"。以全局 cell 为例 —— 它几乎总是 up(因为很少出现两个地理位置相隔很远的数据中心同时宕机),但它在某个具体客户端观察到的可用性通常低于该客户端本地 cell 的可用性,原因有二:

  1. 本地 cell 更不容易与客户端发生分区;
  2. 本地 cell 也许常因维护而宕机,但同一场维护也直接影响客户端本身,所以 Chubby 的不可用对客户端来说并未被观察到。

API 的选择会影响开发者处理故障的方式 —— 这一点上有一条自我批评:那个"master fail-over"事件的意图是让客户端检查可能的变化(因为其他事件可能丢了),但很多开发者选择在收到它时直接让应用崩溃,从而大幅降低了他们自己系统的可用性。这里的反思是:我们本可以做得更好 —— 改成发送冗余的"文件变更"事件,或者干脆保证 fail-over 期间不丢事件。

目前用三个机制防止开发者对 Chubby 可用性过于乐观:审查项目团队打算怎么用 Chubby,劝阻那些会把自身可用性与 Chubby 绑得太紧的做法;提供执行高层任务的库,让开发者自动与 Chubby 故障隔离;以及把每一次 Chubby 故障的复盘不仅用于消除 Chubby 与运维流程的 bug,也用于降低应用对 Chubby 可用性的敏感度。

② 细粒度锁的方案最终没被需要。 前文勾勒过一个"客户端自己跑的细粒度锁服务器"的设计,但结论是:

也许令人意外的是,到目前为止我们并不需要写这样一个服务器;我们的开发者通常发现,要优化他们的应用就必须去掉不必要的通信,而那往往意味着找到一种使用粗粒度锁的方式。

③ API 选择的意外后果。 用来取消长时调用的手段是 Close() 与 Poison(),但它们同时会丢弃该 handle 在服务器端的状态 —— 这使"能取锁的 handle"无法被共享(例如被多个线程共用)。可能要加一个 Cancel() RPC 来放开共享。

④ RPC 的用法会反过来约束传输协议。 KeepAlive 同时承担续租与回传事件和缓存失效两件事,于是自动得到一个想要的性质:客户端无法在不确认缓存失效的情况下续租。但它引入了一个协议选择上的张力:

TCP 的退避策略完全不顾更高层的超时(比如 Chubby 的租约),所以基于 TCP 的 KeepAlive 在高网络拥塞时导致大量会话丢失。我们被迫把 KeepAlive RPC 改用 UDP 而非 TCP 发送;而 UDP 没有任何拥塞避免机制,所以我们更愿意只在"必须满足高层时间界"时才用它。

可能会补一个基于 TCP 的 GetEvent() RPC,在正常情况下用来传事件与失效;KeepAlive 的应答仍然带一份未确认事件清单,以保证事件最终一定被确认。

相关 ​

  • Bigtable —— 最直接的依赖关系:Bigtable 用 Chubby 发现受自己控制的服务器、并让客户端找到 master,同时把一小块元数据存在它上面;Bigtable 对 Chubby 有五处依赖,还实测了跨 11 个 Chubby 实例、14 个 Bigtable 集群的不可用率为 0.0047%
  • GFS —— 另一处直接依赖:GFS 用一把 Chubby 锁来任命 master,替代了"靠操作员介入"的旧做法
  • Paxos —— 底层协议就是 Paxos;Paxos Made Live 讲的正是 Chubby 复制层的重建过程,与这一篇是同一系统的两个视角
  • Zab 与 ZooKeeper —— 同类系统的对照:ZooKeeper 走的是 wait-free 协调 + 全序广播,Chubby 走的是锁 + 会话租约;两篇对"缓存该用一致性还是超时"的选择不同(Chubby 明确拒绝基于时间的缓存)
  • Spanner 与 F1 —— Spanner 用 TrueTime 替代了大部分"靠租约租出时间"的做法,而 Chubby 的 master lease 正是租约路线的典型;两篇并读能看清"时间从哪来"这个分岔
  • Borg —— 更上层的客户端:Borgmaster 选出后取一把 Chubby 锁,好让其他系统能找到它;任务的 BNS 名字(cell / job / task 号)也写进 Chubby 里一个一致且高可用的文件,供 RPC 系统解析端点
  • CAP 定理 —— Chubby 在多数场景里选的是一致性(拒绝弱模型,理由是可理解性),把可用性交给粗粒度 + 少取锁来保住

参考 ​

  • M. Burrows. The Chubby Lock Service for Loosely-Coupled Distributed Systems. OSDI 2006.
  • F. Chang, J. Dean, S. Ghemawat, W. C. Hsieh, D. A. Wallach, M. Burrows, T. Chandra, A. Fikes, R. E. Gruber. Bigtable: A Distributed Storage System for Structured Data. OSDI 2006.
  • T. Chandra, R. Griesemer, J. Redstone. Paxos Made Live — An Engineering Perspective. PODC 2007.

贡献者 ​

文件历史 ​