《The Chubby lock service for loosely-coupled distributed systems》论文阅读
参考链接如下
https://catkang.github.io/2017/09/29/chubby.html
https://zhuanlan.zhihu.com/p/64554506
设计目的
- 提供粗粒度的分布式锁,比如leader选举、服务发现。
- 提供小数据的可靠存储
- 重点关注可靠性、一致性、扩展性而不是性能,一致性依靠paxos解决。
- 提供简单的语义
Paxos库 VS 分布式锁服务
- 一个重点的设计考虑是到底应该提供一个paxos库来为client解决分布式锁的一致性问题,还是直接提供一个高可用的分布式锁服务。
- 大多数服务在刚开始写的时候并没有考虑高可用,当服务的规模越来越大时,程序员才会考虑高可用。等服务改造的时候,直接调用分布式锁服务会比改造为使用一个一致性协议要简单。
- 许多服务在选主或在许多组件之间划分数据的时候有广播结果的需求,所以应该给client提供一个存储和获取少量数据的服务。这个可以通过一个名字服务来完成,但是锁服务本身就可以完成这个任务,并且还可以复用一致性协议的代码,因此Chubby提供了存储小数据的能力。
- 当然对程序员来说,锁比paxos状态机要简单多了,然而大多数程序员对分布式锁的理解都有问题,很少人会考虑到异步通信环境下的机器失败的问题
- 分布式一致性协议依靠quorum来做决定,使用多个副本来保证高可用,比如Chubby很多时候使用5副本。如果Client使用Chubby的话,即使它只有一台机器存活,也可以安全的使用锁,也就是client本身不需要做到多台机以及quorum,一致性由chubby来提供。而如果Client使用一个一致性的库的话,它自己就得有多台机器,并且保证大多数存活才能继续。
细粒度锁 VS 粗粒度锁
- 细粒度锁指的是只会持有锁很短的时间(秒级或更短),而粗粒度锁指的是持有比较长的时间,比如说是用于选主,通常都会是几小时甚至几天。这两种粒度的锁对锁服务提出了不同的要求。
- 粗粒度的锁因为隔很长的时间才需要访问锁服务一次,所以对server端的负载压力很小,并且这个负载跟client端的处理速率关联很小(意思是即使client端每秒处理很多请求,锁服务的server端收到的请求速率也不会明显增加)。另外,锁服务server端的机器故障对client的影响也比较小。
- 相对的,细粒度的锁就完全相反了,server端的失败可能造成很多client阻塞。性能和扩容的能力都很重要,因为server端的负载和client的处理速率密切相关。
- Chubby只试图提供粗粒度的锁,但是client端可以利用Chubby很容易的实现自己的细粒度的锁。一个应用程序可以把自己的细粒度的锁进行分组,然后利用Chubby把这些细粒度锁的组分配到应用自己的一些服务器上。这样做最大的好处是,client端对支撑自己的负载应该需要多少服务器负责了,并且不用自己去实现一致性协议的部分。
系统结构
- Chubby这里是使用paxos协议来保证一致性的,通常使用5个副本,读写请求都由master来完成。
- 如果一个副本挂了,并且几个小时都没有恢复,一个另外的替换系统就会选一台新机器,启动lock server的二进制,然后更新DNS table。
- master会定期从DNS table拉数据,就会得知这个变化,然后在数据库中更新cell成员的列表,这个列表也是通过普通的一致性协议在副本间维护一致性的。
- 同时,这个新的机器会从一个文件服务器和其他的活跃副本那里拉一个当前数据库的备份,等这个机器成功处理了master的一个等待commit的请求后(说明此时数据已经是最新了),这个机器就可以参与投票了。
文件、目录和handles
- Chubby的命名空间和Unix的文件系统基本一样,这个有个好处是Chubby内的文件即可以被Chubby自己的API访问,也可以被其他的文件系统比如GFS的API访问,这可以复用很多工具的代码。
- 有临时和永久的节点,所有节点都可以被显式的删除,临时节点当没有client打开它们时就会被自动删除,所以临时的节点可以被用来检测client是否存活。
- 权限控制:每个节点有3个ACLs名字来控制读、写、更改ACLs的权限。ACLs本身就是一个文件,放在一个ACL目录里。这些ACLs文件由简单的名字列表组成,比如,文件F的写名字是foo,ACL目录下有一个文件的名字就是foo,这个文件里有一个叫bar的项,所以用户bar就被允许写文件F。因为Chubby的ACLs是简单的文件,所以可以被其它想用类似机制的服务所复用。
- handle,和Unix文件描述符类似,包括三个组成部分:
- 检查位: 防止client伪造handles,因此完整的访问控制检查只需要在创建handle的时候做。
- 序列号:让master可以知道这个handle是不是旧的master产生的。
- 模式信息: 在创建时提供,可以在一个master接收到旧的master创建的handle时重建这个handle的状态。
强制锁 VS 建议锁
- 强制锁指的是当client没有持有锁时资源就不可用了,而建议锁指的是只有其它client想要持有同样的锁时才会产生冲突,持有锁对于访问资源来说并不是必要的。Chubby采用的是建议锁,理由如下:
- Chubby锁常常保护其它服务的资源,而不是Chubby中跟锁关联的文件,而使用强制锁往往意味着要对其它服务做额外的修改。
- 当用户需要访问锁住的文件进行调试或管理目的时,我们并不想用户关掉程序。
- 我们的开发者使用很常见的错误检测方式,写assert语句比如‘assert 锁X被持有了’,所以强制锁的方式对他们来说意义不大。
- 在分布式系统中锁是复杂的,因为消息是不确定的,进程也可能会挂掉。举个例子,一个进程持有一个锁L,然后发起一个请求R,然后挂掉。另一个进程就会去持有这个锁L,然后在R到达前做一些操作。等R到达后,它可能就会在没有锁L的保护下进行操作,潜在的会造成不一致的数据。这里的意思是这个锁L保护了一段数据data,按理说这个R应该在这个data上进行操作的,但是由于进程挂掉,导致另一个进程修改了这个data,所以R就可能在不一致的数据上进行操作。
- Chubby提供了一种在使用锁的时候使用序列号的方法来解决这个问题。在每次获得锁后都会请求一个序列号(其实是一个不可读的描述锁状态的字符串),client在发送请求的时候,会把这个序列号发给服务端,服务端会检测这个序列号的合法性。服务端可以通过和Chubby之间维护的cache来检测这个序列号的合法性,或者是直接和自己最近观测到的序列号比较(这里应该隐藏了一个假设,就是同一个cell的请求会路由到同一个服务器)。
- 尽管序列号机制很简单,但是有些协议发展的很慢,不能带上序列号,chubby因此提供了另一种不完美但是更容易的方式来解决这个问题。如果一个client是以正常的方式释放锁的,那么这个锁立刻可以被其他的client获得,但是如果一个锁是因为client挂掉或不可访问而丢掉的,锁服务器会等一段叫lock-delay的时间来防止其它的client获得这个锁。
事件
- Chubby的client端可以订阅一些事件,这些事件通过回调的方式异步发送给client。
缓存
- Chubby使用的是一致性的、write-through缓存。
- 当文件数据或元数据被修改时,server端会阻塞住修改请求,然后向所有的client cache发出invalidate命令,这个invalidate命令是通过KeepAlive RPC的响应来完成的。
- client端收到Keep Alive响应后会马上使缓存失效,然后马上发起下一次Keep Alive RPC,顺带确认自己已经使缓存失效了。
- Server端在收到所有client的确认或者是client端的缓存租约过期后,才会继续这个阻塞的修改请求。
- Chuuby还会缓存锁,也就是client会缓存锁比真正需要的时间更长,因为预期同一个client还会使用这个锁。当另一个client请求同样的锁时,锁持有人能得到通知,因此有机会释放这个锁。
会话
- 会话是Chubby Cell和Client端通过KeepAlive握手维护的一种关系,当会话有效时,client端的handle,锁,缓存都是有效的。
- 当client第一次连接cell时,它会请求一个新的会话,当client结束时会显示的终止会话,或者当这个会话一分钟内没有调用和打开handle时,也会被隐式的关闭。
- 每个会话都有个对应的租约,master承诺在租约内不会单向的关闭会话,master可以延长这个租约,但不能减少。
- 收到KeepAlive后,master会阻塞这个RPC,直到client的租约接近过期,然后master会允许这个RPC返回,就可以通知client新的租约超时时间。
- master可以任意扩展租约超时,默认是12s,但是过载的master可以指定更大的值来减少KeepAlive RPC的数量。
- client在收到响应后,就会马上发起一个新的KeepAlive,因此几乎总是有一个KeepAlive被阻塞在master。
- 除开用来扩展租约之外,KeepAlive还被用来传递事件和缓存失效给client。
- 如果事件或者缓存失效发生了,master允许KeepAlive立刻返回。
- 在KeepAlive RPC的响应中附带上事件确保client在没有确认缓存失效前不能维护会话。
- client维护了自己的一个本地租约超时时间,是master租约超时的近似。之所以还要维护一个本地的,是因为KeepAlive RPC在网络上传递还需要时间,并且master的时钟速率跟client也不相同,所以client需要对这两个因素作出保守的估计。时钟速率这里,主要担心的是server端的时间走的太快,比如租约是12s,client端这里以为刚走了6s,server端那里已经12s了,这样的话server端已经把会话给关闭了client端也不会知道,就会造成不一致。因此文章里说server端的时钟和client端的时钟相比不会比一个已知的常数更快,client端就可以据此作出估计。
- 当client端租约超时时,就会进入一种jeopardy状态,再等一段称为grace period的时间后client端才会真正的认为这个会话超时了。在这期间,如果client端和server端成功的进行了一次KeepAlive RPC的话,会话就再次进入正常状态。
- Chubby的client库可以通知应用程序jeopardy事件,当会话恢复正常时,会通知应用程序safe事件,当会话超时时,会通知应用程序超时事件。这些信息使应用程序可以知道会话的状态,在不确定会话是否关闭时可以停下来等一会儿,如果只是个临时性的问题的话,就可以自动恢复而不用重启应用。这避免了应用重启的巨大开销。
Fail-overs
- 在master挂掉的时候,如果master选举很快,那client可以在自己的本地超时过期前就联系上新的master;
- 否则,client的本地超时过期后,client可以利用grace period来让会话在fail-over期间得以维持,也就是说,grace period其实增加了client端的租约超时时间。
- 图2是client端在master fail-over时利用grace period来保留会话的一个例子。
- 从图中可以看到client的本地租约已经超时,client进入了jeopardy状态,在grace period期间,client成功的联系上了新的master。一旦client成功联系上新master,对应用程序而言,就像是没有失败发生一样。为了实现这个,新master必须要重建旧master的内存状态,一个新选举出来的master需要进行的流程:
- 首先选一个新的client epoch号,client需要在每个调用中带上这个epoch号。master会拒绝使用旧epoch号的client端,这可以防止新master对一个很老的发送给旧master的包作出响应。
- 新master可能会对master-location请求作出响应,但不会对跟session有关的请求作出响应。
- 新master根据数据库中持久化的信息在内存中构建锁和会话的数据结构,会话的租约被扩展到之前的master可能已经使用的最大值。
- master现在允许client执行KeepAlive
- 给每个会话生成一个fail-over事件,使client端刷新缓存,因为client可能错过了缓存失效事件,并且警告应用程序其它事件可能也丢掉了。(因为旧master挂的时候可能还来不及发送各种事件就挂了)。
- master等待client端确认fail-over事件或者是client端的会话超时。
- master允许所有的操作正常进行。
- 如果client使用一个旧的handle,新的master会在内存中构建这个handle的状态。如果这个重建的handle后续被关了,master也会在内存中记录下来,使得在这个master的任期内不可能再重新创建一个相同的handle。
- 在一段时间后,master会把没有handle打开的临时文件给删了,因此client端需要在这段时间内刷新自己对临时文件的handle。这个机制有个不好的地方是在fail-over期间如果一个临时文件的所有client端都失去了会话,这个临时文件也不能及时被删除(需要等这段时间结束,通常是1min)。
扩展性
- 一个chubby master可能会和非常多的client直接通信,因此最有效的扩展机制是减少和master之间的通信,而不是提升请求处理的速度。chubby使用了几个方法:
- 创建任意多的chubby cell,使得chubby client可以直接和附近的cell进行通信。
- master在负载很重时可以增加租约超时时间,使得可以减少KeepAlive RPC的数目。KeepAlive RPC是目前请求中占最大部分的,对于一个过载的机器来说,不能及时处理KeepAlive RPC是最常见的错误,因为client对其它的请求耗时基本都不太敏感。
- chubby client缓存 - 使用协议转换服务器来将chubby的协议转换成更简单的协议,比如DNS和其它的一些协议。
- 文中介绍了两个常用的机制,代理和分区,使得chubby可以进一步扩展。
代理
- chubby的协议可以被代理,通过受信任的进程把请求从client端发送给server端。
- 代理可以通过处理Keepalive请求和读请求来减少server端的负载,但是没办法减少写的流量。
- 但是写流量对chubby的正常负载占比不到1%。
- 如果一个proxy处理N个client,那KeepAlive的流量能够减少N倍。
- proxy cache能够减少读流量程度取决于读共享的平均数量,一般大约是10。
- 不过读流量对chubby的负载占比不到10%,因此KeepAlive的流量减少是重要的多的。
- 代理对于写和第一次读都会增加一次RPC,因此不可用的概率至少是之前的两倍,因为每个代理的client现在都依赖两个机器:代理和master。
分区
- 命名空间可以根据目录进行分区,每个分区都有自己的副本和master,并且前提是跨分区的通信相当少。
fail-over时的问题
- master fail-over最开始的设计是master在新会话创建时把会话写进数据库中,但是当许多进程同时启动时,这就会造成过载。
- 为了避免这个,server端杯修改成不是在创建时把会话写进数据库,而是在会话第一次尝试修改,锁获取或者打开文件时。另外,在KeepAlive时,会有一定的概率把会话写进数据库。因此,对于只读会话而言,写开销就会随着时间被分散了。
- 这个修改带来的一个问题是,只读会话可能不会被写进数据库,因此在fail-over时就会被忽略。
- 如果所有被记录的会话都成功的跟新master取得了联系(这里指的是都成功确认了fail-over事件),那mater就不会等一个额外的租约超时时间,如果这时候这个只读会话的租约还未超时,那它就可能读到一个旧的数据。
- 虽然在实际系统中,这不太可能发生,因为在fail-over时,几乎总是有会话不能成功的和新master取得联系。尽管如此,fail-over还是修改了设计来避免这个影响。
- 在新设计下,不会在数据库中记录会话信息,而是像重建handle一样重建会话。新master现在会等一个最坏情况下的完整超时时间后才会允许操作进行,因为它不知道是否所有的会话都已经成功的确认了fail-over事件。
一致性协议
Chubby采用的是一个有强主的Multi-Paxos,其概要实现如下:
- 多个副本组成一个集群,副本通过一致性协议选出一个Master,集群在一个确定的租约时间内保证这个Master的领导地位;
- Master周期性的向所有副本刷新延长自己的租约时间;
- 每个副本通过一致性协议维护一份数据的备份,而只有Master可以发起读写操作;
- Master挂掉或脱离集群后,其他副本发起选主,得到一个新的Master;
接口
Chubby的对外接口是外部使用者直接面对的使用Chubby的方式,是连接分布式锁的实现及使用之间的桥梁:
- Chubby提供类似UNIX文件系统的数据组织方式,包括Files和Directory来存储数据或维护层级关系,统称Node;提供跟Client同生命周期的Ephemeral类型Node来方便实现节点存活监控;通过类似于UNIX文件描述符的Handle方便对Node的访问;Node除记录数据内容外还维护如ACL、版本号及Checksum等元信息。
- 提供众多方便使用的API,包括获取及关闭Handle的Open及Close接口;获取释放锁的Aquire,Release接口;读取和修改Node内容的GetContentAndStat,SetContent,Delete接口;以及其他访问元信息、Sequencer,ACL的相关接口。
- 提供Event的事件通知机制来避免客户端轮训的检查数据或Lock的变化。包括Node内容变化的事件;子Node增删改的事件;Chubby服务端发生故障恢复的事件;Handle失效事件。客户端收到事件应该做出对应的响应。
锁实现
- 每一个File或者Directory都可以作为读写锁使用,接受用户的Aquire,Release等请求。
- 锁依赖下层的一致性服务来保证其操作顺序。
- Chubby提供的是Advisory Lock的实现,相对于Mandatory Lock,由于可以访问加锁Node的数据而方便数据共享及管理调试。
- 分布式锁面对的最大挑战来自于客户端节点和网络的不可靠,Chubby提供了两种锁实现的方式:
完美实现:
- Aquire Lock的同时,Master生成一个包含Lock版本号和锁类型的Sequencer;
- Chubby Server在Lock相关节点的元信息中记录这个版本号,Lock版本号会在每次被成功Aquire时加一;
- 成功Aquire Lock的Handle中也会记录这个Sequencer;
- 该Handle的后续操作都可以通过比较元信息中的Lock版本号和Sequencer判断锁是否有效,从而接受或拒绝;
- 用户直接调用Release或Handle由于所属Client Session过期而失效时,锁被释放并修改对应的元信息。
简易实现:
- Handle Aquire Lock的同时指定一个叫做lock-delay的时长;
- 获得Lock的Handle可以安全的使用锁功能,而不需要获得Sequencer;
- 获得Lock的Handle失效后,Server会在lock-delay的时间内拒绝其他加锁操作。
- 而正常的Release操作释放的锁可以立刻被再次获取;
- 注意,用户需要保证在指定的lock-delay时间后不会再有依赖锁保护的操作;
对比两种实现方式,简易版本可以使用在无法检查Sequencer的场景从而更一般化,但也因为lock-delay的设置牺牲了一定的可用性,同时需要用户在业务层面保证lock-delay之后不会再有依赖锁保护的操作。
Cache
- Chubby对自己的定位是需要支持大量的Client,并且读请求远大于写请求的场景,因此引入一个对读请求友好的Client端Cache,来减少大量读请求对Chubby Master的压力便十分自然,客户端可以完全不感知这个Cache的存在。
- Cache对读请求的极度友好体现在它牺牲写性能实现了一个一致语义的Cache:
- Cache可以缓存几乎所有的信息,包括数据,数据元信息,Handle信息及Lock;
- Master收到写请求时,会先阻塞写请求,通过返回所有客户端的KeepAlive来通知客户端Invalid自己的Cache;
- Client直接将自己的Cache清空并标记为Invalid,并发送KeepAlive向Master确认;
- Master收到所有Client确认或等到超时后再执行写请求。
Session and KeepAlive
-
Session可以看做是Client在Master上的一个投影,Master通过Session来掌握并维护Client:
-
每个Session包括一个租约时间,在租约时间内Client是有效的,Session的租约时间在Master视角和Client视角由于网络传输时延及两端的时钟差异可能略有不同;
-
Master和Client之间通过KeepAlive进行通信,Client发起KeepAlive,会被Master阻塞在本地,直到Session租约临近过期,此时Master会延长租约时间,并返回阻塞的KeepAlive通知Client。除此之外,Master还可能在Cache失效或Event发生时返回KeepAlive;
-
Master除了正常的在创建连接及租约临近过期时延长租约时间外,故障恢复也会延长Session的租约;
-
Client的租约过期会先阻塞本地的所有请求,进入jeopardy状态,等待额外的45s,以期待与Master的通信恢复。如果事与愿违,则返回用户失败。
-
Session及KeepAlive给了Chubby Server感知和掌握Client存活的能力,这对锁的实现也是非常重要的,因为这给了Master一个判断是否要释放失效Lock的时机。
故障恢复
- Master发生故障或脱离集群后,它锁维护的Session信息会被集群不可见,一致性协议会选举新的Master。
- 由于Chubby对自己Corase Lock的定位,使用锁的服务在锁的所有权迁移后会有较大的恢复开销,这也就要求新Master启动后需要恢复必要的信息,并尽量减少集群停止服务过程的影响:
- 选择新的epoch;
- 根据持久化的副本内容恢复Session及Lock信息,并重置Session租约到一个保守估计的时长;
- 接受并处理Client的KeepAlive请求,第一个KeepAlive会由于epoch错误而被Maser拒绝,Client也因此获得了最新的epoch;之后第二个KeepAlive直接返回以通知Client设置本地的Session租约时间;接着Master Block第三个KeepAlive,恢复正常的通信模式。
- 从新请求中发现老Master创建的Handle时,新Master也需要重建,一段时间后,删除没有Handle的临时节点。
分布式锁的使用
-
锁的使用跟上面提到的锁的实现是紧密相关的,由于客户端节点及网络的不可靠,即使Chubby提供了直观如Aquire,Realease这样的锁操作,使用者仍然需要做出更多的努力来配合完成锁的语义,Chubby论文中以一个选主场景对如何使用锁给出了详细的说明,以完美方案为例:
- 所有Primary的竞争者,Open同一个Node,之后用得到的Handle调用Aquire来获取锁;
- 只有一个成功获得锁,成为Primary,其他竞争者称为Replicas;
- Primary将自己的标识通过SetContent写入Node;
- Replicas调用GetContentsAndStat获得当前的Primary标识,并注册该Node的内容修改Event,以便发现锁的Release或Primary的改变;
- Primary调用GetSequencer从当前的Handle中获得sequencer,并将其传递给所有需要锁保护的操作的Server;
- Server通过CheckSequencer检查其sequencer的合法性,拒绝旧的Primary的请求。
-
如果是简单方案,则不需要Sequencer,但需要在Aquire操作时指定lock-delay,并保证所有需要锁保护的操作会在最后一次Session刷新后的lock-delay时间内完成。
启发
责任分散
- 分布式系统中,通常都会有多个角色进行协作共同完成某个目标,有时候合理的将某些功能的责任分散到不同角色上去,分散到不同时间去,会起到降低复杂度,减少关键节点压力的效果。
- 比如Chubby中发生写事件需要更新Client Cache时,Master并没有尝试自己去更新所有的Client,而是简单的Invalid所有Client的Cache,这样就将更新所有Client Cache这项任务分散到所有的客户端上,分散到后边一次次的请求时机中去。这种推变拉的做法也是Zeppelin中大量使用的。
考虑可扩展时,减少通信次数有时候比优化单次请求处理速度更有效
-
Chubby作为一个为大量Client提供服务的中心节点,并没有花过多的精力在优化单条请求路径上,而是努力地寻找可以减少Client与Master通信的机制:
-
分散多个Cell负责不同地域的Client;
-
负载较重时,Master可以将Session的租约从12s最多延长到60s来减少通信频次;
-
通过Client的Cache缓存几乎所有需要的信息;
-
进一步的采用Proxy或Partition的方式。
限制资源的的线性增长
- 论文中提到对Chubby使用资源情况的检查,包括RPC频率、磁盘空间、打开文件数等。
- 任何可能随着用户数量或数据量的增加而线性增加的资源都应该有机制通过降级操作限制在一个合理的范围内,从而提供更加健壮的服务。
- 负载较重时延长Session租约时间及存储配额的设置应该就是这方面的努力。
故障恢复时的数据恢复
-
为了性能或负载,Master不可能将所有需要的信息全部通过一致性协议同步到所有副本。其内存维护的部分会在故障发生时丢失,新的Master必须能尽可能的恢复这些数据来让外部使用者尽量少的感知到故障的发生。恢复的数据来源方面,Chubby做了一个很好的范例:
-
部分来源于持久化的一致性数据部分,这也是最主要的;
-
部分来源于客户端,如Handle会记录一些信息供新主读取并重新创建。论文中也提到在Chubby的进化中,这种方式也变得越来越重要;
-
部分来源于保守估计,如Session的Timeout。