ZooKeeper: Wait-free coordination for Internet-scale systems

zookeeper

本文详细介绍了大名鼎鼎的动物员管理员的设计思想.
首先zookeeper的定位是一个分布式协调服务。所谓协调服务在我的理解是解决分布式系统中一些组件和组件的关系,这种关系和业务目的没有意义。
比如成员管理(哪些成员在这个集群里),领导选举(如果一个leader挂了,如何选出新的领导),配置管理(master一般会掌握一些配置),分布式锁(保护临界区的资源)
zookeeper的第二个目标就是高吞吐,高性能。所以我们在后面可以看到几个关键词wait-free, pipeline, fast read.同时在高性能基础上有可以保证更新操作的linearizability. 其中也移除了阻塞的api,因为担心一些慢的客户端会因阻塞的api而拖累那些快的api,所以所有api都是wait-free,不阻塞的。但是通过不阻塞的api也可以去实现阻塞的api如分布式锁。
第三个思想是因为一些高级原语可以被一些低级原语去实现。所以zookeeper希望提供这么一个原语级的kernel,好让客户端自己去根据zookeeper提供的原语来组成自己想要的分布式协调服务。当然论文中也给出了几个例子。比如读写锁,简单锁,双barrier等。

service 概览

zookeeper 提供给客户端3样东西,api,znode , znode上的watch。而每一个zookeeper自己本身在内存里维护了一个类似于文件系统的结构树。每一个叶子节点就是一个znode


image.png

znode 有2种类型,第一种是常规的,第二种是临时的。常规的就是正常的文件,可以被创建和删除。临时的就是和创建它的client有关系,一旦client下线,所有临时的节点也会被直接删除。
zookeeper通过提供watch(回掉通知client)来避免让client去轮训,当client关心的一个znode 状态发生改变的时候。所以get api里都有一个watch的boolean,如果为true,就代表需要监听这个znode的事件。被触发一次后,如果依然想watch,得再显示的去调用。
后面所有zookeeper提供的服务,都离不开znode 和 watch,所以这里很关键。
那么交互的方式就是如下的api,也就是zookeeper为我们提供的低级原语。


image.png
image.png

这里zookeeper不会用句柄去管理这些文件,所以api都强制需要用户传全路径,这简化zookeeper维护的状态。

zookeeper 可以保证2样事情

一个是线性化的写,也就是所有更新操作都是一个接着一个,使得client段认为这背后就是单机。也称为线性一致性,强一致性。

所有client的操作,对这个client来说是fifo,也就是可以保证对这个client来说,操作是按照client发的顺序一个一个做的。

这2个保证是如何相互作用的?

试想一个例子: 一个新上任的leader想要修改config,但是我们不想让那些follower看到正在修改的config。 还有如果新上任的leader修改到一半挂了,我们也不想让别的人看到改到一半的config。

chubby的分布式锁可以实现第一个,但对第二个需求是不给力的。

zookeeper 首先是这么玩的,leader 如果改完配置,会在一个目录下创建一个ready的znode。所有这些followe都去监听这个目录下有没有ready的znode。所以没有ready,follower就不会去读。那么leader只需要删除ready znode,更新config,创建ready znode来和followers 交互。达成如上效果。也就是前面提到的用低级原语实现高级原语。

论文提到一个问题,就是有没有可能follower看到了ready node 去读配置了。这个时候,leader删除了ready node,开始改配置。使得followe读到了leader改到一半的配置。

这个问题看似非常tricky,实际上再回头读一遍那2个保证就会发现是不可能的。因为删除node 必然在修改新配置前。 那么对follower来说一定是先看到删除node,再看到修改新配置。但是一旦删除node被follower watch到,就不会再去看config了。如果没有被watch到,说明现在能看到的配置一定是旧的配置。

这里可能没有做过lab 2 raft
的小伙伴没法理解背后是如何实现的,但是做过lab2的小伙伴都懂哒。

还有一个问题,zookeeper为了提高读性能,所以读是弱一致的。就是会读到过期的数据。(有人已经把新的update commit了, 你去读,还是看不到)为了保证强一致的读。客户端可以用sync() 然后去read,就能保证已经commit的写,一定能被读到。

2.4 一些高级原语的例子

配置管理:只需要将配置保存在一个znode中,各个进程可以通过观测来获取配置更新通知。

会合:很多分布式系统包含主节点和工作节点,但是节点的调度由调度器决定,可以将主节点信息放在一个znode,供工作节点找到主节点。

组成员关系:组成员进程上线之后可以在组对应的znode之下创建对应的临时子znode,成员进程退出之后临时znode也被删除,因此可以通过组znode的子znode获取组成员状态。

简单锁:锁可以创建一个对应的znode实现。如果创建成功,那么获取锁。如果已经存在,那么需要等待锁被释放(znode被删除)后才能获取锁(创建znode)。
无惊群效应的简单锁:简单锁会出现大量进程竞争的情况,可以将锁请求排序后,按次序分配锁。


image.png

双栅栏:双栅栏用来保证多个客户端的计算同时开始和同时结束。客户端开始计算之前添加znode到栅栏对应的znode之下,结束计算之后删除znode。客户端需要等待栅栏znode的子znode数量到达一定阈值后才能开始计算,客户端可以等待一个特殊的ready的znode的创建,当数量到达阈值后创建。客户端退出的时候需要等待子znode全部被删除,同样可以通过删除ready删除。

基于zookeeper构建的应用

下面重点介绍一个 yahoo message broker。
ymb是一个分布式的消息订阅发布服务。他需要管理上万个话题,每个话题需要用主备来复制确保消息是可靠的。 zookeeper的加入是的ymb可以使用一个不share东西的架构来实现。 ymb用zk去管理topics,找到失败,然后leader 失败可以发起leader选举


image.png

图中nodes的目录,用来做成员管理。 shut down 和migration_prohibited用来做配置管理。topic下的节点保证主备容灾和leader选举

4. zookeeper的实现

zk提供高可用通过复制数据在每一个组成服务的server上。


image.png

上图是一个基本架构,replicated database是个内存数据库包含整个data tree。
client的读请求会直接到zk的这个内存数据库里找值。所有写请求,会先转到leader那,之后首先通过Request Processor包装成一个幂等的事务请求。随后丢到一个zookeeper自己实现的类似paxos的一致性算法协议里,去做线性化处理。交zab。然后应用到内存数据库中。

Request Processor的作用就是包装更新请求,变成一个幂等的事务。这个幂等之后有大作用。

Atomic Broadcast就是zab算法。所有请求会进来,然后zab会把顺序定好,并且确保majority的server可以commit。 为了达到高吞吐,不会阻塞在client那,所以可以最近好的pipeline。 同时zab保证一个新的leader发送的请求之前,上一个leader commit的东西都会在这个新的leader的请求之前被commit了。(这段可以借鉴raft的作用)
zab利用了tcp的防乱序和防丢失的机制来简化了paxos算法。同时使用leader 直接来propose一个事务当作一次wal对新的更新in memory db,这样可以少一次写磁盘。本来是需要proposal 写一次,wal写第二次。这段需要对paxos的理解
在常规流程里,zab会准确的有序的广播消息 并且有exactly once的语义。但是在恢复的时候,可能会重投消息,但是之前我们已经定义了幂等的事务,所以这是无害的。
Replicated Database 就是一个内存中的文件树。为了避免恢复的时候从头的log开始,恢复时间过长。zk会做snapshot。但是这个snapshot不是基于某一时刻的snapshot,因为它不阻塞,就是它允许一边在生成snapshot的时候,一边更新这颗文件树。同样这个snapshot可能包括之后几个幂等操作的更新在里面。所以重做那些幂等的更新是安全的。
Client-Server Interactions的交互有个重要的返回就是zxid在read的时候。因为所有的写都会被zab保证一致性。zxid是解决读回退的问题也就是读到了比上次读还要旧的问题。那是如何做到的呢?每次读的时候这个server会返回目前执行到的tx id当作zxid给client。如果client去到另外一边读了,那台服务器发现他执行到的tx不如这个client的zxid。就会拒绝和client建立连接知道它catch up到比client更新的zxid为止。
如果client想要读到最新的提交,需要主动先调用sync(),随后再读。客户端的fifo的保证,和全局更新sync()的可线性化可以保证sync后,所有sync之前的写被更新进这个机器。同时sync不用被广播出去,因为我们使用的是基于leader的算法。sync的请求会在一开始发送到leader那,所以那时follower肯定相信这个leader还是leader。 如果等待的事务被提交了,那么服务器不会怀疑这个leader已经过时了。如果pending queue空了,这个leader需要发送一个空事务去commit,然后做sync在这个txn后。同时在实现中,leader自己会有一个超时时间可以确保他可以比follower先发现自己不是leader了,就不会再去提交这个空事务。
如果检测客户端故障,会话是有超时时间的,客户端在没有活动期间也要发送心跳避免超时。如果发送失败,客户端还有一次换一个server发心跳的机会,如果可以和那个server建立联系,这个session就不会过期。

问题

One use of Zookeeper is a fault-tolerant lock service (see the section "Simple locks" on page 6). Why isn't possible that two clients can acquire the same lock? In particular, how does Zookeeper decide if a client has failed and it can give the lock to some other client?
首先第一个问题,因为simple lock设置了SEQUENTIAL,那么在这个文件夹下创建的node都会带一个递增的id。如果他是最小的就会拿到锁。
保证了只有一个client 可以拿到锁。
第二个问题,因为client 设置EPHEMERAL, 所以当client挂了,这个znode也会被删了。然后其他节点因为watch了这个锁的节点,会被唤醒,唤醒后,他们会重新check自己是不是最小的,最小的那个可以获得锁。同时不是最小的会继续watch这个新的最小的。

faq

Q: 为什么只有update需要A-linearizable?
A: 因为作者希望读可以随着server的数量可扩展。所以他们希望这个读可以只涉及一台server。作为代价,读可能返回过期的数据

Q: 什么是pipe line?
A: Zookeeper "pipelines" 某些操作 (create, delete, exists, etc)。 这里的pipeline意味着异步执行,对client来说,而不是等着一个做完,再发起另一个请求
流水线操作的一个担心是,可能会对正在运行的操作进行重新排序,这将引起作者在2.3中讨论的问题。 如果领导者正在执行许多写操作,然后执行写ready操作,则您不希望对这些操作进行重新排序,因为这样,其他客户端可能会在应用前面的写操作之前观察到准备就绪。 为了确保这不会发生,Zookeeper保证FIFO用于客户端操作。 也就是说,客户端操作将按照其发出的顺序进行应用。

Q: 为什么zk选择wait-free而不是blocking?
A: 许多RPC API都处于阻塞状态:例如,考虑lab 2/3中的客户端-他们只发出一个请求,耐心地等待它返回或超时,然后才发送下一个。 这使得API易于使用和推理,但性能却不佳。 例如,假设您想在Zookeeper中更改1,000个密钥-一次更改一次,您将花费大部分时间等待网络传输请求和响应(这是实验2/3所做的!)。 如果您可以同时发送几个请求,则可以分摊其中的一部分费用。 Zookeeper的免等待API使这成为可能,并允许更高的性能-这是作者用例的关键目标。

Q: 实现非阻塞的snapshot的原因是什么,状态是如何变成幂等的?
A: 如果作者必须决定使用一致的快照,则Zookeeper将必须停止所有写操作,才能为内存数据库创建快照。 您可能还记得GFS遵循此计划,但是对于大型数据库,这可能会严重损害Zookeeper的性能。 取而代之的是,作者采用了一种模糊快照方案,该方案不需要在创建快照时阻止所有写操作。 重新启动后,它们通过重放检查点启动后发送的消息来构造一致的快照。
因为Zookeeper中的所有更新都是幂等的,并且以相同的顺序交付,所以应用程序状态在重新引导和重播后将是正确的-某些消息可能会应用两次(一次应用于恢复之前的状态,而一次应用于恢复之后),但是可以 ,因为它们是幂等的。 重播将模糊快照固定为应用程序状态的一致快照。
Zookeeper将客户端API中的操作转换为某种东西,它以事务等幂的方式调用事务。 例如,如果客户端发出条件setData,并且请求中的版本号匹配,则Zookeeper将创建一个setDataTXN,其中包含新数据,新版本号和更新的时间戳。 该事务(TXN)是幂等的:Zookeeper可以执行两次,它将导致相同的状态。

Q: Zookeeper和paxos比性能如何?
A: 它具有令人印象深刻的性能(特别是吞吐量); Zookeeper将击败您实施Raft的过程。 3 zookeeper服务器每秒处理21,000次写入。 您的带有3台服务器的raft的提交速度约为每秒数十次操作(假设要存储一个磁盘),使用SSD可能每秒数百次。

Q: Zookeeper中的watch是怎么实现的?
这取决于client。大多数client的library会注册一个回掉函数,然后当watch trigger就会调用这个回掉函数。
举个例子,go里面一个go client会实现它通过传入一个channel。当watch trigger, 一个event会通过channel被送达,那么应用程序就可以检测这个channel在select语句里。

你可能感兴趣的:(ZooKeeper: Wait-free coordination for Internet-scale systems)