引言
这篇文章主要陈述了ChubbyGo目前功能上遇到的一些问题,以及一些我认为可以继续优化的地方。
其实当我重新仔细的审视了一遍ChubbyGo的实现以后,我惊奇的发现除了一些我已知的可以修改的Bug以及因为Go的RPC框架造成的不便以外,所有安全性问题都来源于时间,这是一个情理之外而又意料之中的事情。虽然在Lamport大神思想的熏陶下我早知道了在分布式环境中时间是一个极为棘手的问题,但是毕竟以前没有深入到代码级别,对这个问题的理解尚且停留在表面。当初步完成了ChubbyGo以后,算是可以以一个比以前更深的层次来看看时间对于一个分布式服务的影响有多大。
这篇文章首先从两个ChubbyGo中真实遇到的例子来进行安全性的论证,随后给出几个从ZooKeeper得到的启发。
安全性
Acquire超时参数
ChubbyGo实现的是一个分布式锁的服务,不同的客户来源于不同的机器,显然客户端的行为对于服务器来说是不可控的。这意味着为了更强的健壮性,加锁操作需要有一个超时的参数,防止在一个持有锁的客户端宕机以后这个锁无法被其他客户端得到。这很好理解,问题的关键在于时间。为什么呢?我们来看一幅图:
- 设客户端发出请求的基准时间为0S。
- 客户端发出一个加锁请求,请求的锁超时时间为2S。
- 假设Leader在接收到请求以后立马处理,两秒后解除这个锁,也就是解锁的时间为2+x。
- 我们把Leader从接收到数据到执行加锁的过程中间的时间添加到数据包从Leader到Client的时延中,也就是y,显然y是大于0的。
- 当Client收到包以后执行锁定,此时我们再引入一个Client与Leader时钟不同步的度,我们称为degree,在加上客户端处理从接收包到加锁的时间,我们称之为z。
- 也就是客户端解锁解锁的时间为2 + x + y + degree + z。
我们可以清楚的看到客户端解锁的时间是要落后与服务器解锁的时间的,这个时间的差值为(y + degree + z),但是一个最为严峻的问题是degree是不确定正负的,因为可能是Leader的时钟更快,也可能是Client的时钟更快,也就是其实(y + degree + z)我们是没办法判断正负的,也意味着可能同一个时间点两个客户端同时认为自己持有锁。目前采用的方案是Leader真实的睡眠时间是TimeOut的两倍,最大程度的避免这个问题,当然从理论上来说仍然是错误的,但从工程的角度来说其实已经避免了绝大多数问题了。
我们可以看到这个问题其实描述的就是客户端在超时时间结束以后仍然持有锁的问题,还有一种情况可能导致上述情况,就是客户端程序可能经过了一个很长时间的GC或者程序的优先级极低,没有被OS的调度器及时调度,也是很多博客描述的情况。这个问题我最终采用Token机制解决,而不是像Chubby一样使用Keepalive解决。有兴趣的朋友可以查看《浅谈分布式锁:安全与性能的取舍之道》2.2节末尾对于Token机制的简单描述。
CheckSeq接口
CheckSeq是引入Token机制以后的为了其他服务器可以检测Token而引入的一个接口,接下来会描述当其他服务器正确的处理请求时ChubbyGo是安全的,我们先来看一张图:
我们假设Leader为服务器,下面的OtherServer为一个需要检测Token的资源服务器,剩下两个是不同的客户端,我们一步一步看:
- ClientA请求到一个锁,Token的值为2.
- ClientA使用这个Token去请求资源。
- OtherServer收到这个请求以后向ChubbyGo Leader发送一个CheckToken请求,参数为从ClientA收到的Token,值为2.
- Leader在收到CheckSeq的时刻看到这个Token目前是最新的,向OtherServer返回OK。但是在数据包跑到一半的时候ClientA持有的锁超时,我们假设ChubbyGo和客户端持有的锁在同一时刻失效(至少服务器后失效),其实也就是上一节我们描述的问题。也就是说当OtherServer收到OK的时候,可能ClientB带有Token3的请求已经到了,但是我们前面说过ClientA已经知道自己不持有锁了,所以OtherServer虽然收到了OK,但是向ClientA分配资源的行为会被ClientA拒绝。
- ClientB的请求到达,此时OtherServer继续上述过程,当然如果不出现上面极端的情况的话请求资源成功。
当然如果资源存在ChubbyGo中就没有这些事情了,直接检查每个请求的Token就ok了。
展望
FIFO client order与Sequential Consistency
ChubbyGo与Chubby一样提供了线性一致性,这意味着对于所有的客户来说他们可以看到全部的发生于他们Get操作之前的全部操作,也就是全局的事件可以理解为线性,且除了Leader以外全部的服务都只是起一个数据冗余的作用,显然这是一个比较大的资源浪费,因为至少有N/2台Follower服务器与Leader拥有相同的日志(底层一致性协议采用Raft实现),而它们却是无作为的。
我们此时其实是要考虑一个问题,那就是客户到底是否需要如此强的一致性呢?我们以ChubbyGo锁服务提供的虚拟文件树举例,显然客户在绝大多数时候并不关心除了自己加锁以外的文件的数据到底是什么。如此看来FIFO实在是一个很合适的一致性,因为客户端只需要在意自己角度的一致性就可以了,ZooKeeper中使用一个zxid实现FIFO。当然这也有一个问题,就是此时看来貌似Zookeeper没办法达到全局的一致性,其实对于Zookeeper的架构来说这个不难实现,也就是Sync的作用,当然就我来说,我认为ZooKeeper也许把Get当做一个特殊的写操作会更加优雅一点。
所以ChubbyGo的后期可以尝试引入FIFO client order一致性,这并不难实现,我们只需要给每一个操作带上zxid就ok了,至少目前看起来是这样,也许后面还可以支持从Follower读取数据。
关于改进更多的细节我放在了Chubby的文档中,见3.3节。
总结
进行了这些思考得出结论其实比较令人沮丧,因为从架构上来说ChubbyGo并没有出彩之处,相反ZooKeeper的架构更加的简洁,高效,且赋予了用户更多的可能性。但是相比于Redis,ChubbyGo还是有一些优点的,比如ChubbyGo就不会出现Redis作分布式锁可能出现的数据丢失导致多个客户端持有一个锁的情况,且功能也更多一点。