或者当我们为ZooKeeper,etcd和Consul KV编写客户端C ++库时
在分布式系统的世界中,有许多典型任务:存储有关集群组成的信息,管理节点的配置,检测故障节点,选择领导者
等 。 为了解决这些问题,已经创建了特殊的分布式系统-协调服务。 现在我们将对其中三个感兴趣:ZooKeeper,etcd和Consul。 在Consul的所有丰富功能中,我们将重点关注Consul KV。

实际上,所有这些系统都是容错的线性化键值存储。 尽管它们的数据模型有很大的不同,我们将在后面讨论,但是它们使我们能够解决相同的实际问题。 显然,每个使用协调服务的应用程序都与其中一个绑定在一起,这可能导致需要支持多个系统,这些系统可以在一个数据中心为不同的应用程序解决相同的任务。
解决这个问题的想法源自澳大利亚的一家咨询机构,我们(由一小群学生组成)必须实施它,我将告诉您。
我们能够创建一个库,为使用ZooKeeper,etcd和Consul KV提供通用接口。 该库是用C ++编写的,但是有移植到其他语言的计划。
资料模型
要为三个不同的系统开发一个通用的界面,您需要了解它们的共同点以及它们之间的区别。 让我们做对。
动物园管理员
键被组织成一棵树,称为节点(znodes)。 因此,对于该站点,您可以获取他的孩子的列表。 创建znode(创建)和更改值(setData)的操作是分开的:只有现有的键才能读取和更改值。 可以将手表附加到检查节点是否存在,读取值以及获取子代的操作。 Watch是一次性触发器,当服务器上相应数据的版本更改时将触发。 临时节点用于检测故障。 它们被附加到创建它们的客户的会话中。 当客户端关闭会话或停止向ZooKeeper通知其存在时,这些节点将被自动删除。 支持简单事务—如果不能执行至少一项操作,则一组操作要么全部成功要么失败。
等
该系统的开发人员显然受到了ZooKeeper的启发,因此所做的一切都与众不同。 密钥层次结构不在这里,但它们按字典顺序排列。 您可以获取或删除属于某个范围的所有键。 这样的结构可能看起来很奇怪,但实际上它非常具有表现力,并且很容易模拟通过它的层次结构视图。
etcd中没有标准的比较设置操作,但是有更好的东西-事务。 当然,它们都在所有三个系统中,但是在etcd中,事务特别好。 它们包括三个部分:检查,成功,失败。 第一个块包含一组条件,第二个和第三个操作。 事务是原子执行的。 如果所有条件都为真,则执行成功块,否则-失败。 在API版本3.3中,成功和失败块可以包含嵌套事务。 即,可以原子地执行几乎任意级别的嵌套的条件构造。 您可以从
文档中了解有关哪些检查和操作的更多信息。
手表也存在于此,尽管它们有些复杂和可重复使用。 也就是说,在一系列键上安装了watch之后,您将收到该范围内的所有更新,直到您取消watch为止,而不仅仅是第一个。 在etcd中,租约等效于ZooKeeper客户端会话。
领事KV也没有严格的层次结构,但是Consul可以创建它的外观:您可以接收和删除具有指定前缀的所有键,即,使用键的“子树”。 这样的查询称为递归。 此外,领事只能选择在前缀后不包含指定字符的键,这与接收到的“子代”相对应。 但是,值得记住的是,这恰好是分层结构的外观:如果不存在其父项,则完全有可能创建密钥,或者删除具有子项的密钥,而子项将继续存储在系统中是完全可能的。

而不是监视,Consul中阻止了HTTP请求。 从本质上讲,这些是对数据读取方法的常规调用,其中会与其他参数一起指示数据的最新已知版本。 如果服务器上相应数据的当前版本大于指定的版本,则立即返回响应,否则,当值更改时返回。 这里也有可以随时附加到按键的会话。 值得注意的是,与etcd和ZooKeeper不同的是,删除会话会导致相关密钥的删除,而存在一种将会话简单地从它们分离的模式。
事务是可用的,没有分支,但是有各种检查。
放在一起
最严格的数据模型是ZooKeeper。 在Zoodeeper或Consul中不能有效地仿真etcd中可用的表达范围请求。 为了充分利用所有服务,我们提供了一个与ZooKeeper接口几乎等效的接口,但有以下重要例外:
- 不支持序列,容器和TTL节点
- 不支持ACL
- 如果set方法不存在,则set方法将创建一个密钥(在这种情况下,在ZK setData中将返回错误)
- set和cas方法是分开的(在ZK中,它们本质上是同一件事)
- 擦除方法将顶点和子树一起删除(在ZK中,如果顶点有子节点,则delete将返回错误)
- 每个键只有一个版本-值的版本(在ZK中有三个 )
拒绝顺序节点是由于在etcd和Consul中没有对它们的内置支持,并且在生成的库接口之上,用户可以轻松实现它们。
在卸下顶部ZooKeeper时实现相同的行为将需要在etcd和Consul中为每个密钥维护一个单独的子计数器。 由于我们试图避免存储元信息,因此决定删除整个子树。
实施的微妙之处
让我们更详细地考虑在不同系统中实现库接口的某些方面。
etcd中的层次结构在etcd中维护层次结构视图是最有趣的任务之一。 范围请求使获取具有指定前缀的键列表变得容易。 例如,如果您希望所有以
"/foo"
开头的内容,则请求范围
["/foo", "/fop")
。 但这将返回键的整个子树,如果子树很大,则可能无法接受。 首先,我们计划使用
zetcd中实现的密钥转换机制。 它涉及在键的开头添加一个字节,等于树中节点的深度。 我举一个例子。
"/foo" -> "\u01/foo" "/foo/bar" -> "\u02/foo/bar"
然后,您可以通过请求范围
["\u02/foo/", "\u02/foo0")
获得
"/foo"
键的所有直接子级。 是的,在ASCII中,
"0"
紧跟在
"/"
。
但是,如何删除顶点呢? 原来,您需要从01到FF
["\uXX/foo/", "\uXX/foo0")
XX形式的所有范围
["\uXX/foo/", "\uXX/foo0")
。 然后,我们在单个事务中遇到了
操作数限制 。
结果,发明了一种简单的密钥转换系统,该系统使我们能够有效地实现密钥的移除和子列表的接收。 在最后一个标记之前添加一个特殊符号就足够了。 例如:
"/very" -> "/\u00very" "/very/long" -> "/very/\u00long" "/very/long/path" -> "/very/long/\u00path"
然后删除
"/very"
键变成删除
"/\u00very"
和范围
["/very/", "/very0")
,并使所有子项都请求范围为
["/very/\u00", "/very/\u01")
。
删除ZooKeeper中的密钥正如我已经提到的,在ZooKeeper中,如果节点具有子节点,则无法删除该节点。 我们要删除键和子树。 如何成为 我们正在乐观地做。 首先,我们递归遍历子树,在单独的查询中获得每个顶点的子代。 然后,我们建立一个事务,尝试以正确的顺序删除子树的所有节点。 当然,可以在读取子树和删除子树之间进行更改。 在这种情况下,事务将失败。 此外,子树在读取过程中可能会更改。 如果例如该顶点已被删除,则查询下一个节点的子节点可能会返回错误。 在这两种情况下,我们都会重复整个过程。
这种方法使删除键(如果有子键)非常无效,如果应用程序继续使用子树,删除并创建键,则删除键的效率甚至更高。 但是,这使我们不能使etcd和Consul中其他方法的实现复杂化。
在ZooKeeper中设置在ZooKeeper中,存在分别用于树结构(创建,删除,getChildren)和用于节点中数据(setData,getData)的方法,此外,所有方法都有严格的先决条件:如果节点已经创建,删除或创建,则create将返回错误。 setData-如果尚不存在。 我们需要set方法,无需考虑密钥就可以调用它。
一种选择是采用乐观的方法,就像在删除时一样。 检查节点是否存在。 如果存在,请调用setData;否则,请创建。 如果最后一个方法返回错误,请重新重复一遍。 首先要注意的是检查存在性的意义。 您可以立即调用create。 成功完成将意味着该节点不存在并已创建。 否则,create将返回相应的错误,此后必须调用setData。 当然,在两次调用之间,可以通过竞争调用移除顶点,并且setData也将返回错误。 在这种情况下,您可以再次重复所有操作,但这值得吗?
如果这两种方法均返回错误,则我们肯定知道存在竞争性删除。 想象一下,这种删除发生在调用set之后。 然后,无论我们尝试建立什么值,它都已被擦除。 因此,即使实际上没有写任何内容,您也可以假定该设置成功。
更多技术细节
在本节中,我们脱离分布式系统并讨论编码。
客户的主要要求之一是跨平台:在Linux,MacOS和Windows中,必须至少支持其中一项服务。 最初,我们仅在Linux下进行开发,后来在其他系统中开始测试。 这引起了很多问题,有一段时间以来,如何处理完全不清楚。 结果,Linux和MacOS现在都支持所有三种协调服务,而Windows上仅支持Consul KV。
从一开始,我们就尝试使用现成的库来访问服务。 在ZooKeeper的情况下,选择权取决于
ZooKeeper C ++ ,最终无法在Windows上进行编译。 但是,这并不奇怪:该库被定位为仅Linux。 对于领事,
ppconsul是唯一的选择。 我必须添加对
会话和
事务的支持。 对于etcd,从未找到支持该协议最新版本的完整库,因此我们仅
生成了grpc client 。
受ZooKeeper C ++库的异步接口的启发,我们决定也实现异步接口。 在ZooKeeper C ++中,为此使用了future / promise原语。 不幸的是,在STL中,它们的实现非常有限。 例如,没有
then方法可以在将来的结果可用时将传递的函数应用于将来的结果。 在我们的情况下,必须使用这种方法将结果转换为库的格式。 为了解决这个问题,我们必须实现简单的线程池,因为根据客户的要求,我们不能使用大量的第三方库,例如Boost。
然后我们的实现如下。 当被调用时,将创建一个额外的promise / future对。 返回新的future,并将转移的future与相应的功能以及其他promise一起放入队列中。 池中的线程从队列中选择多个期货,并使用wait_for对其进行轮询。 当结果变为可用时,将调用相应的函数,并将其返回值传递给promise。
我们使用相同的线程池来执行对etcd和Consul的请求。 这意味着几个不同的线程可以与基础库一起使用。 ppconsul不是线程安全的,因此对其的调用受锁保护。
您可以从多个线程使用grpc,但是有一些细微之处。 Etcd手表通过grpc流实现。 这些是某些类型的消息的双向通道。 该库为所有手表创建一个流,并为处理传入消息创建一个流。 因此,grpc禁止并行记录流式传输。 这意味着在初始化或删除手表时,您需要等到上一个请求的发送完成后再发送下一个请求。 我们使用
条件变量进行同步。
总结
自己看看:
liboffkv 。
我们的团队:
Raed Romanov ,
Ivan Glushenkov ,
Dmitry Kamaldinov ,
Victor Krapivensky ,
Vitaly Ivanin 。