五名学生和三个分布式键值存储

或者我们如何为 ZooKeeper、etcd 和 Consul KV 编写客户端 C++ 库

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

五名学生和三个分布式键值存储

本质上,所有这些系统都是容错的、可线性化的键值存储。 尽管它们的数据模型存在显着差异(我们稍后将讨论),但它们解决的是相同的实际问题。 显然,每个使用协调服务的应用程序都与其中一个应用程序绑定,这可能导致需要在一个数据中心支持多个系统,为不同的应用程序解决相同的问题。

解决这个问题的想法起源于澳大利亚的一家咨询机构,然后由我们这个学生小团队来实施,这就是我要讲的内容。

我们设法创建了一个库,它提供了与 ZooKeeper、etcd 和 Consul KV 配合使用的通用接口。 该库是用 C++ 编写的,但计划将其移植到其他语言。

数据模型

要为三个不同的系统开发通用接口,您需要了解它们的共同点和不同点。 让我们弄清楚一下。

动物园管理员

五名学生和三个分布式键值存储

键被组织成一棵树,称为节点。 因此,对于一个节点,您可以获得其子节点的列表。 创建 znode (create) 和更改值 (setData) 的操作是分开的:只能读取和更改现有的键。 监视可以附加到检查节点是否存在、读取值和获取子节点的操作。 Watch 是一种一次性触发器,当服务器上相应数据的版本发生更改时触发。 临时节点用于检测故障。 它们与创建它们的客户端会话相关联。 当客户端关闭会话或停止通知 ZooKeeper 其存在时,这些节点将自动删除。 支持简单事务 - 一组操作,如果其中至少一个操作不可能,则要么全部成功,要么全部失败。

五名学生和三个分布式键值存储

该系统的开发人员显然受到了 ZooKeeper 的启发,因此所做的一切都不同。 键没有层次结构,但它们形成了按字典顺序排序的集合。 您可以获取或删除属于某个范围的所有键。 这种结构可能看起来很奇怪,但实际上非常具有表现力,并且可以通过它轻松模拟分层视图。

etcd 没有标准的比较和设置操作,但它确实有更好的东西:事务。 当然,它们存在于所有三个系统中,但 etcd 事务特别好。 它们由三个块组成:检查、成功、失败。 第一个块包含一组条件,第二个和第三个块包含操作。 事务以原子方式执行。 如果所有条件都为真,则执行成功块,否则执行失败块。 在 API 3.3 中,成功和失败块可以包含嵌套事务。 也就是说,可以原子地执行几乎任意嵌套级别的条件构造。 您可以了解有关存在哪些检查和操作的更多信息 文件资料.

这里也有手表,尽管它们稍微复杂一些并且可以重复使用。 也就是说,在某个关键范围上安装手表后,您将收到该范围内的所有更新,直到您取消手表为止,而不仅仅是第一个更新。 在 etcd 中,ZooKeeper 客户端会话的类似物是租约。

领事 K.V.

这里也没有严格的层次结构,但 Consul 可以创建它存在的外观:您可以获取和删除具有指定前缀的所有键,即使用键的“子树”。 此类查询称为递归查询。 此外,Consul 只能选择前缀后不包含指定字符的键,这对应于获取直接“子级”。 但值得记住的是,这正是层次结构的表现:如果父项不存在,则很可能创建一个密钥,或者删除具有子项的密钥,而子项将继续存储在系统中。

五名学生和三个分布式键值存储
Consul 没有监视,而是阻止 HTTP 请求。 本质上,这些是对数据读取方法的普通调用,其中与其他参数一起指示了数据的最后一个已知版本。 如果服务器上对应数据的当前版本大于指定的版本,则立即返回响应,否则 - 当值发生变化时。 还有可以随时附加到密钥的会话。 值得注意的是,与 etcd 和 ZooKeeper 不同,删除会话会导致关联键的删除,而有一种模式可以简单地取消会话与它们的链接。 可用的 交易,没有分支机构,但有各种检查。

把它们放在一起

ZooKeeper拥有最严格的数据模型。 etcd 中可用的表达范围查询无法在 ZooKeeper 或 Consul 中有效模拟。 尝试整合所有服务的优点,我们最终得到了一个几乎与 ZooKeeper 界面相同的界面,但有以下重大例外:

  • 序列、容器和 TTL 节点 不支持
  • 不支持 ACL
  • 如果 key 不存在,set 方法会创建它(在 ZK 中 setData 在这种情况下返回错误)
  • set 和 cas 方法是分开的(在 ZK 中它们本质上是同一件事)
  • 擦除方法删除节点及其子树(在 ZK 中,如果该节点有子节点,则删除会返回错误)
  • 对于每个密钥只有一个版本 - 值版本(在 ZK 一共有三个)

拒绝顺序节点是因为 etcd 和 Consul 没有对它们的内置支持,并且用户可以在生成的库接口之上轻松实现它们。

在删除顶点时实现类似于 ZooKeeper 的行为需要为 etcd 和 Consul 中的每个键维护一个单独的子计数器。 由于我们试图避免存储元信息,因此决定删除整个子树。

实施的微妙之处

让我们仔细看看在不同系统中实现库接口的一些方面。

etcd 中的层次结构

事实证明,在 etcd 中维护分层视图是最有趣的任务之一。 范围查询可以轻松检索具有指定前缀的键列表。 例如,如果您需要以 "/foo",你要求一个范围 ["/foo", "/fop")。 但这将返回键的整个子树,如果子树很大,这可能是不可接受的。 一开始我们计划使用一个按键翻译机制, 在 zetcd 中实现。 它涉及在键的开头添加一个字节,等于树中节点的深度。 让我举一个例子。

"/foo" -> "u01/foo"
"/foo/bar" -> "u02/foo/bar"

然后获取该密钥的所有直接子级 "/foo" 可以通过请求范围来实现 ["u02/foo/", "u02/foo0")。 是的,以 ASCII 表示 "0" 紧随其后 "/".

但这种情况下如何实现顶点的移除呢? 原来需要删除该类型的所有范围 ["uXX/foo/", "uXX/foo0") 对于 XX,从 01 到 FF。 然后我们遇到了 操作次数限制 在一笔交易内。

于是,发明了一个简单的密钥转换系统,使得可以有效地实现删除密钥和获取子列表。 在最后一个标记之前添加一个特殊字符就足够了。 例如:

"/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中,有单独的方法用于处理树结构(create、delete、getChildren)和处理节点中的数据(setData、getData)。此外,所有方法都有严格的前提条件:如果节点已经创建,则create将返回错误已创建、删除或设置数据 - 如果它尚不存在。 我们需要一个可以在不考虑键是否存在的情况下调用的 set 方法。

一种选择是采取乐观的方法,例如删除。 检查节点是否存在。 如果存在则调用setData,否则创建。 如果最后一个方法返回错误,请重复一遍。 首先要注意的是,存在性测试是没有意义的。 您可以立即调用create。 成功完成将意味着该节点不存在并且已创建。 否则,create 将返回相应的错误,之后您需要调用 setData。 当然,在调用之间,顶点可能会被竞争调用删除,并且 setData 也会返回错误。 在这种情况下,你可以重新来过,但这值得吗?

如果两种方法都返回错误,那么我们就可以确定发生了竞争删除。 让我们想象一下这个删除是在调用 set 之后发生的。 那么我们试图建立的任何意义都已经被抹去了。 这意味着我们可以假设 set 已成功执行,即使实际上没有写入任何内容。

更多技术细节

在本节中,我们将暂时脱离分布式系统并讨论编码。
客户的主要要求之一是跨平台:Linux、MacOS 和 Windows 上必须支持至少一项服务。 最初我们只针对Linux进行开发,后来开始在其他系统上进行测试。 这引起了很多问题,有一段时间完全不清楚如何解决。 因此,Linux 和 MacOS 现在支持所有三种协调服务,而 Windows 上仅支持 Consul KV。

从一开始,我们就尝试使用现成的库来访问服务。 就 ZooKeeper 而言,选择在于 动物园管理员 C++,最终未能在 Windows 上编译。 然而,这并不奇怪:该库被定位为仅限 Linux。 对于领事来说唯一的选择是 PP领事。 必须添加支持 会议 и 交易。 对于etcd,没有找到支持最新版本协议的成熟库,所以我们简单地 生成的grpc客户端.

受到 ZooKeeper C++ 库的异步接口的启发,我们决定也实现一个异步接口。 ZooKeeper C++ 为此使用 future/promise 原语。 不幸的是,在 STL 中,它们的实现非常有限。 例如,没有 然后方法,当未来的结果可用时,它将传递的函数应用于未来的结果。 在我们的例子中,需要这样的方法将结果转换为我们库的格式。 为了解决这个问题,我们必须实现自己的简单线程池,因为根据客户的要求,我们不能使用 Boost 等重型第三方库。

我们当时的实现是这样的。 调用时,会创建一个额外的 Promise/Future 对。 返回新的 future,并将传递的 future 与相应的函数和一个附加的 Promise 一起放入队列中。 池中的线程从队列中选择多个 future,并使用 wait_for 轮询它们。 当结果可用时,将调用相应的函数并将其返回值传递给 Promise。

我们使用相同的线程池来执行对 etcd 和 Consul 的查询。 这意味着底层库可以被多个不同的线程访问。 ppconsul 不是线程安全的,因此对它的调用受锁保护。
您可以从多个线程使用 grpc,但有一些微妙之处。 在 etcd 中,手表是通过 grpc 流实现的。 这些是某种类型消息的双向通道。 该库为所有监视创建一个线程,并为处理传入消息创建一个线程。 所以 grpc 禁止并行写入流。 这意味着在初始化或删除手表时,必须等到上一个请求发送完成后才能发送下一个请求。 我们用于同步 条件变量.

见自己: 利奥夫克夫.

我们的队伍: 拉德·罗曼诺夫, 伊万·格卢申科夫, 德米特里·卡马尔迪诺夫, 维克托·克拉皮文斯基, 维塔利·伊万宁.

来源: habr.com

添加评论