引言
这篇文章是在看了Chubby的论文以后将其上我认为的重点单独摘出来,然后加上我自己的一些理解,旨在希望想要了解Chubby的朋友对其能够有一个初步的认识,首先要确定的是我们学习这个东西所要学习的是设计思路,是对一些问题的解决方法,而不是如何使用,因为这东西是谷歌自研的,闭源。这也就是为什么平时AP的分布式锁的实现都使用ZooKeeper而不是这个经典的Chubby了。
显然这个东西熟悉它的人并不多,这并不奇怪,因为它并不是一个直接提供给程序员使用的一个接口,而作为诸如MapReduce这样的内部实现,而且其是一个闭源的项目。而论文中确有很多实现上的细节解释,这也导致论文中很多提到的点不是亲自实现的话很难有很深的见解,但是作为一个如此详细的分布式锁(好像也用作命名服务器name service)的实现讲解仍能够使我们学习到很多知识。
设计目标
首先分布式锁是什么我们不在多说,我们需要知道的是Chubby为我们提供了一个高可靠的分布式锁服务,而且这个锁是建议性的,那么什么是建议性锁呢?就是系统只提供加锁及检测是否加锁的接口,系统本身不会参与锁的协调和控制,也就是说虽然上锁,但是未得到锁的用户仍可访问资源。而且因为Chubby用于MapReduce,BigTable的leader的选举,这里并不适合使用细粒度的锁(持有锁几毫秒),所以Chubby使用粗粒度的锁(当然对于服务器的负载也比较低),当然这在实现上是有区别的,粗粒度的锁在服务器宕机后需要Chubby保存状态,而细粒度的锁只需释放资源即可。总结一下就是以下几点:
- 提供一个完整的分布式锁服务而不是一个一致性的客户端库。
- 提供粗粒度的锁服务。
- 高可用,高可靠。
- 提供小文件的读写功能,也是为了增强性能,后面会说。
- 提供事件通知机制(修改后同步客户端缓存)。
论文中提到了一点,也就是上面我们所说的第一点,Chubby是一个分布式的锁服务,但是开始的时候有人觉得应该设计一个支持Paxos的协议库,然而实现一个锁服务相比于前者有以下优点:
- 上层调用更为方便,只需要几个语句即可。如果是一个协议库的话客户端要做的事情太多。
- 基于锁的接口对于开发者来说更为熟悉。
- 对于客户端来说可以构建一个更为可靠的服务,因为一般的一致性算法都使用Quorum机制,也就是说在2f+1个节点中最多只能容忍f个节点的失效,但是如果是一个锁服务的话,这意味着Chubby允许客户端系统在其自身成员存活数小于半数时仍可以正确地做出决策(如果客户端使用Paxos算法就没办法在f个节点失效时进行决策了)。
系统结构
Chubby分为两个组件,一个是在客户端的chubby library,一个是Chubby cell,也就是服务端,它们之间通过RPC通信。
我们首先来看看Chubby cell部分,这其实就是一个服务器急群,其中一般为五个服务器,这里面会有一个master,剩下的都是从服务器,负责从master同步信息。第一个问题就是客户端怎么知道哪一个是master?答案就是客户端通过向DNS中列出的各副本发送master定位请求来找到master。非master副本通过返回master标识符来响应这种请求。一旦客户端定位到master,它就会将自己的所有请求直接发送给master。这样的话其实更改是比较简单的,我们只需要去更新DNS中的宕机的服务器节点为一个新的节点即可,然后这个新的副本会从存储在文件服务器上一组备份中选择一个数据库的最近的拷贝,同时从活动的那些副本中获取更新。这里如何保证一致性呢?其实这种有master的节点保证一致性很容易,我们只需要使写请求会通过一致性协议传送给所有副本,当写请求被一个Chubby单元中半数以上的副本收到后就认为已经写入成功。这种半同步的方式相比与同步可以使得效率提升。重要的是读请求也只能通过master来处理{这里我是有疑惑的,为什么从节点不能读呢?是因为担心前面半同步造成的一致性问题吗?}
逻辑结构:目录与文件
Chubby对外提供的接口可Linux的文件系统非常类似,它由一系列文件和目录所组成的严格树状结构组成,不同的名字单元之间通过反斜杠分割。
比如如下路径
/ls/foo/wombat/pouch
ls是所有节点的共有前缀,代表lock service,foo是Chubby集群的名字,剩下的路径就代表了一个真正的一个业务节点。当然这个概念只是为了让我们程序员更好的理解,这并不是一个严格的文件系统,比如不支持相对路径,软连接(符号连接),硬链接这样的概念,且为允许不同目录下的文件由不同的Chubby master负责,也禁止了文件和目录的mv操作。我们把一个目录和文件的组合称为一个node,且node又分为临时的和永久的,其中包含着一些元数据,其中就有着锁信息,这样的话就可以通过查看文件信息去更改客户端的缓存了。
锁与sequencer
这里其实是一个比较有意思的地方,我们知道分布式锁可能会出现这样一个棘手的问题,客户端A请求锁,然后得到锁,它发出一个写操作,但是这个操作因为网络原因变得无比缓慢,以至于服务器认为它死掉了,然后把锁分配给了客户端B,这个时候客户端A丢失的操作回来了,使得出现数据不一致的情况。那么如何解决这个问题呢?答案就是使用fencing令牌
,DDIA上称这个策略为fencing令牌,而论文中称为sequencer,也就是序列器,它的原理是什么呢?就是给每个获取的锁分配一个单调递增的序号,在一些需要锁保护的操作的时候带上这个序号(需要客户端支持,这也是架构图需要客户端的原因之一),如果出现上面那种情况就会检测到这个请求的序号小于当前最新的锁序号,从而抛弃这个请求。这样看来fencing令牌已经完美的解决了这个问题,但是其实还有另一种策略来防止这个问题,即锁延迟(lock-delay)
,为什么呢?个人认为是一种补丁,sequencer应该是一个后来加上的策略,使用第二种策略的原因是担心有些有些老的servers不支持这种机制,而且由于成本或者维护者的原因,即使Chubby系统已经具有了sequencer的支持,因为这些servers无法更新,因此也就无法使用它。所以在宕机后一段时间不允许再次获取锁,也可以在一定程度解决这个问题。
事件与缓存
这其实是两个主题,但把它们放在一起的原因是它们都减少了客户端和服务器之间的通信。这其实是非常值得学习的一点,这给予了我们一种启发,即:考虑可扩展时,减少通信次数有时候比优化单次请求处理速度更有效。首先我们说说缓存,我们在客户端中对文件内容(元数据)进行存储,这显然减少了客户端和服务器通信的次数,但是随之而来的问题就是缓存一致性。这里我们需要清楚一个概念,即Chubby缓存的一致性是由master的失效通知来保证的,也就是当Chubby收到一个写请求的时候,服务器首先阻塞这个操作,然后向所有可能存储了该数据的客户端发送缓存失效信息,当收到所有的应答以后继续修改操作。因为master会将那些对缓存失效状态还无法确认的node(这个是指Chubby里的node,而不是网络节点)看做是不可缓存的。这种策略允许读操作总是无延时的得到处理,这点非常有用,因为读操作要远远多于写。另外一种选择是:在失效通知期间阻塞所有访问该node的调用,这可以避免在失效通知期间过度急切的客户端对master造成轰炸式的访问,但是代价是增加了延迟。如果这会成为问题,可以尝试采用一种混合模式,在检测到过载时切换处理方式。这里隐藏了一个通用的处理方式,即责任分散,前面我们没有选择使master去更新所有的client cache,而是简单的使cache无效,这样更新的职责就分配到了所有的客户端上,这让我想起了Redis的渐进式hash,同样是把一个耗费大量时间的操作分为了多个不那么耗时的操作。
总结
这篇文章仅仅是介绍了Chubby最基础部分,对于Chubby有兴趣的朋友可以去参考论文[1],这确实最为清楚的讲解了,正如开头所说,我们得清楚学习Chubby是在学什么,我们学习的是它的设计思路,它解决问题的思路,而不是这是一个什么,怎么使用,毕竟这是闭源的,可能绝大多数人这辈子也用不上了。
参考:
- 博文《Chubby的锁服务》
- 博文《Google利器之Chubby》
- 论文 [1]《The Chubby lock service for loosely-coupled distributed systems》
- 书籍《从Paxos到ZooKeeper》