简介
ClickHouse 是用于实时应用和分析的最快、资源效率最高的开源数据库。作为其组件之一,ClickHouse Keeper 是 ZooKeeper 的快速、更高资源效率和功能更丰富的替代方案。这个开源组件提供了一个高度可靠的元数据存储,以及协调和同步机制。它最初是为 ClickHouse 开发的,当它作为分布式系统部署在自管理设置或托管服务(如 CloudHouse Cloud)中时使用。但是,我们相信更广泛的社区可以从该项目在其他用例中的应用中受益。
在这篇文章中,我们描述了 ClickHouse Keeper 的动机、优势和开发,并预告了我们接下来计划的改进。此外,我们引入了一个可重复使用的基准测试套件,它允许我们轻松地模拟和基准测试典型的 ClickHouse Keeper 使用模式。在此基础上,我们展示了基准测试结果,这些结果突出表明 ClickHouse Keeper **在保持与 ZooKeeper 相近的性能的同时,对于相同的数据量,使用的内存最多比 ZooKeeper 少 46 倍**。
动机
现代 分布式系统 需要一个共享的、可靠的 信息库 和 共识 系统来协调和同步分布式操作。对于 ClickHouse 来说,最初选择的是 ZooKeeper。它通过广泛的使用而变得可靠,提供了一个简单而强大的 API,并且性能合理。
然而,不仅是性能,资源效率和可扩展性始终是 ClickHouse 的首要 优先事项。ZooKeeper 作为 Java 生态系统项目,并没有很好地融入我们以 C++ 为主的代码库,随着我们以越来越大的规模使用它,我们开始遇到资源使用和操作方面的挑战。为了克服 ZooKeeper 的这些缺点,我们从头开始构建了 ClickHouse Keeper,并考虑了我们项目需要解决的其他需求和目标。
ClickHouse Keeper 是 ZooKeeper 的直接替代品,具有完全兼容的客户端协议和相同的数据模型。除此之外,它还提供以下优点:
- 更轻松的设置和操作:ClickHouse Keeper 是用 C++ 而不是 Java 实现的,因此它可以 嵌入 ClickHouse 或作为独立运行
- 快照和日志由于更好的压缩而占用更少的磁盘空间
- 默认数据包和节点数据大小没有限制(在 ZooKeeper 中为 1 MB)
- 没有 ZXID 溢出 问题(它会在 ZooKeeper 中每 2B 笔事务强制重启)
- 由于使用了更好的分布式共识协议,网络分区后的恢复速度更快
- 额外的 一致性 保证:ClickHouse Keeper 提供与 ZooKeeper 相同的一致性保证 - 线性化 写操作,加上在同一个 会话 中的操作的严格排序。此外,ClickHouse Keeper 还可选地(通过 quorum_reads 设置)提供线性化读操作。
- ClickHouse Keeper 资源效率更高,在相同数据量的情况下使用更少的内存(我们将在本博客的后面部分演示这一点)
ClickHouse Keeper 的开发始于 2021 年 2 月作为 ClickHouse 服务器中的嵌入式服务。同年,引入了独立模式,并添加了 Jepsen 测试 - 每 6 小时,我们都会运行自动化 测试,使用多种不同的工作流和故障场景来验证共识机制的正确性。
在撰写本博客时,ClickHouse Keeper 已经投入生产超过一年半,并已在我们自己的 ClickHouse Cloud 中大规模部署,自 2022 年 5 月首次私密预览发布以来。
在博客的其余部分,我们有时将 ClickHouse Keeper 简称为“Keeper”,因为我们通常在内部这样称呼它。
ClickHouse 中的使用
一般来说,任何需要在多个 ClickHouse 服务器之间保持一致性的操作都依赖于 Keeper。
- Keeper 为自管理 无共享 ClickHouse 集群 中的数据 复制 提供协调系统
- 对于 mergetree 引擎系列的复制表,自动 插入去重 基于保存在 Keeper 中的块哈希和。
- Keeper 为 分区 名称(基于连续的 块 号码)以及为特定集群节点分配分区 合并 和 修改 提供共识
- Keeper 用于 KeeperMap 表引擎 的底层,它允许您将 Keeper 用作具有线性化写操作和顺序一致性读操作的一致性键值存储
- 了解 一个使用它在 ClickHouse 之上实现任务调度队列的应用
- Kafka Connect Sink 使用此表引擎作为可靠的 状态存储,用于 实现 恰好一次传递 保证
- Keeper 跟踪 S3Queue 表引擎 中已消费的文件
- 复制数据库引擎 将所有元数据存储在 Keeper 中。
- Keeper 用于协调 备份 与 ON CLUSTER 子句。
- 用户自定义函数 可以 存储 在 Keeper 中。
- 访问控制 信息可以 存储 在 Keeper 中。
- Keeper 用作 ClickHouse 云 中所有元数据的共享中心存储。
观察 Keeper
在以下部分中,为了观察(并在稍后在基准测试中建模)ClickHouse Cloud 与 Keeper 之间的一些交互,我们从 WikiStat 数据集中加载一个月的数据到 表格 中,该表格位于具有 3 个节点的 ClickHouse Cloud 服务 中。每个节点拥有 30 个 CPU 内核和 120 GB 内存。每个服务都使用自己的专用 ClickHouse Keeper 服务,该服务由 3 台服务器组成,每台 Keeper 服务器拥有 3 个 CPU 内核和 2 GB 内存。
下图说明了此数据加载场景:
① 数据加载
通过数据加载 查询,我们从约 740 个压缩文件(一个文件代表某一天的特定小时)中加载约 46.4 亿行数据,并与所有 3 个 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 Cloud 服务的 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 Cloud 完全 分离 了服务器上的数据和元数据存储。所有数据分区 存储 在共享对象存储中,所有元数据 存储 在 Keeper 中。当 ClickHouse 服务器将新分区写入对象存储(见上面的 ②)或将一些分区合并到新的更大的分区(见上面的 ③)时,该 ClickHouse 服务器将使用 多写 事务请求来更新 Keeper 中关于新分区的元数据。此信息包括分区的名称、属于该分区的哪些文件以及与文件相对应的 blob 在对象存储中的位置。每个服务器都有一个包含元数据子集的本地缓存,并通过 Keeper 实例通过基于 观察 的订阅机制 自动 收到数据更改的通知。
对于我们前面提到的初始分区创建和后台分区合并,总共执行了约 18,000 个 Keeper 请求。这包括约 12,000 个多写事务请求(仅包含写子请求)。所有其他请求都是读写请求的混合。此外,ClickHouse 服务器从 Keeper 收到约 800 个观察通知。
total_requests: 17705
multi_requests: 11642
watch_notifications: 822
我们可以 看到 这些请求是如何从所有 3 个 ClickHouse 节点发送的,以及观察通知是如何接收到的,它们非常均匀。
┌─n─┬─total_requests─┬─multi_requests─┬─watch_notifications─┐
│ 1 │ 5741 │ 3671 │ 278 │
│ 2 │ 5593 │ 3685 │ 269 │
│ 3 │ 6371 │ 4286 │ 275 │
└───┴────────────────┴────────────────┴─────────────────────┘
以下两个图表可视化了数据加载过程中的这些 Keeper 请求: 我们可以看到约 70% 的 Keeper 请求是多写事务。
请注意,Keeper 请求的数量会根据 ClickHouse 集群大小、提取设置和数据大小而有所不同。我们简要说明了这 3 个因素是如何影响生成 Keeper 请求的数量的。
ClickHouse 集群大小
如果我们用 10 个服务器而不是 3 个服务器并行加载数据,我们将以超过 3 倍的速度提取数据(使用 SharedMergeTree)。
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.
服务器数量越多,生成的 Keeper 请求数量就超过 3 倍。
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,000 个而不是 240 个初始分区。
┌─parts─────────┬─rows_avg─────┬─size_avg─┬─rows_total───┐
│ 4.24 thousand │ 1.09 million │ 9.20 MiB │ 4.64 billion │
└───────────────┴──────────────┴──────────┴──────────────┘
这 导致 更多分区合并。
┌─merges─┐
│ 9094 │
└────────┘
并且我们 得到 更多 Keeper 请求(约 147,000 个,而不是约 17,000 个)。
total_requests: 147540
multi_requests: 105951
watch_notifications: 7439
数据大小
同样,如果我们 加载 更多数据(使用每个初始分区的默认值约为 100 万行),例如,来自 WikiStat 数据集的 6 个月数据,那么我们就会为我们的服务获得约 24,000 个初始分区。
┌─parts──────────┬─rows_avg─────┬─size_avg─┬─rows_total────┐
│ 23.75 thousand │ 1.10 million │ 9.24 MiB │ 26.23 billion │
└────────────────┴──────────────┴──────────┴───────────────┘
这 导致 更多合并。
┌─merges─┐
│ 28959 │
└────────┘
导致 约 680,000 个 Keeper 请求。
total_requests: 680996
multi_requests: 474093
watch_notifications: 32779
Keeper 基准测试
我们开发了一个名为 keeper-bench-suite 的基准测试套件,用于对上面探讨的典型的 ClickHouse 与 Keeper 之间的交互进行基准测试。为此,keeper-bench-suite 允许模拟来自由 N
(例如 3)个服务器组成的 ClickHouse 集群的并行 Keeper 工作负载: 我们借助了 keeper-bench,这是一个用于对 Keeper 或任何与 ZooKeeper 兼容的系统进行基准测试的工具。借助该构建块,我们可以模拟和基准测试来自 N
个 ClickHouse 服务器的典型并行 Keeper 流量。此图显示了 Keeper Bench Suite 的完整架构,它允许我们轻松设置和基准测试任意 Keeper 工作负载场景: 我们使用 AWS EC2 实例作为基准测试服务器来执行 Python 脚本,该脚本 ① 通过启动 3 个适当的(例如,m6a.4xlarge)EC2 实例来设置和启动一个 3 节点 Keeper 集群,每个实例运行一个 Keeper docker 容器,以及 2 个带有 cAdvisor 和 Redis(cAdvisor 需要)的容器,用于监视本地 Keeper 容器的资源使用情况
。
② 使用预配置的工作负载配置启动 keeper-bench
③ 每 1 秒抓取每个 cAdvisor 和 Keeper 的 Prometheus 端点
④ 将抓取的指标(包括时间戳)写入 ClickHouse Cloud 服务中的 2 个 表格 中,这些表格是通过 SQL 查询方便地分析指标以及 Grafana 仪表板的基础
请注意,ClickHouse Keeper 和 ZooKeeper 都直接提供 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 的 并发 设置)不同数量的客户端(例如,ClickHouse 服务器)并行向 Keeper 发送请求:例如,3 个、10 个、100 个、500 个、1000 个。
从每个模拟的客户端中,为了模拟短时间运行和长时间运行的 Keeper 会话,我们(使用 keeper-bench 的 迭代 设置)向 Keeper 发送总共 1 万到 1000 万个请求。这旨在测试每个组件的内存使用量是否会随着时间的推移而发生变化。
工作负载
我们模拟了一个典型的 ClickHouse 工作负载,其中包含约 1/3 的写和删除操作以及约 2/3 的读取操作。这反映了这样一个场景:一些数据被提取、合并,然后查询。可以轻松地定义和基准测试其他工作负载。
衡量的指标
Prometheus 端点
我们使用 cAdvisor 的 Prometheus 端点来衡量
- 主内存使用量 (container_memory_working_set_bytes)
- CPU 使用率 (container_cpu_usage_seconds_total)
我们使用 ClickHouse Keeper 和 ZooKeeper 的 Prometheus 端点来衡量其他(所有可用的)Keeper Prometheus 端点指标值。例如,对于 ZooKeeper,许多 JVM 特定的指标(堆大小和使用情况、垃圾回收等)。
运行时间
我们还根据每次运行的最小和最大时间戳来衡量 Keeper 处理所有请求的运行时间。
结果
我们使用 keeper-bench-suite 对 ClickHouse Keeper 和 ZooKeeper 在我们的工作负载下的资源消耗和运行时间进行了比较。我们对每个基准配置运行了 10 次,并将结果存储在 ClickHouse Cloud 服务中的 两个表 中。我们使用了一个 SQL 查询 来生成三个表格结果表
这些结果的列在 这里 描述。
我们在所有运行中使用了 ClickHouse Keeper 23.5
和 ZooKeeper 3.8
(带捆绑的 OpenJDK 11
)。请注意,我们不会在这里打印三个表格结果,因为每个表格都包含 216 行。您可以通过点击上面的链接查看结果。
示例结果
在这里,我们展示了两个图表,我们 过滤 了 Keeper 两个版本都使用 3 个 CPU 内核和 2 GB 内存,并行处理来自 3 个模拟客户端(ClickHouse 服务器)的相同请求大小的行,并对第 99 个百分位数的结果进行了过滤。这些可视化的表格结果在 这里。
内存使用情况
我们可以看到,对于我们模拟的工作负载,ClickHouse Keeper 在处理相同数量的请求时,始终比 ZooKeeper 使用的内存少得多。例如,对于基准运行 ③ 处理由 3 个模拟 ClickHouse 服务器并行发送的 640 万个请求,ClickHouse Keeper 使用的内存比 ZooKeeper 在运行 ④ 中使用的内存少约 46 倍。
对于 ZooKeeper,我们在所有主要运行(①、②、③)中使用了 1 GiB 的 JVM 堆大小配置(JVMFLAGS: -Xmx1024m -Xms1024m
),这意味着已提交的 JVM 内存(保留的堆和非堆内存保证可供 Java 虚拟机使用)大小对于这些运行约为 1 GiB(见上图中的透明灰色条显示了使用了多少)。除了 docker 容器内存使用情况(蓝色条)外,我们还测量了在已提交的 JVM 内存(粉色条)中实际使用的(堆和非堆)JVM 内存量。运行 JVM 本身会有一些轻微的容器内存 开销(蓝色和粉色条的差异)。但是,实际使用的 JVM 内存始终明显大于 ClickHouse Keeper 的整体容器内存使用情况。
此外,我们可以看到 ZooKeeper 在运行 ③ 中使用了完整的 1 GiB JVM 堆大小。我们还进行了额外的运行 ④,为 ZooKeeper 增加了 2 GiB 的 JVM 堆大小,导致 ZooKeeper 使用了其 2 GiB JVM 堆中的 1.56 GiB,并改善了运行时间,与 ClickHouse Keeper 的运行 ③ 的运行时间相匹配。我们在下一张图表中展示了上述所有运行的运行时间。
我们可以从表格结果中看到,在 ZooKeeper 运行期间,会进行几次(主要)垃圾回收。
运行时间和 CPU 使用率
下面的图表显示了上一张图表中讨论的运行的运行时间和 CPU 使用情况(圆圈数字在两个图表中对齐): ClickHouse Keeper 的运行时间与 ZooKeeper 的运行时间非常接近。尽管使用的内存(见上一张图表)和 CPU 明显更少。
扩展 Keeper
我们 观察到,ClickHouse Cloud 经常在与 Keeper 交互时使用多写入事务。我们深入研究一下 ClickHouse Cloud 与 Keeper 的交互,以概述 ClickHouse 服务器使用此类 Keeper 事务的两种主要场景。
自动插入去重
在上面的场景中,服务器-2 ① 处理插入到表 块-wise 中的数据。对于当前块,服务器-2 ② 将数据写入对象存储中的一个新的数据部分,并 ③ 使用 Keeper 多写入 事务 将有关新部分的元数据存储在 Keeper 中,例如,部分文件对应的 blob 在对象存储中的位置。在存储此元数据之前,事务首先尝试将步骤 ① 中处理的块的哈希和存储在 Keeper 中的 去重日志
节点中。如果相同的哈希和值 已存在 于去重日志中,则整个事务 失败(回滚)。此外,步骤 ② 中的数据部分将被删除,因为该部分包含的数据之前已经插入过。这种自动插入 去重 使 ClickHouse 插入 幂等,因此,容错,允许客户端重试插入,而不会造成数据重复。成功后,事务会触发子 watch,并且 ④ 所有订阅了部分元数据节点事件的 Clickhouse 服务器都会被 Keeper 自动通知新的条目。这会导致它们从 Keeper 中获取元数据更新到其本地元数据缓存中。
将部分合并分配给服务器
当服务器-2 决定将一些部分合并成一个更大的部分时,服务器 ① 会使用 Keeper 事务将待合并的部分标记为锁定(以防止其他服务器合并它们)。接下来,服务器-2 ② 将部分合并成一个新的更大的部分,并 ③ 使用另一个 Keeper 事务来存储有关新部分的元数据,这会触发 watch ④ 通知所有其他服务器新的元数据条目。
请注意,上述场景只有在 Keeper 以原子方式和顺序执行这些 Keeper 事务的情况下才能正常工作。否则,两个 ClickHouse 服务器同时并行发送相同数据可能会都无法在去重日志中找到数据的哈希和,导致对象存储中出现数据重复。或者多个服务器会合并相同的部分。为了防止这种情况,ClickHouse 服务器依赖于 Keeper 的全有或全无多写入事务,以及其线性化写入保证。
线性化 vs 多核处理
ZooKeeper 和 ClickHouse Keeper 中的 共识算法,分别是 ZAB 和 Raft,它们都确保多个分布式服务器可以可靠地就相同信息达成一致。例如,上面示例中允许合并哪些部分。
ZAB 是 ZooKeeper 的专用共识机制,至少从 2008 年开始就一直在开发 中。
我们选择 Raft 作为我们的共识机制,是因为它的算法简单且 易于理解,并且在 2021 年我们启动 Keeper 项目时,有一个轻量级且易于集成的 C++ 库。
但是,所有共识算法都是同构的。对于 线性化 写入,(依赖)转换和事务内的写入操作必须按严格顺序依次处理,无论使用哪种共识算法。假设 ClickHouse 服务器正在并行向 Keeper 发送事务,并且这些事务是依赖的,因为它们写入相同的节点(例如,本节开头示例中的 去重日志
)。在这种情况下,Keeper 只能通过严格顺序地执行这些事务及其操作来保证和实现线性化: 为了实现这一点,ZooKeeper 使用单线程 请求处理器 来实现写入请求处理,而 Keeper 的 NuRaft 实现使用单线程 全局队列。
一般来说,线性化使得垂直(更多 CPU 内核)或水平(更多服务器)扩展写入处理速度变得很困难。可以通过分析和识别独立事务并并行运行它们来解决,但目前 ZooKeeper 和 ClickHouse Keeper 都没有实现此功能。这张图表(我们过滤了第 99 个百分位数的结果)突出了这一点: 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 会自动并行执行独立的事务: 使用多 Raft,Keeper 将能够支持具有更高并行读写需求的工作负载,例如,具有 100 个节点的超大型 ClickHouse 集群。
加入 Keeper 社区!
听起来很激动?然后,我们邀请您加入 Keeper 社区。
- 这是 您如何在 ClickHouse 中使用 Keeper 的方法
- 要在 ClickHouse 之外成为 Keeper 的用户,请查看 此 页面,了解何时使用它或不使用它
- 这是 您发布问题的地方;您可以在 X 上关注我们,并 加入 我们的聚会和活动。
我们欢迎您为 Keeper 代码库做出贡献。查看我们的路线图 在这里,查看我们的贡献者指南 在这里。
总结
在这篇博文中,我们介绍了 ClickHouse Keeper 的功能和优势——一个资源高效的开源 ZooKeeper 替代品。我们探讨了我们在 ClickHouse Cloud 中对它的使用,并在此基础上,展示了一个基准套件和结果,突出了 ClickHouse Keeper 在性能指标相当的情况下,始终使用比 ZooKeeper 少得多的硬件资源。我们还分享了我们的路线图以及您可以参与的方式。我们邀请您合作!