DoubleCloud 即将停止运营。现在迁移到 ClickHouse,享受限时免费迁移服务。立即联系我们 ->->

博客 / 工程

ClickHouse Keeper:用 C++ 编写的 ZooKeeper 替代品

author avatar
Tom Schreiber 和 Derek Chia
2023 年 9 月 27 日

简介

ClickHouse 是最快、最资源节约的开源数据库,适用于实时应用和分析。作为其组件之一,ClickHouse Keeper 是一个快速、更资源节约且功能丰富的 ZooKeeper 替代品。这个开源组件提供了一个高度可靠的元数据存储,以及协调和同步机制。它最初是为 ClickHouse 开发的,当 ClickHouse 部署为自管理设置或托管服务(如 CloudHouse Cloud)中的分布式系统时使用。但是,我们相信更广泛的社区可以在其他用例中受益于这个项目。

在这篇文章中,我们描述了 ClickHouse Keeper 的动机、优势和开发,并预告了我们未来计划的改进。此外,我们还介绍了一个可重用基准测试套件,它允许我们轻松地模拟和基准测试典型的 ClickHouse Keeper 使用模式。在此基础上,我们展示了基准测试结果,突出表明 ClickHouse Keeper **在相同数据量的情况下,内存使用量比 ZooKeeper 少 46 倍,同时保持接近 ZooKeeper 的性能**。

动机

现代 分布式系统 需要一个共享的、可靠的 信息库共识 系统来协调和同步分布式操作。对于 ClickHouse 而言,最初选择 ZooKeeper 来完成这项任务。它通过广泛的应用而变得可靠,提供了一个简单而强大的 API,并且性能也还不错。

然而,不仅性能,而且资源效率和可扩展性一直是 ClickHouse 的首要 优先事项。ZooKeeper 作为 Java 生态系统中的一个项目,并不适合我们以 C++ 为主的代码库,并且随着我们在越来越大的规模上使用它,我们开始遇到资源使用和操作方面的挑战。为了克服 ZooKeeper 的这些缺点,我们从头开始构建了 ClickHouse Keeper,并考虑了我们的项目需要解决的额外需求和目标。

ClickHouse Keeper 是 ZooKeeper 的直接替换,具有完全兼容的客户端协议和相同的数据模型。除此之外,它还提供以下优势:

  • 更轻松的设置和操作:ClickHouse Keeper 用 C++ 而不是 Java 实现,因此 可以 嵌入到 ClickHouse 中运行,也可以独立运行。
  • 由于更好的压缩,快照和日志占用的磁盘空间更少。
  • 默认情况下,数据包和节点数据大小没有限制(在 ZooKeeper 中, 1 MB)。
  • 没有 ZXID 溢出 问题(在 ZooKeeper 中,每 20 亿次事务就会强制重启一次)。
  • 由于使用了更好的分布式共识协议,网络分区后恢复速度更快。
  • 额外的 一致性 保证:ClickHouse Keeper 提供与 ZooKeeper 相同的一致性保证 - 线性化 写入,以及在同一 会话 中对操作进行严格排序。此外,ClickHouse Keeper 还提供可选的(通过 quorum_reads 设置)线性化读取。
  • ClickHouse Keeper 更资源节约,在相同数据量的情况下,内存使用量更少(我们将在本博客后面的内容中演示这一点)。

ClickHouse Keeper 的开发 始于 2021 年 2 月,作为 ClickHouse 服务器中的一个嵌入式服务。同年,独立模式被 引入,并且 Jepsen 测试被 添加 - 每 6 个小时,我们使用几种不同的工作流程和故障场景运行自动化的 测试 来验证共识机制的正确性。

在撰写本博客时,ClickHouse Keeper 已投入生产超过一年半,并且自 2022 年 5 月首次私有预览发布以来,一直在我们自己的 ClickHouse Cloud 中大规模部署。

在博客的剩余部分,我们有时会简单地将 ClickHouse Keeper 称为“Keeper”,因为我们内部通常这样称呼它。

在 ClickHouse 中的使用

通常,任何需要在多个 ClickHouse 服务器之间保持一致性的操作都依赖于 Keeper。

观察 Keeper

在以下部分中,为了观察(并在以后在基准测试中建模)ClickHouse 云与 Keeper 之间的一些交互,我们从 WikiStat 数据集加载一个月的数据到 表格 中,该表格位于具有 3 个节点的 ClickHouse 云服务 中。每个节点拥有 30 个 CPU 内核和 120 GB 内存。每个服务使用其自己的专用 ClickHouse Keeper 服务,该服务包含 3 个服务器,每个 Keeper 服务器拥有 3 个 CPU 内核和 2 GB 内存。

以下图表说明了此数据加载场景:Keeper-01.png

① 数据加载

通过数据加载 查询,我们从大约 740 个压缩文件中(一个文件代表一天中特定小时的特定数据)加载了大约 46.4 亿行数据,并 并行 使用所有三个 ClickHouse 服务器在大约 100 秒内完成。单个 ClickHouse 服务器的峰值主内存使用率约为 107 GB。

0 rows in set. Elapsed: 101.208 sec. Processed 4.64 billion rows, 40.58 GB (45.86 million rows/s., 400.93 MB/s.)
Peak memory usage: 107.75 GiB.

② 部分创建

为了存储数据,3 个 ClickHouse 服务器一起 创建 了 240 个初始 部分,存储在 对象存储 中。每个初始部分的平均行数约为 1900 万行。平均大小约为 100 MiB,插入行的总数为 46.4 亿。

┌─parts──┬─rows_avg──────┬─size_avg───┬─rows_total───┐
│ 240.00 │ 19.34 million │ 108.89 MiB │ 4.64 billion │
└────────┴───────────────┴────────────┴──────────────┘

因为我们的数据加载查询使用 s3Cluster 表函数,所以初始部分的创建 均匀分布 在我们 ClickHouse 云服务的 3 个 ClickHouse 服务器上。

┌─n─┬─parts─┬─rows_total───┐
│ 1 │ 86.00 │ 1.61 billion │
│ 2 │ 76.00 │ 1.52 billion │
│ 3 │ 78.00 │ 1.51 billion │
└───┴───────┴──────────────┘

③ 部分合并

在数据加载过程中,在后台,ClickHouse 执行 了 1706 个部分 合并

┌─merges─┐
│   1706 │
└────────┘

④ Keeper 交互

ClickHouse 云完全 分离 了服务器上的数据和元数据的存储。所有数据部分 存储 在共享对象存储中,所有元数据 存储 在 Keeper 中。当 ClickHouse 服务器将新部分写入对象存储(见②以上)或将一些部分合并到新的更大的部分(见③以上)时,此 ClickHouse 服务器将使用 multi-写入事务请求来更新 Keeper 中有关新部分的元数据。此信息包括部分的名称、哪些文件属于该部分以及与文件对应的 blob 在对象存储中的位置。每个服务器都有一个本地缓存,其中包含元数据的子集,并且 自动获取 由 Keeper 实例通过基于 watch 的订阅机制通知的数据更改。

对于我们前面提到的初始部分创建和后台部分合并,总共执行了大约 18k 个 Keeper 请求 执行。其中包括大约 12k 个多写入事务请求(仅包含写入子请求)。所有其他请求都是读取和写入请求的混合。此外,ClickHouse 服务器从 Keeper 收到了大约 800 个监视通知。

total_requests:      17705
multi_requests:      11642
watch_notifications: 822

我们可以 看到 这些请求是如何发送的,以及监视通知是如何从所有三个 ClickHouse 节点中相当均匀地接收到的。

┌─n─┬─total_requests─┬─multi_requests─┬─watch_notifications─┐
│ 1 │           5741 │           3671 │                 278 │
│ 2 │           5593 │           3685 │                 269 │
│ 3 │           6371 │           4286 │                 275 │
└───┴────────────────┴────────────────┴─────────────────────┘

以下两张图表可视化了这些 Keeper 请求 期间 数据加载过程中的 Keeper 请求:Keeper-02.png 我们可以看到,大约 70% 的 Keeper 请求是多写入事务。

请注意,Keeper 请求的数量可能会根据 ClickHouse 集群大小、摄取设置和数据大小而有所不同。我们简要演示了这三个因素如何影响生成的 Keeper 请求数量。

ClickHouse 集群大小

如果我们使用 10 个而不是 3 个服务器并行加载数据,则我们使用 SharedMergeTree 加载数据的速度比使用 3 个服务器快 3 倍以上。

0 rows in set. Elapsed: 33.634 sec. Processed 4.64 billion rows, 40.58 GB (138.01 million rows/s., 1.21 GB/s.)
Peak memory usage: 57.09 GiB.

更高的服务器数量会产生 3 倍以上数量的 Keeper 请求。

total_requests:      60925
multi_requests:      41767
watch_notifications: 3468

摄取设置

对于我们使用 3 个 ClickHouse 服务器运行的 原始 数据加载,我们配置了每个初始部分的最大大小约为 2500 万行,以便以更高的内存使用率为代价提高摄取速度。相反,如果我们 运行 具有每个初始部分约 100 万行的 默认 值的相同数据加载,则数据加载速度会更慢,但每个 ClickHouse 服务器使用的主内存约减少 9 倍。

0 rows in set. Elapsed: 121.421 sec. Processed 4.64 billion rows, 40.58 GB (38.23 million rows/s., 334.19 MB/s.)
Peak memory usage: 12.02 GiB.

并且创建了大约 4 千个而不是 240 个初始部分 创建

┌─parts─────────┬─rows_avg─────┬─size_avg─┬─rows_total───┐
│ 4.24 thousand │ 1.09 million │ 9.20 MiB │ 4.64 billion │
└───────────────┴──────────────┴──────────┴──────────────┘

导致 更多部分合并。

┌─merges─┐
│   9094 │
└────────┘

并且我们 获得 更多 Keeper 请求(大约 147k 个而不是大约 17k 个)。

total_requests:      147540
multi_requests:      105951
watch_notifications: 7439

数据大小

同样,如果我们 加载 更多数据(使用每个初始部分约 100 万行的默认值),例如 WikiStat 数据集中的六个月数据,那么我们将在服务中获得大约 24 千个初始部分。

┌─parts──────────┬─rows_avg─────┬─size_avg─┬─rows_total────┐
│ 23.75 thousand │ 1.10 million │ 9.24 MiB │ 26.23 billion │
└────────────────┴──────────────┴──────────┴───────────────┘

导致 更多合并。

┌─merges─┐
│  28959 │
└────────┘

导致 大约 680k 个 Keeper 请求。

total_requests:      680996
multi_requests:      474093
watch_notifications: 32779

对 Keeper 进行基准测试

我们开发了一个名为 keeper-bench-suite 的基准测试套件,用于对上面探讨的 ClickHouse 与 Keeper 之间的典型交互进行基准测试。为此,keeper-bench-suite 允许模拟来自由 N(例如 3)个服务器组成的 ClickHouse 集群的并行 Keeper 工作负载:Keeper-03.png 我们正在利用 keeper-bench,这是一个用于对 Keeper 或任何与 ZooKeeper 兼容的系统进行基准测试的工具。有了这个构建块,我们可以模拟和基准测试来自 N 个 ClickHouse 服务器的典型并行 Keeper 流量。此图显示了 Keeper 基准测试套件的完整体系结构,它允许我们轻松设置和基准测试任意 Keeper 工作负载场景:Keeper-04.png 我们正在使用 AWS EC2 实例作为基准测试服务器,用于执行 Python 脚本,该脚本 ① 通过启动 3 个合适的(例如 m6a.4xlarge)EC2 实例来设置和启动 3 节点 Keeper 集群,每个实例运行一个 Keeper docker 容器以及两个带有 cAdvisorRedis(cAdvisor 需要)的容器,用于监视本地 Keeper 容器的资源使用情况

② 使用预配置的工作负载配置启动 keeper-bench

③ 每秒抓取每个 cAdvisor 和 Keeper 的 Prometheus 端点

④ 将抓取的指标(包括时间戳)写入 ClickHouse 云服务中的两个 中,这些表是方便地通过 SQL 查询分析指标以及 Grafana 仪表盘的基础

请注意,ClickHouse KeeperZooKeeper 都直接提供 Prometheus 端点。目前,这些端点只有很少的重叠,并且通常提供截然不同的指标,这使得很难比较它们,特别是在内存和 CPU 使用率方面。因此,我们选择使用额外的基于 cAdvisor 的基本容器指标。此外,在 docker 容器中运行 Keeper 使我们能够轻松更改提供给 Keeper 的 CPU 内核数量和内存大小。

配置参数

Keeper 的大小

我们使用不同的 docker 容器大小为 ClickHouse Keeper 和 ZooKeeper 运行基准测试。例如,1 个 CPU 内核 + 1 GB 内存、3 个 CPU 内核 + 1 GB 内存、6 个 CPU 内核 + 6 GB 内存。

客户端和请求的数量

对于每个 Keeper 大小,我们使用 keeper-bench 的 concurrency 设置模拟(并行)不同数量的客户端(例如,ClickHouse 服务器)向 Keeper 发送请求:例如 3、10、100、500、1000。

从这些模拟客户端中的每一个,为了模拟短时间运行和长时间运行的 Keeper 会话,我们使用 keeper-bench 的 iterations 设置向 Keeper 发送了总共 1 万到 1000 万个请求。这旨在测试任一组件的内存使用量是否会随着时间的推移而改变。

工作负载

我们模拟了一个典型的 ClickHouse 工作负载,其中大约 1/3 是写入和删除操作,大约 2/3 是读取操作。这反映了一种场景,其中一些数据被摄取、合并,然后查询。很容易定义和基准测试其他工作负载。

度量指标

Prometheus 端点

我们使用 cAdvisor 的 Prometheus 端点来度量

我们使用 ClickHouse KeeperZooKeeper 的 Prometheus 端点来度量其他(所有可用)Keeper Prometheus 端点指标值。例如,对于 ZooKeeper,许多特定于 JVM 的指标(堆大小和使用率、垃圾回收等)。

运行时

我们还根据每次运行的最小和最大时间戳来度量 Keeper 处理所有请求的运行时。

结果

我们使用 keeper-bench-suite 对 ClickHouse Keeper 和 ZooKeeper 的资源消耗和运行时间进行了比较。我们对每个基准配置运行了 10 次,并将结果存储在 ClickHouse Cloud 服务中的 两个表 中。我们使用了一个 SQL 查询 生成三个表格结果表。

这些结果的列描述在 这里

我们在所有运行中使用了 ClickHouse Keeper 23.5ZooKeeper 3.8.(带有捆绑的 OpenJDK 11)。请注意,我们没有在这里打印三个表格结果,因为每个表都包含 216 行。您可以通过上面的链接查看结果。

示例结果

在这里,我们展示了两个图表,我们 过滤 了第 99 个百分位数的结果,用于 Keeper 版本都使用 3 个 CPU 内核和 2 GB 内存,并行处理来自 3 个模拟客户端(ClickHouse 服务器)的相同请求大小的行。这些可视化的表格结果在 这里

内存使用情况

Keeper-05.png 我们可以看到,对于我们的模拟工作负载,ClickHouse Keeper 在处理相同数量的请求时,始终比 ZooKeeper 使用更少的内存。例如,对于基准运行 ③,处理来自 3 个模拟 ClickHouse 服务器并行发送的 640 万个请求,ClickHouse Keeper 的内存使用量比运行 ④ 中的 ZooKeeper 少约 46 倍。

对于 ZooKeeper,我们对所有主要运行(①、②、③)使用了 1GiB 的 JVM 堆大小配置(JVMFLAGS: -Xmx1024m -Xms1024m),这意味着已提交的 JVM 内存(保留的堆和非堆内存保证可供 Java 虚拟机使用)大小为这些运行约 1GiB(参见上面的图表中透明的灰色条,显示了使用了多少)。除了 docker 容器内存使用情况(蓝色条)之外,我们还测量了已提交的 JVM 内存中实际使用的(堆和非堆)JVM 内存量(粉色条)。JVM 本身运行有一些轻微的容器内存 开销(蓝色条和粉色条之间的差异)。但是,实际使用的 JVM 内存仍然始终明显大于 ClickHouse Keeper 的总容器内存使用量。

此外,我们可以看到 ZooKeeper 在运行 ③ 中使用了完整的 1 GiB JVM 堆大小。我们进行了额外的运行 ④,将 ZooKeeper 的 JVM 堆大小增加到 2 GiB,导致 ZooKeeper 使用了其 2 GiB JVM 堆的 1.56 GiB,运行时间得到了改进,与 ClickHouse Keeper 运行 ③ 的运行时间相匹配。我们将在下一个图表中展示所有运行的运行时间。

我们可以在表格结果中看到,在 ZooKeeper 运行期间发生了几次(主要)垃圾收集。

运行时间和 CPU 使用率

以下图表可视化了之前图表中讨论的运行的运行时间和 CPU 使用率(圆圈中的数字在两个图表中对齐):Keeper-06.png ClickHouse Keeper 的运行时间与 ZooKeeper 的运行时间非常接近。尽管使用了明显更少的内存(参见上一个图表)和 CPU。

扩展 Keeper

我们 观察到 ClickHouse Cloud 通常在与 Keeper 的交互中使用多写事务。我们更深入地了解 ClickHouse Cloud 与 Keeper 的交互,以概括 ClickHouse 服务器使用的此类 Keeper 事务的两种主要场景。

自动插入去重

Keeper-08.png 在上面概述的场景中,服务器-2 ① 处理插入到表中的数据 -方式。对于当前块,服务器-2 ② 将数据写入对象存储中的一个新数据部分,并且 ③ 使用 Keeper 多写 事务 将有关对象存储中新部分的元数据存储在 Keeper 中,例如,与部分文件对应的 blob 存储在对象存储中的位置。在存储此元数据之前,事务首先尝试将步骤 ① 中处理的块的哈希总和存储在 Keeper 中的 去重日志 znode 中。如果相同的哈希总和值 已经 存在于去重日志中,那么整个事务将 失败(回滚)。此外,步骤 ② 中的数据部分被删除,因为该部分包含的数据过去已经插入过。这种自动插入 去重 使 ClickHouse 插入操作 幂等,因此具有容错性,允许客户端重试插入操作,而不会导致数据重复。成功后,事务会触发子 观察,并且 ④ 所有订阅了部分元数据 znode 事件的 Clickhouse 服务器都会被 Keeper 自动通知新的条目。这会导致它们从 Keeper 中获取元数据更新到其本地元数据缓存中。

将部分合并分配给服务器

Keeper-09.png 当服务器-2 决定将一些部分合并成一个更大的部分时,服务器 ① 会使用一个 Keeper 事务将要合并的部分标记为锁定(以防止其他服务器合并它们)。接下来,服务器-2 ② 将部分合并成一个新的更大的部分,并且 ③ 使用另一个 Keeper 事务存储有关新部分的元数据,这会触发观察 ④,通知所有其他服务器关于新的元数据条目。

请注意,上述场景只有在 Keeper 以原子且顺序的方式执行此类 Keeper 事务时才能正常工作。否则,两个 ClickHouse 服务器在同一时间并行发送相同数据可能会导致两者都无法在去重日志中找到数据的哈希总和,从而导致对象存储中的数据重复。或者多个服务器会合并相同的部分。为了防止这种情况,ClickHouse 服务器依赖 Keeper 的全有或全无多写事务以及它的线性化写入保证。

线性化与多核处理

ZooKeeper 和 ClickHouse Keeper 中的 共识算法,分别是 ZABRaft,都确保多个分布式服务器可以可靠地就相同的信息达成一致。例如,上面示例中哪些部分允许合并。

ZAB 是 ZooKeeper 的专用共识机制,至少从 2008 年开始开发 至今

我们选择 Raft 作为我们的共识机制,因为它简单且 易于理解 的算法,以及在 2021 年启动 Keeper 项目时,有可用的轻量级且易于集成的 C++ 库

但是,所有共识算法都是彼此同构的。对于 线性化 写入,(依赖的)转换和事务中的写入操作必须严格按顺序,一次一个地处理,无论使用哪种共识算法。假设 ClickHouse 服务器正在并行向 Keeper 发送事务,并且这些事务是依赖的,因为它们写入相同的 znode(例如,本节开头示例场景中的 去重日志)。在这种情况下,Keeper 只能通过严格顺序地执行这些事务及其操作来保证和实现线性化:Keeper-10.png 为此,ZooKeeper 使用单线程 请求处理器 实现写入请求处理,而 Keeper 的 NuRaft 实现使用单线程 全局队列

通常,线性化使得很难垂直(更多 CPU 内核)或水平(更多服务器)扩展写入处理速度。可以通过分析和识别独立的事务并并行运行它们,但目前,ZooKeeper 和 ClickHouse Keeper 都没有实现这一点。此图表(我们过滤了第 99 个百分位数的结果)突出了这一点:Keeper-07.png ZooKeeper 和 ClickHouse Keeper 都在使用 1、3 和 6 个 CPU 内核,并行处理来自 500 个客户端的总共 128 万个请求。

(非线性化) 读取请求和辅助任务(管理网络请求、批量数据等)的性能,理论上可以利用 ZAB 和 Raft 的 CPU 内核数量进行扩展。我们的基准测试结果通常表明,ZooKeeper 目前比 Clickhouse Keeper 更擅长这样做,尽管我们一直在不断改进我们的性能(三个 最近的 示例)。

Keeper 的下一步:多组 Raft 用于 Keeper,等等

展望未来,我们看到了扩展 Keeper 以更好地支持我们上面描述的场景的必要性。因此,我们在这个项目中迈出了重要的一步,为 Keeper 引入了多组 Raft 协议。

因为,如上所述,扩展非分区(非分片)线性化是不可能的,所以我们将专注于 多组 Raft,我们将在其中 分区 存储在 Keeper 中的数据。这允许更多的事务相互独立(在不同的分区上工作)。通过为每个分区在同一服务器内使用单独的 Raft 实例,Keeper 会自动并行执行独立的事务:Keeper-11.png 使用多组 Raft,Keeper 将能够支持具有更高并行读写需求的工作负载,例如,具有 100 多个节点的非常大的 ClickHouse 集群。

加入 Keeper 社区!

听起来很激动人心?那么,我们邀请您加入 Keeper 社区。

  • 这是 使用 Keeper 与 ClickHouse 的方法。
  • 要在 ClickHouse 之外成为 Keeper 的用户 - 请查看 页面,了解何时使用它或不使用它。
  • 这是 您发布问题的地方;您可以在 X 上关注我们,并 加入 我们的聚会和活动。

我们欢迎您对 Keeper 代码库做出贡献。查看我们的路线图 此处,并查看我们的贡献者指南 此处

总结

在这篇博文中,我们介绍了 ClickHouse Keeper 的功能和优势,它是一个资源高效的开源 ZooKeeper 替代品。我们探讨了 ClickHouse Cloud 中对它的使用,并在此基础上,提供了一个基准测试套件和结果,突出了 ClickHouse Keeper 在性能指标相当的情况下,始终比 ZooKeeper 使用的硬件资源要少得多。我们还分享了我们的路线图和参与方式。欢迎您加入合作!

分享此文章

订阅我们的时事通讯

及时了解功能发布、产品路线图、支持和云服务!
正在加载表单...
关注我们
Twitter imageSlack imageGitHub image
Telegram imageMeetup imageRss image