文章目录
摘要
我们描述了我们使用Chubby锁服务的经验,该服务旨在为松散耦合的分布式系统提供粗粒度锁以及可靠(尽管低容量)存储。Chubby提供的接口很像带有咨询锁的分布式文件系统,但设计重点在于可用性和可靠性,而不是高性能。该服务的许多实例已使用了一年多,其中几个实例每个同时处理数万个客户端。本文描述了初始设计和预期用途,将其与实际使用进行了比较,并解释了如何修改设计以适应差异。
1. 简介
本文描述了一个名为Chubby的锁服务。它适用于松散耦合的分布式系统,该系统由大量通过高速网络连接的小型机器组成。例如,一个Chubby实例(也称为Chubby单元)可能服务于通过1Gbit/s以太网连接的一万台4核处理器机器。大多数Chubby单元被限制在一个数据中心或机房,但我们确实运行了至少一个Chubby单元,其副本相隔数千公里。
锁服务的目的是允许其客户端同步他们的活动并就其环境的基本信息达成一致。主要目标包括可靠性,适用于大量客户端的可用性以及易于理解的语义;吞吐量和存储容量被认为是次要的。Chubby的客户端接口类似于执行整个文件读取和写入的简单文件系统,通过咨询锁和文件修改等各种事件的通知进行扩展。
我们期望Chubby帮助开发者处理他们系统中的粗粒度同步,特别是处理从一组对等服务器中选举领导者的问题。例如,Google File System
[7]使用Chubby锁来指定GFS主服务器,Bigtable[3]以多种方式使用Chubby:选择主服务器、使主服务器发现它控制的服务器、以及允许客户端找到主服务器。此外,GFS和Bigtable都使用Chubby作为存储众所周知且可用的少量元数据的位置;实际上,他们使用Chubby作为其分布式数据结构的根。某些服务使用锁来对多个服务器之间的工作进行分区(粗粒度的)。
在部署Chubby之前,Google的大多数分布式系统都使用临时方法进行初步选举(当工作可以重复而不会造成损害时),或者需要操作员干预(当正确性十分必要时)。在前一种情况下,Chubby可以节省大量的计算工作量。在后一种情况下,它在不需要人为干预系统失败中实现了可用性的显著改善。
熟悉分布式计算的读者会认识到在对等体中选择一个主作为分布式共识问题的实例,并且意识到我们需要使用异步通信的解决方案;该术语描述了绝大多数真实网络的行为,例如以太网或因特网,它们允许数据包丢失、延迟和重新排序。(实践者通常应该注意基于对环境做出更强假设的模型的协议。)Paxos协议[12,13]解决了异步共识。Oki和Liskov使用了相同的协议(参见他们关于viewstamped replication
[19,§4]的论文),其他人关注到的等价协议[14,§6]。实际上,到目前为止我们遇到的异步共识的所有工作协议都以Paxos为核心。Paxos在没有时间假设的情况下保持安全,但必须引入时钟以确保活跃;这克服了Fischer等人的不可能性结果[5,§1]。
构建Chubby是满足上述需求要求的工程努力;这不是研究。我们声称没有新的算法或技术。本文的目的是描述我们所做的及为什么,而不是为它辩护。在接下来的部分中,我们将介绍Chubby的设计和实现,以及如何根据经验对其进行更改。我们描述了使用Chubby的意想不到的方式,以及被证明是错误的功能。我们省略了其他文献中已涵盖的细节,例如共识协议或RPC系统的详细信息。
2. 设计
2.1 理由
有人可能会说我们应该建立一个体现Paxos的库,而不是一个访问中心锁服务的库,甚至是一个高可靠的库。客户端Paxos库将不依赖于其他服务器(除了名称服务),并且将为程序员提供标准框架,假设他们的服务可以作为状态机实现。实际上,我们提供了一个独立于Chubby的客户端库。
然而,锁服务比客户端库具有一些优势。首先,我们的开发人员有时不会按照人们的意愿设计高可用性。通常,他们的系统从原型开始,负载很小,宽松的可用性保障;代码总是没有为共识协议特别构造。随着服务的成熟和客户端的增加,可用性变得更加重要;然后将副本和主选举添加到现有设计中。虽然这可以通过提供分布式共识的库来完成,但是锁服务可以更容易地维护现有的程序结构和通信模式。例如,要选择一个主服务器然后写入现有文件服务器,只需要向现有系统添加两条语句和一个RPC参数:一个将获得锁成为主服务器,传递一个额外的整数(锁获取计数)写入RPC,并向文件服务器添加if语句,以便在获取计数低于当前值时拒绝写入(以防止延迟数据包)。我们发现这种技术比使现有服务器参与共识协议更容易,尤其是如果在过渡期间必须保持兼容性的话。
其次,我们的许多服务在其组件之间选择主或分区数据需要一种机制来广播结果。这表明我们应该允许客户端存储和获取少量数据 - 即读取和写入小文件。这可以通过名称服务来完成,但我们的经验是锁服务本身非常适合这项任务,因为这减少了客户端所依赖的服务数量,并且因为协议的一致性特征是共享。Chubby作为名称服务器的成功很大程度上归功于它使用一致的客户端缓存,而不是基于时间的缓存。特别是,我们发现开发人员非常认同不必选择缓存超时,例如DNS生存时间值,如果选择不当,可能导致高DNS负载或长客户端故障恢复时间。
第三,我们的程序员更熟悉基于锁的接口。Paxos的复制状态机和与独占锁相关的临界区都可以为程序员提供顺序编程的假象。然而,许多程序员以前遇到过锁,并认为他们知道使用它们。具有讽刺意味的是,这些程序员通常是错误的,特别是当他们在分布式系统中使用锁时; 很少人考虑具有异步通信的系统中独立机器故障对锁的影响。然而,对锁的明显地熟悉克服了说服程序员使用可靠机制进行分布式决策的障碍。
最后,分布式一致性算法使用仲裁来做出决策,因此他们使用多个副本来实现高可用性。例如,Chubby本身通常在每个单元中有五个副本,其中三个必须运行才能使单元运行起来。相反,如果客户端系统使用锁服务,即使是单个客户端也可以获得锁并安全地进行。因此,锁服务减少了可靠客户端系统运行所需的服务器数量。从宽松的意义上讲,人们可以将锁服务视为提供通用选举的一种方式,允许客户端系统在少于其大多数成员的情况下正确地做出决策。可以想象以不同的方式解决这个最后的问题:通过提供“共识服务”,使用多个服务器来提供Paxos协议中的“受主”。与锁服务一样,即使只有一个活跃的客户端进程,共识服务也可以让客户端安全地运行;类似的技术已用于减少拜占庭容错[24]所需的状态机数量。但是,假设共识服务不是专门用于提供锁(将其减少为锁服务),则该方法不会解决上述任何其他问题。
这些论点提出了两个关键设计决策:
- 我们选择了锁服务,而不是库或者共识服务,以及
- 我们选择提供小文件,以允许当选的主服务广播自己及其参数,而不是建立和维护另一个服务。
来自我们的预期用途和环境的一些决策:
- 一个通过Chubby文件广播其主的服务可能有数千个客户端。因此,我们必须允许数千个客户端查看此文件,最好不需要很多服务器。
- 复制的服务的副本和客户端可能希望知道该服务的主的更改时间。这表明事件通知机制对于避免轮询很有用。
- 即使客户端不需要定期轮询文件,许多人也会这样做; 这是支持许多开发人员后得出的结论。因此,需要缓存文件。
- 我们的开发人员对非直观的缓存语义感到困惑,所以我们更喜欢一致的缓存。
- 为了避免经济损失和监禁时间,我们提供安全机制,包括访问控制。
一个可能让一些读者感到惊讶的选择是我们不希望锁使用是细粒度的,它们可能只持续很短的持续时间(秒或更短);相反,我们期望粗粒度使用。例如,应用可能会使用锁来选择主,然后该主将在相当长的时间内(可能是数小时或数天)处理对该数据的所有访问。这两种使用方式表明了锁服务器的不同要求。
粗粒度锁对锁服务器的负载要小得多。特别是,锁获取率通常仅与客户端应用程序的事务率弱相关。粗粒度锁很少被获取,因此锁服务器短时不可用很少会延迟客户端。另一方面,从客户端之间的锁转移可能需要高昂的恢复过程,因此不希望锁服务器的故障恢复导致锁丢失。因此,粗粒度锁可以在锁服务器故障时很好地存活,对这样做的开销几乎不用关心,并且这种锁允许许多客户端由适度数量的可用性稍低的锁服务器充分服务。
细粒度锁导致不同的结论。即使锁服务器短暂不可用也可能导致许多客户端停止运行。性能和随意添加新服务器的能力非常值得关注,因为锁服务的事务率随着客户端的合计事务率而增长。通过在锁定服务器故障期间不维持锁来减少锁的开销是有利的,并且每隔一段时间丢失锁的时间损失并不严重,因为锁被短时间保持。(客户端必须准备好在网络分区期间丢失锁,因此锁服务器故障恢复时的锁丢失不会引入新的恢复路径。)
Chubby旨在仅提供粗粒度锁。幸运的是,客户端可以直接实现针对其应用量身定制的细粒度锁。应用可能会将其锁分组,并使用Chubby的粗粒度锁将这些分组锁分配给应用特定的锁服务器。维持这些细粒度锁需要很少的状态;服务器只需保留一个很少更新的非易失性、单调递增的获取计数器。客户端可以在释放锁时获悉丢失的锁,如果使用简单的固定长度租约,则协议可以简单有效。该方案最重要的好处是我们的客户端开发人员负责支持其负载所需的服务器,但却免除了实现共识本身的复杂性。
2.2 系统架构
Chubby有两个主要组件通过RPC进行通信:服务器和客户端应用链接的库;请参见图1。Chubby客户端与服务器之间的所有通信都由客户端库作为中介。一个可选的第三个组件,即代理服务器,将在3.1节中讨论。
一个Chubby单元由一小组称为副本的服务器(通常为五个)组成,副本放置以减少关联故障(例如,在不同的机架中)的可能性。副本使用分布式共识协议来选举一个主;主必须获得大多数副本的投票,以及承诺几秒钟间隔内(称为主租约)不会选举不同的主。如果主继续赢得大部分投票,主租约将由副本定期更新。
副本维护一个简单数据库的副本,但只有主启动对此数据库的读写操作。所有其他副本只需从主服务器复制使用共识协议发送的更新。
客户端通过将主位置请求发送到DNS中列出的副本来查找主服务器。非主服务器副本通过返回主服务器的标识来响应此类请求。一旦客户端找到了主服务器,客户端就会将所有请求定向到它,直到它停止响应,或直到它表示它不再是主服务器。写请求通过共识协议传播到所有副本; 当写入到达单元中的大多数副本时,将确认此类请求。只有主服务器才能满足读取请求;如果主租约尚未到期,这是安全的,因为没有其他主服务器可能存在。如果主服务器发生故障,则其他副本在主租约到期时运行选举协议;通常会在几秒钟内选出一个新的主。例如,最近的两次选举分别为6s和4s,但我们看到的值高达30s(§4.1)。
如果一个副本失败并且几小时内没有恢复,则简单的替换系统会从空闲池中选择一台新计算机并在其上启动锁服务器二进制文件。然后,它会更新DNS表,将故障副本的IP地址替换为新副本的IP地址。当前主服务器定期轮询DNS并最终注意到更改。然后它更新单元数据库中单元成员的列表;此列表通过常规复制协议在所有成员之间保持一致。在此期间,新副本从存储在文件服务器上的备份和来自活跃副本的更新的组合中获取数据库的最新副本。一旦新副本处理了当前主节点等待提交的请求,则允许副本在新主节点的选举中投票。
2.3 文件,目录和句柄
Chubby导出一个类似于UNIX[22]但比UNIX更简单的文件系统接口。它以通常方式由严格的文件和目录树组成,名称组件由斜杠分隔。典型的名称是:
/ls/foo/wombat/pouch
ls
前缀对所有Chubby名称都是通用的,代表锁服务。第二个组件(foo)是Chubby单元的名称;它通过DNS查找解析为一个或多个Chubby服务器。一个特殊的单元名称local表示应该使用客户端的本地Chubby单元;这通常是同一建筑物中的一个,因此也是最容易访问到的一个。名称/wombat/pouch
的其余部分在对应名称的Chubby单元中进行解释。和UNIX一样,每个目录都包含子文件和目录的列表,而每个文件包含一系列未解释的字节。
由于Chubby的命名结构类似于文件系统,因此我们能够通过其自己的专用API以及我们的其他文件系统(如Google File System)使用的接口将其提供给应用程序。这大大减少了编写基本浏览和名称空间操作工具所需的工作量,并减少了教育非正式Chubby用户的需要。
该设计与UNIX的不同之处在于易于分发。为了允许不同目录中的文件从不同的Chubby主服务器提供服务,我们不暴露可以将文件从一个目录移动到另一个目录的操作、我们不维护目录修改时间、并且我们避免了路径相关的权限语义(即,对文件的访问权限由文件本身的权限控制,而不是由通向文件的路径上的目录控制)。为了更容易缓存文件元数据,系统不会显示上次访问时间。
名称空间仅包含文件和目录,统称为节点。每个这样的节点在其单元中只有一个名称;没有符号或硬链接。
节点可以是永久性的,也可以是暂时性的。可以显式删除任何节点,但如果暂时性节点没有客户端打开它们,则也会被删除(对于目录,它们是空的)。暂时性文件用作临时文件,并作为客户端对其他方活着的标示符。任何节点都可以充当咨询读/写锁;这些锁在2.4节中有更详细的描述。
每个节点都有各种元数据,包括用于控制读取、写入和更改节点的ACL名称的三个访问控制列表(ACLs)名称。除非被覆盖,否则节点在创建时会继承其父目录的ACL名称。ACL本身就是位于ACL目录中的文件,ACL目录是单元本地名称空间的一个众所周知的部分。这些ACL文件由主体名称的简单列表组成;读者可能会想起计划9的团队[21]。因此,如果文件F的写入ACL名称为foo,并且ACL目录包括含有条目bar的文件foo,则允许用户bar写入F。用户通过RPC系统内置的机制进行身份验证。由于Chubby的ACL是简单文件,因此它们自动对希望使用类似访问控制机制的其他服务可用。
每个节点的元数据包括四个单调递增的64位数字,允许客户端轻松检测更改:
- 实例编号;大于具有相同名称的任何先前节点的实例编号。
- 内容世代编号(仅限文件);写入文件内容时会增加。
- 锁世代编号;当节点的锁从空闲转换为持有时,这会增加。
- ACL世代编号;当写入节点的ACL名称时,这会增加。
Chubby还暴露了64位文件内容校验和,因此客户端可以判断文件是否不同。
客户端打开节点以获取类似于UNIX文件描述符的句柄。句柄包括:
- 检查编号数字以阻止客户端创建或猜测句柄,因此只有在创建句柄时才需要执行完全访问控制检查(与UNIX比较,UNIX会在打开时检查其权限位,但不会在每次读/写时检查,因为文件描述符不能伪造)。
- 一个序列编号,允许主服务器判断句柄是由它还是由前一个主服务器生成的。
- 在打开时提供的模式信息,以允许主服务器在向新重新启动的主服务器提供旧句柄时重新创建其状态。
2.4 锁和序列生成器
每个Chubby文件和目录都可以充当读写锁:一个客户端句柄可以以独占(写入)模式保持锁,或者任何数量的客户端句柄都可以将锁保持在共享(读取器)模式。像大多数程序员所知的互斥锁一样,锁是建议性的。也就是说,它们只与获取相同锁的其他尝试冲突:持有一个名为F的锁既不需要访问文件F,也不能阻止其他客户端这样做。我们拒绝强制锁,这使得没有锁的客户端无法访问锁定的对象:
- Chubby锁通常保护由其他服务实现的资源,而不仅仅是与锁相关联的文件。要以有意义的方式强制执行强制锁,这要求我们对这些服务进行更广泛的修改。
- 我们不希望强制用户在需要访问锁定文件以进行调试或管理时关闭应用。在复杂的系统中,使用大多数个人计算机上采用的方法更加困难,因为个人计算机上管理软件只需指示用户关闭其应用或重新启动即可破坏强制锁。
- 我们的开发人员通过编写诸如“锁X被持有”之类的断言的传统方式执行错误检查,因此它们从强制检查中获益很少。当没有锁时,Buggy或恶意进程有很多机会破坏数据,因此我们发现强制锁提供的额外防护没有重要价值。
在Chubby中,在任一模式下获取锁都需要写入权限,因此无权限的读无法阻止写入者运行。
锁在分布式系统中很复杂,因为通信通常是不确定的,并且进程可能独立地失败。因此,持有锁L的进程可以发出请求R,但随后失败。另一个进程可以获取L并在R到达其目的地之前执行一些操作。如果R稍后到达,则可以在没有L保护的情况下对其进行操作,并且可能在不一致的数据上进行操作。无序接收消息的问题已得到很好的研究;解决方案包括虚拟时间[11]和虚拟同步[1],它通过确保按照与每个参与者的观察一致的顺序处理消息来避免问题。
将序列编号引入现有复杂系统中的所有交互中是很昂贵的。相反,Chubby提供了一种方法,通过该方法可以将序列号引入仅使用锁的那些交互中。在任何时候,锁持有者都可以请求序列发生器,这是一个不透明的字节串,用于在获取后立即描述锁的状态。它包含锁的名称,获取它的模式(独占或共享)以及锁生成编号。如果客户端期望通过锁保护操作,则客户端将序列生成器传递给服务器(例如文件服务器)。接收服务器应测试序列生成器是否仍然有效并具有适当的模式;如果没有,它应该拒绝该请求。可以针对服务器的Chubby缓存检查序列生成器的有效性,或者,如果服务器不希望与Chubby保持会话,则针对服务器观察到的最新序列生成器。序列生成器机制只需要在受影响的消息中添加一个字符串,并且很容易向我们的开发人员解释。
虽然我们发现序列发生器易于使用,但重要的协议发展缓慢。因此,Chubby提供了一种不完善但更容易的机制,可以降低对不支持序列生成器的服务器的延迟或重新排序请求的风险。如果客户端以正常方式释放锁,则可以立即对其他客户端声明可用,正如人们所期望的那样。但是,如果锁因为持有者失败或无法访问而变为空闲,则锁服务器将阻止其他客户端在称为锁延迟的时段内要求锁。客户端可以指定任意锁延迟,直到某个边界,目前为一分钟;此限制可防止有故障的客户端在任意长时间内使锁(因而导致某些资源)不可用。虽然不完美,但锁延迟可保护未经修改的服务器和客户端免受因消息延迟和重新启动而导致的日常问题。
2.5 事件
Chubby客户端在创建句柄时可以订阅一系列事件。这些事件通过Chubby库的一个向上调用来异步传递给客户端。事件包括:
- 修改的文件内容 - 通常用于监视通过文件通知的服务的位置。
- 添加,删除或修改子节点 - 用于实现镜像(§2.12)。(除了允许发现新文件之外,为子节点返回事件还可以监视临时文件,而不会影响其引用计数。)
- Chubby主服务器故障 - 警告客户端其他事件可能已丢失,因此必须重新扫描数据。
- 句柄(及其锁)已变为无效 - 这通常表明存在通信问题。
- 获得锁 - 可用于确定选举主的时间。
- 来自另一个客户端的冲突锁请求 - 允许缓存锁。
事件在相应的操作发生后传递。因此,如果通知客户端文件内容已经改变,则保证在随后读取文件时看到新数据(或者更新的数据)。
提到的最后两个事件很少使用,且事后可能会被省略。例如,在主选举之后,客户端通常需要与新的主通信,而不是简单地知道存在主;因此,它们等待文件修改事件,指示新主节点将其地址写入文件中。理论上,冲突锁事件允许客户端缓存其他服务器上保存的数据,使用Chubby锁来维护缓存一致性。冲突锁请求的通知将告诉客户端完成使用与锁相关联的数据:它将完成挂起操作,刷新对起始位置的修改,丢弃缓存数据和释放锁。到目前为止,还没有人使用这种方式。
2.6 API
客户端将Chubby句柄视为指向支持各种操作的不透明结构的指针。句柄仅由Open()创建,并使用Close()销毁。
Open()打开命名文件或目录以生成句柄,类似于UNIX文件描述符。只有这个调用需要一个节点名称;所有其他调用都在句柄上操作。
相对于现有目录句柄评估名称;该库提供始终有效的“/”句柄。目录句柄避免了在包含多层抽象[18]的多线程程序中使用程序范围内当前目录的困难。
客户端显示各种选项:
- 如何使用句柄(读取;写入和锁;更改ACL); 仅当客户端具有适当的权限时才能创建句柄。
- 应该传递的事件(见§2.5)。
- 锁延迟(§2.4)。
- 是否应该(或必须)创建新文件或目录。如果创建了文件,则调用者可以提供初始内容和初始ACL名称。返回值表示文件是否实际创建。
Close()关闭一个打开的句柄。不允许进一步使用该句柄。这个调用永远不会失败。相关的调用Poison()会导致句柄上的未完成和后续操作失败而没有关闭它;这允许客户端取消其他线程发出的Chubby调用,而不必担心释放他们正在访问的内存。
作用于句柄的主要调用有:
- GetContentsAndStat()返回文件的内容和元数据。文件的内容以原子方式和整体读取。我们避免了部分读取和写入以阻止大文件。相关调用GetStat()仅返回元数据,而ReadDir()返回目录子项的名称和元数据。
- SetContents()写入文件的内容。可选地,客户端可以提供内容生成编号以允许客户端模拟文件的compare-and-swap操作;仅当生成编号为当前时才更改内容。文件的内容始终以原子方式整体写入。相关调用SetACL()对与节点关联的ACL名称执行类似的操作。
- 如果节点没有子节点,Delete()将删除该节点。
- Acquire(),TryAcquire(),Release()获取及释放锁。
- GetSequencer()返回一个序列生成器(§2.4),它描述了该句柄持有的任何锁。
- SetSequencer()将序列生成器与句柄相关联。 如果该序列生成器不再有效,则对句柄的后续操作将失败。
- CheckSequencer()检查序列生成器是否有效(参见§2.4)。
如果创建句柄后节点已被删除,则调用失败,即使文件随后已重新创建。也就是说,句柄与文件的实例相关联,而不是与文件名相关联。Chubby可以对任何调用应用访问控制检查,但会始终检查Open()调用(参见§2.3)。
除了调用本身所需的任何其他参数外,上述所有调用都需要一个操作参数。操作参数保存可能与任何调用相关联的数据和控制信息。特别是,通过操作参数,客户端可以:
- 提供回调以使调用异步,
- 等待该调用完成,和/或
- 获取扩展错误和诊断信息。
客户端可以使用此API执行主选举,如下所示:所有潜在的主打开锁文件并尝试获取锁。其中一个成功并成为主,而其他成为副本。主使用SetContents()将其标识写入锁文件,以便客户端和副本可以使用GetContentsAndStat()读取文件,可能是为了响应文件修改事件(§2.5)。理想情况下,主使用GetSequencer()获取一个序列生成器,然后将其传递给与之通信的服务器;他们应该使用CheckSequencer()确认它仍然是主。锁延迟可以与无法检查序列生成器的服务一起使用(§2.4)。
2.7 缓存
为了减少读取流量,Chubby客户端将保持一致地缓存文件数据和节点元数据(包括文件缺失),直接写入的缓存缓存在内存中。缓存由下面描述的租约机制维护,并通过主服务器发送的失效保持一致,其保存每个客户端可能缓存的内容的列表。该协议可确保客户端看到Chubby状态的一致视图或错误。
当要更改文件数据或元数据时,修改将被阻塞,而主服务器会将数据的失效发送给可能已缓存它的每个客户端;此机制基于KeepAlive RPC之上,将在下一节中进行更全面的讨论。收到失效后,客户端会刷新无效状态,并通过进行下一次KeepAlive调用进行确认。只有在服务器知道每个客户端已使其缓存失效之后,才会进行修改,因为客户端确认了失效,或者因为客户端允许其缓存租约过期。
只需要进行一轮失效,因为主节点将节点视为不可访问,而缓存失效仍未被确认。这种方法允许始终无延迟地处理读取;这很有用,因为读取次数大大多于写入次数。另一种方法是阻止在失效期间访问节点的调用;这将使得过热的客户端在失效期间使用未缓存的访问来轰炸主服务器的可能性降低,这是以偶尔的延迟为代价的。如果这是一个问题,可以考虑采用混合方案,如果检测到过载,则切换策略。
缓存协议很简单:它使更改中的缓存数据失效,并且永远不更新它。更新而不是使失效更简单,但仅更新协议可能是任意低效的;访问文件的客户端可能无限期地接收更新,从而导致无限数量的不必要更新。
尽管提供严格一致性的开销很大,我们拒绝较弱的模型,因为我们觉得程序员会发现它们更难使用。类似地,在具有各种预先存在的通信协议的环境中,诸如虚拟同步之类的机制被要求客户端在所有消息中交换序列号被认为是不合适的。
除了缓存数据和元数据,Chubby客户端还缓存打开的句柄。因此,如果客户端打开之前已打开的文件,则只有第一个Open()调用有必要导致到主服务器的RPC。这种缓存限制在很小的一些方面,因此它永远不会影响客户端观察到的语义:如果应用关闭了临时文件,则临时文件的句柄不能保持打开;允许锁的句柄可以重用,但不能由多个应用句柄同时使用。最后一个限制是因为客户端可能使用Close()或Poison()来消除对主服务器的未完成Acquire()调用的副作用。
Chubby的协议允许客户端缓存锁 - 也就是说,保持锁的时间超过严格必要的时间,希望它们可以被同一个客户端再次使用。如果另一个客户端请求了冲突锁,则一个事件通知锁持有者,让持有者在其他地方需要时释放锁(参见§2.5)。
2.8 会话和KeepAlives
Chubby会话是Chubby单元和Chubby客户端之间的关联关系;它存在一段时间,并由称为KeepAlives的周期性握手维持。除非Chubby客户端以其他方式通知主服务器,否则客户端的句柄、锁和缓存数据都保持有效,前提是其会话保持有效。(但是,会话保持协议可能要求客户端确认缓存失效以获取其会话;请参阅下文。)
客户端在首次联系Chubby单元的主节点时请求新会话。它会在它终止时或者会话已经空闲时(没有打开句柄且一分钟内没有调用)显式结束会话。
每个会话都有一个相关的租约 - 一个延伸到未来的时间间隔,在此期间主服务器保证不会单方面终止会话。此间隔的结束称为会话租约超时。此间隔的结束称为会话租约超时。主服务器可以在将来进一步延长此超时,但可能不会及时向后移动此超时。
主服务器在三种情况下延长租约超时:在创建会话时、发生主服务器故障恢复时(见下文)、以及响应从客户端发送的KeepAlive RPC时。收到KeepAlive后,主服务器通常会阻止RPC(不允许它返回),直到客户端的上一个租约间隔接近到期为止。主服务器稍后允许RPC返回到客户端,从而通知客户端新的租约超时。主服务器可以将超时延长到任意值。默认延长为12秒,但过载的主服务器可能会使用更高的值来减少必须处理的KeepAlive调用的数量。客户端在收到上一个回复后立即启动新的KeepAlive。因此,客户端确保几乎总是有一个KeepAlive调用阻塞在主服务器上。
除了延长客户端的租约外,KeepAlive回复还用于将事件和缓存失效传回客户端。主服务器允许KeepAlive在要发送事件或失效时提前返回。KeepAlive回复时的捎带事件可确保客户端无法在不确认缓存失效的情况下维持会话,并导致所有Chubby RPC从客户端流向主服务器。这简化了客户端,并允许协议经由允许仅在一个方向上启动连接的防火墙进行操作。
客户端维护本地租约超时,这是主服务器租约超时的保守近似值。它与主服务器的租约超时不同,因为客户端必须同时考虑其KeepAlive回复传输中花费的时间和主服务器时钟前进的速率的时间从而做出保守的假设;为了保持一致性,我们要求服务器的时钟前进速度不超过客户端的已知常数因子。
如果客户端的本地租约超时到期,则无法确定主服务器是否已终止其会话。客户端清空并禁用其缓存,我们说它的会话处于危险之中。客户端等待另一个称为宽限期的间隔,默认为45秒。如果客户端和主服务器在客户端宽限期结束之前设法交换成功的KeepAlive,则客户端将再次启用其缓存。否则,客户端假定会话已过期。这样做是为了当Chubby单元变得不可访问时,Chubby API调用不会无限期地阻塞;如果在重新建立通信之前宽限期结束,则调用返回错误。
当宽限期从危险事件开始时,Chubby库可以通知应用。当已知会话幸免于通信问题时,安全事件会告知客户端继续;反之如果会话超时,则发送过期事件。此信息允许应用在不确定其会话状态时自行停顿,并且如果问题证明是暂时的,则无需重新启动即可恢复。这对于避免具有很大启动开销的服务中断非常重要。
如果客户端在节点上持有句柄H并且H上的任何操作因相关会话已过期而失败,则H上的所有后续操作(除了Close()和Poison())将以相同的方式失败。客户端可以使用它来保证网络和服务中断仅导致丢失后续一系列操作,而不是任意子序列,从而允许将复杂的更改标记为通过最终写入的提交。
2.9 故障恢复
当主服务器失败或以其他方式失去主控权时,它会丢弃其关于会话、句柄和锁的内存中状态。会话租约的权威计时器在主服务器上运行,因此在选择新的主服务器之前,会话租约计时器被停止;这是合法的,因为它相当于延长客户的租约。如果主服务器选举很快发生,客户端可以在其本地(近似)租约计时器到期之前联系新主服务器。如果选举需要很长时间,客户端会在尝试查找新主服务器时刷新缓存并等待宽限期。因此,宽限期允许超过正常租约超时的跨故障恢复维护会话。
图2显示了冗长的主故障恢复事件中的事件序列,其中客户端必须使用其宽限期来保留其会话。时间从左到右增加,但倍数不增加。客户端会话租约显示为粗箭头,由新旧主服务器(上面的M1-3)和客户端(下面的C1-3)查看。向上倾斜的箭头表示KeepAlive请求,向下倾斜的箭头表示其回复。原始主服务器具有给客户端的会话租约M1,而客户端具有保守的近似值C1。在通过KeepAlive应答2通知客户端之前,主服务器具有租约M2;客户端能够扩展其对租约C2的视图。主服务器在回复下一个KeepAlive之前就已经死了,并且在选出另一个主服务器之前已经过了一段时间。最终,客户端对其租约(C2)的近似值到期。然后,客户端刷新其缓存并启动宽限期的计时器。
在此期间,客户端无法确定其租约是否已在主服务器上过期。它不会销毁其会话,但会阻止其API上的所有应用调用,以防止应用观察到不一致的数据。在宽限期开始时,Chubby库向应用发送危险事件,以允许其自行停止,直到可以确定其会话的状态。
最终一个新的主服务器选举成功。主服务器最初使用其前任可能为客户端提供的会话租约的保守近似M3。从客户端到新主服务器的第一个KeepAlive请求(4)被拒绝,因为它具有错误的主服务器时世代号(在下面详细描述)。重试的请求(6)成功了但通常不会进一步延长主服务器租约因为M3是保守的。但是,回复(7)允许客户端再次延长其租约(C3),并且可选地通知应用其会话不再处于危险之中。由于宽限期足以覆盖租约C2结束与租约C3开始之间的间隔,客户端只看到延迟。如果宽限期小于该间隔,则客户端将放弃会话并向应用报告失败。
一旦客户端联系到新主服务器,客户端库和主服务器就会合作,为应用提供没有发生故障的错觉。为了实现这一点,新的主服务器必须重建前一个主服务器所具有的内存状态的保守近似。它部分通过读取在磁盘上稳定存储的数据(通过正常的数据库复制协议进行复制),部分是通过从客户端获取状态,部分通过保守的假设来实现这一点。数据库记录每个会话、持有锁和临时文件。
一个新当选的主服务器运行:
- 它首先选择一个新的客户端世代号,客户端需要在每次调用时展示。主服务器拒绝来自使用较旧世代号的客户端的调用,并提供新的世代号。这确保新的主服务器不会响应发送给先前的主服务器的非常旧的数据包,即使是在同一台计算机上运行的数据包。
- 新的主服务器可以响应主服务器位置请求,但不会首先处理传入的会话相关的操作。
- 它为记录在数据库中的会话和锁构建内存数据结构。会话租约延长到前一个主服务器可能使用的最大值。
- 主服务器现在允许客户端执行KeepAlives,但没有其他与会话相关的操作。
- 它向每个会话发出故障恢复事件;这会导致客户端刷新缓存(因为它们可能错过失效),并警告应用可能已丢失其他事件。
- 主服务器等待直到每个会话确认故障恢复事件或让其会话到期。
- 主服务器允许所有操作继续运行。
- 如果客户端使用在故障恢复之前创建的句柄(根据句柄中的序列号的值确定),则主服务器将重新创建句柄的内存中表示并兑现该调用。如果关闭了这样的重新创建的句柄,则主服务器将其记录在内存中,以便在主服务世代中无法重新创建它;这可确保延迟或重复的网络数据包不会意外地重新创建关闭的句柄。有缺陷的客户端可以在未来的世代重新创建一个关闭的句柄,但鉴于客户端已经出现故障,这是无害的。
- 经过一段时间间隔(比如说一分钟),主服务器会删除没有打开文件句柄的临时文件。在故障恢复后,客户端应在此间隔期间刷新临时文件上的句柄。如果此类文件上的最后一个客户端在故障恢复期间丢失其会话,则此机制具有令人遗憾的影响,即临时文件可能不会立即消失。
读取者不会惊讶地发现,故障恢复代码的运行频率远远低于系统的其他部分,但它是有意思的漏洞的丰富来源。
2.10 数据库实现
Chubby的第一个版本使用Berkeley DB[20]的复制版本作为其数据库。Berkeley DB提供了将字节串键映射到任意字节的B树。我们安装了一个键值比较函数,它首先按路径名中的组件数进行排序;这允许节点将其路径名称作为键,同时保持兄弟节点在排序顺序中相邻。由于Chubby不使用基于路径的权限,因此数据库中的单个查找就足以进行每个文件访问。
Berkeley DB使用分布式共识协议在一组服务器间复制其数据库日志。一旦添加了主服务器租约,这与Chubby的设计相匹配,这使得实现变得简单。
虽然Berkeley DB的B-tree代码被广泛使用且成熟,但复制代码是最近添加的,并且使用者较少。软件维护人员必须优先考虑维护和改进其最受欢迎的产品功能。虽然Berkeley DB的维护人员解决了我们遇到的问题,但我们认为使用复制代码会使我们面临比我们希望冒的风险更大的风险。因此,我们编写了一个简单的数据库,使用类似于Birrell等人[2]的设计的预写日志和快照。如前所述,数据库日志使用分布式共识协议在副本之间分发。Chubby使用了Berkeley DB的一些功能,因此这种重写允许整个系统的显著简化;例如,虽然我们需要原子操作,但我们不需要一般性事务。
2.11 备份
每隔几个小时,每个Chubby单元的主服务器都会将其数据库的快照写入另一个建筑中的GFS文件服务器[7]。使用独立的建筑可确保备份在建筑损坏后仍然存在,并且备份不会在系统中引入循环依赖性;同一建筑中的GFS单元可能依赖于该Chubby单元来选择其主。
备份提供灾难恢复和初始化新替换副本的数据库的方法,而不会对正在使用的副本施加负载。
2.12 镜像
Chubby允许将文件集合从一个单元镜像到另一个单元。镜像很快,因为文件很小,如果添加、删除或修改文件,事件机制(第2.5节)会立即通知镜像代码。如果没有网络问题,在一秒钟之内,全世界数十个镜像都会发生变化。如果镜像无法访问,则在恢复连接之前它将保持不变。然后通过比较校验和来识别更新的文件。
镜像最常用于将配置文件复制到分布在世界各地的各个计算集群。一个名为global的特殊单元包含一个子树/ls/global/master
,它映射到每个其他Chubby单元中的子树/ls/cell/slave
。global单元是特殊的,因为它的五个副本位于世界上广泛分离的地区,因此几乎总是可以从大多数组织访问。
从global单元中镜像的文件包括Chubby自己的访问控制列表,Chubby单元和其他系统向监控服务公布其存在的各种文件,允许客户端定位大型数据集(如Bigtable单元)和对于其他系统的许多配置文件的指针。
3. 扩展机制
Chubby的客户端是独立进程,因此Chubby必须处理的客户端数量超出预期;我们已经看到90000个客户端直接与一个Chubby主服务器通信 - 远远超过所涉及的机器数量。因为每个单元只有一个主服务器,并且它的机器与客户端的机器相同,所以客户端可以大大超过主服务器。因此,最有效的扩展技术通过重要因素减少了与主服务器的通信。假设主服务器没有严重的性能错误,那么主服务器上的请求处理的微小改进几乎没有效果。我们使用几种方法:
- 我们可以创建任意数量的Chubby单元;客户端几乎总是使用附近的单元(与DNS一起使用)以避免依赖远程机器。我们的典型部署使用一个Chubby单元用于数千台机器的数据中心。
- 当主服务器处于高负载时,主服务器可以将租约时间从默认的12s增加到大约60s,因此需要处理更少的KeepAlive RPC。(KeepAlives是迄今为止主要的请求类型(见4.1),未能及时处理它们是过载服务器的典型故障模式;客户端对其他调用中的延迟变化很不敏感。)
- Chubby客户端缓存文件数据、元数据、缺少文件和打开句柄,以减少他们在服务器上进行的调用次数。
- 我们使用协议转换服务器将Chubby协议转换为不太复杂的协议,如DNS等。我们将在下面讨论其中一些。
在这里,我们描述了两种熟悉的机制,代理和分区,我们期望它将允许Chubby进一步扩展。我们尚未在生产中使用它们,但它们是已经设计,可能很快就会使用。我们目前没有必要考虑超过五倍的扩展:首先,人们希望放入数据中心或依赖单个服务实例的机器数量有限制。其次,因为我们为Chubby客户端和服务器使用类似的机器,所以增加每台机器的客户端数量的硬件改进也增加了每台服务器的容量。
3.1 代理
Chubby的协议可以通过将来自其他客户端的请求传递给Chubby单元的可信进程代理(在两端使用相同的协议)。代理可以通过处理KeepAlive和读取请求来减少服务器负载;它无法减少经过代理缓存的写入流量。但即使使用激进的客户端缓存,写入流量也只占Chubby正常工作负载的1%(见§4.1),因此代理可以显著增加客户端数量。如果代理处理Nproxy个客户端,则KeepAlive流量将减少Nproxy倍,可能是1万或更多。代理缓存最多可以将读取流量减少到平均共享读取量 - 大约10倍(§4.1)。但由于读取目前构成了Chubby负载的10%以下,因此节省KeepAlive流量是迄今为止更重要的功效。
代理为写入和首次读取增加了额外的RPC。人们可能期望代理使该单元暂时不可用频率至少变成以前的两倍,因为每个代理客户端依赖于可能失败的两台机器:其代理和Chubby主服务器。
机警的读者会注意到2.9节中描述的故障恢复策略对于代理来说并不理想。我们将在4.4节讨论这个问题。
3.2 分区
如2.3节所述,选择了Chubby的接口,以便可以在服务器之间对单元的名称空间进行分区。虽然我们还不需要它,但代码可以按目录对名称空间进行分区。如果启用,Chubby单元将由N个分区组成,每个分区都有一组副本和一个主服务器。目录D中的每个节点D/C
将存储在分区P(D/C) = hash(D) mod N
上。注意,D的元数据可以存储在不同的分区P(D) = hash(D0) mod N
,其中D0是D的父级。
分区旨在实现分区之间几乎没有通信的大型Chubby单元。虽然Chubby缺少硬链接、目录修改时间和跨目录重命名操作,但仍有一些操作需要跨分区通信:
- ACL本身就是文件,因此一个分区可能会使用另一个分区进行权限检查。但是,ACL文件很容易被缓存;只有Open()和Delete()调用需要ACL检查;并且大多数客户端不需要读取ACL的公共可访问文件。
- 删除目录时,可能需要进行跨分区调用以确保目录为空。
由于每个分区独立于其他分区处理大多数调用,因此我们希望该通信仅对性能或可用性产生适度影响。
除非分区数N很大,否则可以预期每个客户端将联系大多数分区。因此,分区会将任何给定分区上的读写流量减少N倍,但不一定会减少KeepAlive流量。如果Chubby需要处理更多客户端,我们的策略涉及代理和分区的组合。
4. 使用,惊喜和设计错误
4.1 使用和行为
下表给出了作为Chubby单元快照的统计数据;RPC速率是在十分钟期间看到的。这些数字是Google中单元的典型。
可以看出以下几点:
- 许多文件用于命名;见§4.3。
- 配置、访问控制和元数据文件(类似于文件系统的超级块)很常见。
- 消极缓存很重要。
- 平均有
230k/24k = 10
个客户端使用每个缓存文件。 - 很少有客户端持有锁,共享锁很少见;这与用于主选举和在副本之间分区数据的锁一致。
- RPC流量由KeepAlives会话主导;有少量读取(未命中缓存);写入或锁获取很少。
现在我们简要介绍一下我们单元中断的典型原因。如果我们(乐观地)假设一个单元是“在线”,只要它有一个愿意服务的主服务器,那么在我们的单元样本上,我们在几周的时间内记录了61次中断,总共相当于700个单元日的数据。我们排除因关闭数据中心的维护而导致的中断。所有其他原因包括:网络拥塞、维护、过载以及运营人员、软件和硬件引起的错误。大多数中断时间为15秒或更少,52次未满30秒;我们的大多数应用不会受到少于30s的Chubby中断的严重影响。其余9次中断是由网络维护(4)、怀疑的网络连接问题(2)、软件错误(2)和过载(1)引起的。
由于数据库软件错误(4)和操作员错误(2),在几十单元年的运行时间内,我们丢失了六次数据;没有涉及硬件错误。具有讽刺意味的是,操作错误涉及以避免软件错误的升级。我们已经纠正了非主副本中由软件引起的两次损坏。
Chubby的数据适合存储在RAM中,因此大多数操作都很便捷。我们的生产服务器的平均请求延迟始终远远低于一毫秒,无论单元负载如何,直到单元接近过载,此时延迟大幅增加且会话被丢弃。当许多会话(> 90000)处于活跃状态时,通常会发生过载,但可能是由异常情况引起的:当客户端同时发出数百万个读取请求时(在第4.3节中描述),以及当客户端库中的错误禁用某些读取的缓存时 ,每秒产生数万个请求。由于大多数RPC都是KeepAlive,因此服务器可以通过增加会话租约期(请参阅§3)来维持与许多活跃客户端的低平均请求延迟。当突发写入到达时,分组提交减少了每个请求完成的有效工作,但这种情况很少见。
在客户端测量的RPC读取延迟受RPC系统和网络的限制;对于本地单元,它们不到1毫秒,而对相对极之间则为250毫秒。写入(包括锁操作)由于数据库日志更新延迟进一步增大了5-10ms,但如果最近操作失败的客户端缓存该文件,则最多延迟数十秒。即使写入延迟的这种可变性对服务器上的平均请求延迟几乎没有影响,因为写入很少发生。
如果不删除会话,客户端对延迟变化相当不敏感。我们一度在Open()中添加了人为延迟来遏制滥用客户端(见§4.5);开发者只有当延迟超过十秒并且反复出现时才注意到。我们发现扩展Chubby的关键不是服务器性能;减少与服务器的通信可能会有更大的影响。我们检查了没有出现任何令人震惊的错误,然后专注于可能更有效的扩展机制。另一方面,开发者会注意到如果性能错误可能会影响到本地Chubby缓存(客户端可能每秒读取数千次)。
4.2 Java客户端
谷歌的基础组件主要是用C ++编写的,但越来越多的系统都是用Java编写的[8]。这种趋势给Chubby带来了意想不到的问题,Chubby有一个复杂的客户端协议和一个不平凡的客户端库。
Java鼓励整个应用的可移植性,代价是通过使其有点令人厌烦地与其他语言链接而牺牲增量使用。访问非原生库的常用Java机制是JNI[15],但它被认为是缓慢而繁琐的。我们的Java程序员不喜欢JNI,为了避免使用它们,他们更喜欢将大型库转换为Java,并承诺支持它们。
Chubby的C ++客户端库是7000行(与服务器相当),客户端协议很精巧。维护Java维护库需要小心和代价,而且没有高速缓存的实现会给Chubby服务器带来负担。因此,我们的Java用户运行协议转换服务器的副本,其导出与Chubby的客户端API紧密对应的简单RPC协议。事后看来,我们如何避免编写、运行和维护这个额外服务器的成本并不明显。
4.3 用作名称服务
尽管Chubby被设计为锁服务,但我们发现其最受欢迎的用途是作为名称服务器。
在正常的Internet命名系统(DNS)中的缓存是基于时间。DNS条目具有生存时间(TTL),并且DNS数据在该时间段内未刷新时将被丢弃。通常可以直接选择合适的TTL值,但如果需要及时替换失败的服务,TTL可能会变得足够小从而使DNS服务器过载。
例如,我们的开发人员通常会运行涉及数千个进程的作业,并且每个进程都可以相互通信,从而导致二次查询次数增加。我们可能希望使用60s的TTL;这将允许在没有过度延迟的情况下更换出现问题的客户端,并且在我们的环境中不被认为是不合理的短暂替换时间。在这种情况下,要维护只有3千个客户端的单个作业的DNS缓存,每秒需要15万次查找。(相比之下,2-CPU 2.6GHz Xeon DNS服务器每秒可处理5万个请求。)较大的作业会产生更严重的问题,并且有多个作业会同时运行。在引入Chubby之前,我们的DNS负载的可变性对Google来说是一个严重的问题。
相比之下,Chubby的缓存使用显式失效,因此在没有更改的情况下,固定速率的KeepAlive会话请求可以无限期地在客户端维护任意数量的缓存条目。已经看到一个2核CPU 2.6GHz Xeon的Chubby主服务器可以处理直接与之通信的9万个客户端(没有代理);客户端包括具有上述类型通信模式的大型作业。提供快速名称更新而无需单独轮询每个名称的能力非常吸引人,以至于Chubby现在为公司的大多数系统提供名称服务。
尽管Chubby的缓存允许单个单元支持大量客户端,但负载峰值仍然是一个问题。当我们第一次部署基于Chubby的名称服务时,启动一个3千个进程作业(从而产生900万个请求)可能会使Chubby主服务器屈服。为解决此问题,我们选择将名称条目分组到批中,以便单个查找将返回并缓存作业中大量(通常为100个)相关进程的名称映射。
Chubby提供的缓存语义比名称服务所需的更精确;名称解析只需要及时通知而不是完全一致。因此,通过引入专为名称查找而设计的简单协议转换服务器,有机会减少Chubby的负载。如果我们预见到使用Chubby作为名称服务,我们可能会选择更快地实现完整代理,以避免需要这个简单但仍然需要的额外的服务器。
存在另一个协议转换服务器:Chubby DNS服务器。这使得存储在Chubby中的命名数据可供DNS客户端使用。此服务器对于简化从DNS名称到Chubby名称的转换以及适应无法轻松转换的现有应用(如浏览器)非常重要。
4.4 故障恢复问题
主服务器故障恢复的原始设计(第2.9节)要求主服务器在创建数据库时将新会话写入数据库。在锁服务器的Berkeley DB版本中,当一次启动许多进程时,创建会话的开销成为问题。为了避免过载,服务器被修改为在数据库中存储会话不是在第一次创建会话时,而是在尝试第一次修改、锁获取或打开临时文件时。此外,活跃会话在每个KeepAlive上以一定概率记录在数据库中。因此,只读会话的写入在时间上分散了。
尽管有必要避免过载,但是这种优化具有不期望的效果,即年轻的只读会话可能不会记录在数据库中,因此如果发生故障恢复则可能被丢弃。虽然这些会话没有持有锁,但这是不安全的;如果所有记录的会话都在丢弃的会话租约到期之前接入新的主服务器,则丢弃的会话会读取陈旧的数据一段时间。这在实践中很少见;在大型系统中,几乎可以肯定某些会话将无法登记,从而迫使新主服务器等待最长的租约时间。尽管如此,我们已经修改了故障恢复设计,以避免这种影响,并避免当前方案引入代理的复杂性。
在新设计下,我们完全避免在数据库中记录会话,而是以与主服务器当前重新创建句柄相同的方式重新创建它们(§2.9,¶8)。在允许操作继续之前,新的主服务器现在必须等待完整的最坏情况租约超时,因为它无法知道是否所有会话都已登记(§2.9,¶6)。同样,这在实践中几乎没有影响,因为很可能并非所有会话都会登记。
一旦可以在没有固化磁盘状态的情况下重新创建会话,代理服务器就可以管理主服务器不知道的会话。仅对代理可用的额外操作允许它们更改与锁相关联的会话。这允许一个代理在代理失败时从另一个代理接管客户端。主服务器所需的唯一进一步更改是保证不会放弃与代理会话关联的锁或临时文件句柄,直到新代理有机会声明它们为止。
4.5 滥用客户端
Google的项目团队可以自由建立他们自己的Chubby单元,但这样做会增加他们的维护负担,并消耗额外的硬件资源。因此,许多服务使用共享的Chubby单元,这使得将客户端与其他的不当行为隔离开来很重要。Chubby旨在在一家公司内运营,因此针对它的恶意拒绝服务攻击很少见。但是,错误、误解以及我们开发人员的不同期望会产生类似于攻击的效果。
我们的一些补救措施是严厉的。例如,我们审查项目团队计划使用Chubby的方式,并拒绝访问共享的Chubby名称空间,直到审核满意为止。这种方法的一个问题是,开发人员往往无法预测将来如何使用他们的服务,以及使用如何增长。读者会注意到我们自己未能预测如何使用Chubby本身的讽刺意味。
我们审查的最重要方面是确定是否使用任何Chubby资源(RPC速率,磁盘空间,文件数)与用户数量或项目处理的数据量是否呈线性增长(或更差)。必须通过补偿参数来缓解任何线性增长,该补偿参数可以被调整以将Chubby上的负载减少到合理的界限。然而,我们的早期审核还不够彻底。
一个相关的问题是大多数软件文档中缺乏性能建议。一个团队编写的模块可能会在一年之后由另一个团队复用,并带来灾难性的后果。有时很难向接口设计者解释他们必须改变他们的接口不是因为它们不好,而是因为其他开发人员可能不太了解RPC的成本。
下面列出我们遇到的一些问题。
4.5.1 缺乏积极的缓存
最初,我们并不意识到缓存文件缺失的迫切需要,也不重用打开文件句柄。尽管尝试了教育,我们的开发人员经常编写循环,当文件不存在时无限期地重试,或者在人们可能期望他们只打开文件一次时重复打开关闭文件来轮询文件。
起初,当应用程序在短时间内多次尝试Open()相同文件时,我们通过引入指数增加的延迟来对抗这些重试循环。在某些情况下,开发人员承认这些暴露的错误,但通常需要我们花更多的时间在教育上。最后,使重复的Open()调用更廉价更加容易。
4.5.2 缺少配额
Chubby从未打算用作大量数据的存储系统,因此它没有存储配额。 事后来看,这是天真的。
Google的一个项目编写了一个模块来跟踪数据上传,在Chubby中存储一些元数据。这种上传很少发生,仅限于一小部分人,因此空间有限。但是,另外两项服务开始使用相同的模块作为跟踪来自更广泛用户群的上传的手段。不可避免地,这些服务一直在增长,直到Chubby的使用极限:在每个用户操作上整个重写了一个1.5MByte的文件,并且该服务使用的整体空间超出了所有其他Chubby客户端的空间需求。
我们引入了文件大小限制(256kBytes),并鼓励服务迁移到更合适的存储系统。但是很难对繁忙的人们维护的生产系统进行重大改变 - 数据迁移到其他地方大约花费了一年的时间。
4.5.3 发布/订阅
已经有几次尝试使用Chubby的事件机制作为Zephyr[6]风格的发布/订阅系统。Chubby的重要的保证及其使用失效而不是更新来维护缓存一致性使得它对于除了最简单的发布/订阅示例之外的所有操作都是缓慢且低效的。幸运的是,在重新设计应用的成本太大之前,已经获得了所有这些用途。
4.6 经验教训
在这里,我们列出的经验教训,以及各方面的设计变更,如果我们有机会,我们会做:
4.6.1 开发人员很少考虑可用性
我们发现我们的开发人员很少考虑失败可能性,并且倾向于将像Chubby这样的服务视为始终可用。例如,我们的开发人员曾经构建了一个系统,该系统使用了数百台机器,当Chubby选出新的主服务器时,这些机器启动恢复程序需要几十分钟。这使得单个故障的后果在时间和受影响的机器数量上放大了一百倍。我们希望开发人员规划短暂的Chubby中断,以便这样的事件对他们的应用几乎没有影响。这是第2.1节中讨论的粗粒度锁的参数之一。
开发人员也无法理解正在运行的服务与其应用可用的服务之间的区别。例如,全局Chubby单元(参见§2.12)几乎总是运行的,因为两个以上地理位置较远的数据中心很少同时发生故障。但是,给定客户端观察到的可用性通常低于客户端本地Chubby单元的观察到的可用性。首先,本地单元不太可能与客户端分区,其次,虽然本地单元可能由于维护而经常停机,但是相同的维护会直接影响客户端,因此客户端不会观察到Chubby的不可用性。
我们的API选择也会影响开发人员选择处理Chubby中断的方式。例如,Chubby提供了一个事件,允许客户端检测何时发生主服务器故障恢复。目的是让客户检查可能的更改,因为其他事件可能已丢失。不幸的是,许多开发人员选择在接收此事件时让应用崩溃,从而大大降低了系统的可用性。相较来说,我们发送冗余的“文件更改”事件可能更好,甚至保证在故障恢复期间不丢失任何事件。
目前,我们使用三种机制来防止开发人员对Chubby可用性过度乐观,尤其是全局单元的可用性。首先,正如前面提到的(§4.5),我们回顾了项目团队计划如何使用Chubby,并建议他们抛弃将其可用性与Chubby的关系过于紧密的技术。其次,我们现在提供执行某些高级任务的库,以便开发人员自动与Chubby中断隔离。第三,我们使用每个Chubby中断的事后反思作为一种手段,不仅可以消除Chubby和我们的操作程序中的错误,还可以降低应用对Chubby可用性的敏感性 - 两者都可以提高我们系统的整体可用性。
4.6.2 可以忽略细粒度锁
在2.1节结束时,我们为客户端运行以提供细粒度锁的服务器勾画了一个设计。到目前为止,我们还不需要编写这样的服务器,这可能是一个惊喜;我们的开发人员通常发现要优化他们的应用,他们必须删除不必要的通信,这通常意味着找到一种使用粗粒度锁的方法。
4.6.3 API选择不佳会产生意外影响
在大多数情况下,我们的API发展良好,但有一个错误突出。我们取消长时间运行的调用的方法是Close()和Poison() RPC,它们也会丢弃句柄的服务器状态。这防止了可以获取锁的句柄被共享,例如,由多个线程共享。我们可以添加一个Cancel() RPC来允许更多的开放句柄共享。
4.6.4 RPC使用会影响传输协议
KeepAlives既用于刷新客户端的会话租约,也用于将事件和缓存失效从主服务器传递到客户端。此设计具有自动且理想的效果,即客户端无法在不确认缓存失效的情况下刷新其会话租约。
这似乎是理想的,除了它在我们选择的协议中引入了一个担忧。TCP的退避策略不关注较高层次的超时,例如Chubby租约,因此基于TCP的KeepAlives在高网络拥塞时导致许多会话丢失。我们被迫通过UDP而不是TCP发送KeepAlive RPC;UDP没有拥塞避免机制,所以我们更愿意只在必须满足高级时间边界时才使用UDP。
我们可以使用额外的基于TCP的GetEvent() RPC来扩充协议,该RPC将用于在正常情况下传递事件和失效,以与KeepAlives相同的方式使用。KeepAlive回复仍将包含未确认事件的列表,以便事件最终被确认。
5. 与相关工作的比较
Chubby基于由来已久的想法。Chubby的缓存设计源自分布式文件系统[10]。它的会话和缓存令牌在行为上类似于Echo[17];会话减少了V系统中租约[9]的开销。暴露通用锁服务的想法可以在VMS[23]中找到,尽管该系统最初使用了允许低延迟交互的专用高速互连网络。与其缓存模型一样,Chubby的API基于文件系统模型,包括类似文件系统的名称空间不仅仅对文件非常方便[18,21,22]。
Chubby与分布式文件系统(例如Echo或AFS[10])的性能和存储期望不同:客户端不会读取、写入或存储大量数据,并且他们不期望高吞吐量甚至低延迟,除非数据被缓存。他们确实都期望一致性、可用性和可靠性,但是当性能不那么重要时,这些特性更容易实现。由于Chubby的数据库很小,我们可以在线存储它的许多副本(通常是五个副本和一些备份)。我们每天多次进行完整备份,并通过数据库状态的校验和,我们每隔几个小时进行一次副本互相校验。普通文件系统性能和存储要求的弱化使我们能够从单个Chubby主服务器中为数万个客户端提供服务。通过提供许多客户端可以共享信息和协调活动的中心点,我们解决了系统开发人员面临的一类问题。
文献中描述的大量文件系统和锁服务阻碍了详尽的比较,因此我们提供了其中一个的详细信息:我们选择与Boxwood的锁服务[16]进行比较,因为它是最近设计的,它也设计为在松散耦合的环境中运行,但其设计在各方面与Chubby有所不同,有些是令人关注的,有些是偶然的。
Chubby在单个服务中实现了锁、可靠的小文件存储系统和会话/租约机制。相比之下,Boxwood将这些分为三个:锁服务、Paxos服务(状态的可靠存储库)和故障检测服务。Boxwood系统本身将这三个组件一起使用,但另一个系统可以独立使用这些组件。我们怀疑这种设计差异源于目标受众的差异。Chubby旨在为不同的受众和应用组合;其用户范围从创建新分布式系统的专家到编写管理脚本的新手。对于我们的环境,使用熟悉的API的大规模共享服务似乎很有吸引力。相比之下,Boxwood提供了一个工具包(至少对我们来说)适合于少数更复杂的开发人员,这些开发人员可以共享代码但不需要一起使用的项目。
在许多情况下,Chubby提供了比Boxwood更高层次的接口。例如,Chubby结合了锁和文件名称空间,而Boxwood的锁名称是简单的字节序列。Chubby客户端默认缓存文件状态;Boxwood的Paxos服务的客户端可以通过锁服务实现缓存,但可能会使用Boxwood本身提供的缓存。
这两个系统具有明显不同的默认参数,根据不同的期望而选择:每个客户端每隔200ms联系每个Boxwood故障检测器,超时为1秒; Chubby的默认租约时间为12秒,KeepAlives每7秒交换一次。Boxwood的子组件使用两个或三个副本来实现可用性,而我们通常每个单元使用五个副本。但是,这些选择本身并不表示深层设计差异,而是表明必须如何调整此类系统中的参数以适应更多客户端机器,或与其他项目共享机架的不确定性。
一个更有趣的区别是Boxwood缺乏Chubby宽期限的引入。(回想一下,宽期限允许客户端在不丢失会话或锁的情况下渡过长长的Chubby主服务器中断。Boxwood的“宽限期”相当于Chubby的“会话租约”,这是一个不同的概念。)同样,这种差异是对两个系统中的规模和失败概率的期望不同的结果。虽然主服务器的故障恢复很少见,但丢失的Chubby锁对于客户端来说是成本高昂的。
最后,两个系统中的锁用于不同目的。Chubby锁是重量级的,需要序列生成器才能安全地保护外部资源,而Boxwood锁是轻量的,主要用于Boxwood内部。
6. 总结
Chubby是一种分布式锁服务,旨在实现Google分布式系统中活动的粗粒度同步;它已被广泛用作配置信息的名称服务和存储库。
它的设计基于众所周知的优点:在容错的几个副本之间的分布式共识、一致的客户端缓存以减少服务器负载、同时保留简单的语义、及时通知更新、以及熟悉的文件系统接口。我们使用缓存、协议转换服务器和简单的负载适配,以允许每个Chubby实例扩展到数万个客户端进程。我们希望通过代理和分区进一步扩展它。
Chubby已成为Google的主要内部名称服务;它是MapReduce[4]等系统的常见交流机制;存储系统GFS和Bigtable使用Chubby从冗余副本中选择主;它是需要高可用性的文件的标准存储库,例如访问控制列表。
7. 致谢
许多人为Chubby系统做出了贡献:Sharon Perl在Berkeley DB上编写了复制层;Tushar Chandra和Robert Griesemer编写了复制的数据库,取代了Berkeley DB;Ramsey Haddad将API和Google的文件系统接口结合起来;Dave Presotto,Sean Owen,Doug Zongker和Praveen Tamara编写了Chubby DNS、Java和命名协议转换器以及完整的Chubby代理;Vadim Furman添加了打开句柄和文件缺失的缓存;Rob Pike,Sean Quinlan和Sanjay Ghemawat提供了宝贵的设计建议;许多谷歌开发者发现了早期的缺陷。
参考文献
- BIRMAN, K. P., AND JOSEPH, T. A. Exploiting virtual synchrony in distributed systems. In 11th SOSP (1987), pp. 123–138.
- BIRRELL, A., JONES, M. B., AND WOBBER, E. A simple and efficient implementation for small databases. In 11th SOSP (1987), pp. 149–154.
- CHANG, F., DEAN, J., GHEMAWAT, S., HSIEH, W. C., WALLACH, D. A., BURROWS, M., CHANDRA, T., FIKES, A., AND GRUBER, R. Bigtable: A distributed structured data storage system. In 7th OSDI (2006).
- DEAN, J., AND GHEMAWAT, S. MapReduce: Simplified data processing on large clusters. In 6th OSDI (2004), pp. 137–150.
- FISCHER, M. J., LYNCH, N. A., AND PATERSON, M. S. Impossibility of distributed consensus with one faulty process. J. ACM 32, 2 (April 1985), 374–382.
- FRENCH, R. S., AND KOHL, J. T. The Zephyr Programmer’s Manual. MIT Project Athena, Apr. 1989.
- GHEMAWAT, S., GOBIOFF, H., AND LEUNG, S.-T. The Google file system. In 19th SOSP (Dec. 2003), pp. 29–43.
- GOSLING, J., JOY, B., STEELE, G., AND BRACHA, G. Java Language Spec. (2nd Ed.). Addison-Wesley, 2000.
- GRAY, C. G., AND CHERITON, D. R. Leases: An efficient fault-tolerant mechanism for distributed file cache consistency. In 12th SOSP (1989), pp. 202–210.
- HOWARD, J., KAZAR, M., MENEES, S., NICHOLS, D., SATYANARAYANAN, M., SIDEBOTHAM, R., AND WEST, M. Scale and performance in a distributed file system. ACM TOCS 6, 1 (Feb. 1988), 51–81.
- JEFFERSON, D. Virtual time. ACM TOPLAS, 3 (1985), 404–425.
- LAMPORT, L. The part-time parliament. ACM TOCS 16, 2 (1998), 133–169.
- LAMPORT, L. Paxos made simple. ACM SIGACT News 32, 4 (2001), 18–25.
- LAMPSON, B. W. How to build a highly available system using consensus. In Distributed Algorithms, vol. 1151 of LNCS. Springer–Verlag, 1996, pp. 1–17.
- LIANG, S. Java Native Interface: Programmer’s Guide and Reference. Addison-Wesley, 1999.
- MACCORMICK, J., MURPHY, N., NAJORK, M., THEKKATH, C. A., AND ZHOU, L. Boxwood: Abstractions as the foundation for storage infrastructure. In 6th OSDI (2004), pp. 105–120.
- MANN, T., BIRRELL, A., HISGEN, A., JERIAN, C., AND SWART, G. A coherent distributed file cache with directory write-behind. TOCS 12, 2 (1994), 123–164.
- MCJONES, P., AND SWART, G. Evolving the UNIX system interface to support multithreaded programs. Tech. Rep. 21, DEC SRC, 1987.
- OKI, B., AND LISKOV, B. Viewstamped replication: A general primary copy method to support highly-available distributed systems. In ACM PODC (1988).
- OLSON, M. A., BOSTIC, K., AND SELTZER, M. Berkeley DB. In USENIX (June 1999), pp. 183–192.
- PIKE, R., PRESOTTO, D. L., DORWARD, S., FLANDRENA, B., THOMPSON, K., TRICKEY, H., AND WINTERBOTTOM, P. Plan 9 from Bell Labs. Computing Systems 8, 2 (1995), 221–254.
- RITCHIE, D. M., AND THOMPSON, K. The UNIX timesharing system. CACM 17, 7 (1974), 365–375.
- SNAMAN, JR., W. E., AND THIEL, D. W. The VAX/VMS distributed lock manager. Digital Technical Journal 1, 5 (Sept. 1987), 29–44.
- YIN, J., MARTIN, J.-P., VENKATARAMANI, A., ALVISI, L., AND DAHLIN, M. Separating agreement from execution for byzantine fault tolerant services. In 19th SOSP (2003), pp. 253–267.