本文字数10545估计阅读时间27分钟作者Spencer Torres概述今天我们正式发布 ClickCannon这是一个用于 ClickHouse 的开源基准测试工具。它最初是一个内部项目旨在为 ClickStack 进行可观测性工作负载的基准测试并提供容量规划建议如今已发展成为一个通用的框架能够大规模地回放数据、模拟用户并测量工作负载性能。在本文中我们将探讨• 基准测试实际可观测性工作负载的挑战• 我们如何以受控吞吐量回放实际生产数据• 实现高吞吐量工作负载生成的并发架构• 我们如何模拟真实的用户行为和查询模式• 我们如何收集、分析和比较基准测试结果ClickCannon 项目地址https://github.com/ClickHouse/ClickCannon。引言随着 ClickStack 在过去一年中被更广泛地采用有一个问题反复出现[https://clickhouse.com/blog/clickstack-a-high-performance-oss-observability-stack-on-clickhouse]运行 ClickHouse 需要多少硬件无论用户是运行 Managed ClickStack 还是自行部署开源堆栈他们都想知道需要多少 CPU、内存和磁盘才能处理他们的可观测性工作负载。要回答这个问题我们必须构建一个容量规划模型这反过来又变成了一项基准测试工作。我们需要了解在实际的可观测性工作负载下不同的 schema、摄取速率 (ingestion rates)、查询模式 (query patterns) 和硬件配置的表现。现有的工具在一定程度上帮助我们解决了问题但没有一个能够提供我们所需的对插入和查询的精确控制。最初它是一个用于可观测性部署容量规划的内部工具最终发展成为一个更加灵活的解决方案。随着项目的不断演进我们逐渐意识到我们构建的已经不仅仅是一个面向可观测性的基准测试框架。实际上我们在不经意间打造了一款适用于 ClickHouse 的通用工作负载测试工具。那些最初用于回放 OpenTelemetry 数据、模拟用户行为以及在大规模场景下测量系统性能的基础能力同样适用于几乎所有的数据写入Insert和查询Query工作负载。正因如此我们决定将它开源。今天我们非常高兴地宣布 ClickCannon 正式开源。这是一款面向 ClickHouse 的通用基准测试工具能够帮助用户对自定义的写入和查询工作负载进行性能测试并支持对吞吐量、并发度以及用户行为等关键参数进行精细化控制。可观测性基准测试面临的挑战如何对可观测性 (Observability) 进行基准测试一个可观测性系统的性能受多种因素交互影响包括表结构table schema、数据摄取ingest速率以及用户产生的查询工作负载。因此任何容量规划模型都需要一种方法来捕获这些变量。我们将从摄取吞吐量ingest throughput着手因为它通常是最重要的输入之一并且用户也最容易进行估算。插入基准测试我们以未压缩吞吐量uncompressed throughput来衡量吞吐量。这是因为在进行工作负载容量规划或从其他平台迁移时大多数可观测性用户已经熟悉并理解这一指标。同时它也避免了由于不同的压缩算法、传输格式和数据特性所引入的各种不确定性。大多数可观测性工作负载都表现为相对稳定的数据流入偶尔伴随峰值与此同时用户持续地搜索日志、过滤追踪traces并探索最新事件。主要挑战在于如何维持特定的摄取吞吐量以复现reproduce特定规模的工作负载。举例来说假设您希望在给定的硬件配置下达到 100 MiB/s 的未压缩吞吐量。我们不是追求 50 MiB/s 或 200 MiB/s而是要使其尽可能接近 100 MiB/s。接下来您还需要以 1 GiB/s 的吞吐量重复此过程并逐步提升至 5 GiB/s约合每月 12.3 PiB的受控吞吐量。查询基准测试与此同时可观测性本身也是一个实时问题。当用户查询最新事件、过滤追踪、检查日志以及在不同时间范围间跳转时数据也在持续不断地被摄取。因此一个有价值的基准测试不能仅仅孤立地衡量插入操作。它需要在数据持续抵达的同时模拟真实的查询工作负载从而使我们能够对不同级别的用户活跃度、并发性以及数据摄取ingest情况进行建模。底层 ClickHouse 的表结构schema也可以通过多种方式进行调优和优化每种选择都会影响存储、数据摄取ingest和查询的性能。我们将在本系列博客文章的第二部分深入探讨这个问题其中我们将展示如何使用 ClickCannon 工具来优化默认的 ClickStack 表结构。使用代表性数据与选择格式在进行任何基准测试之前我们首先需要具有代表性的数据。该项目的一个核心原则是我们希望使用真实的可观测性工作负载进行基准测试而非纯粹的合成数据。合成数据集虽然有助于隔离特定行为但往往无法捕捉到生产系统中常见的数据基数 (cardinality)、分布 (distributions) 和访问模式 (access patterns)。幸运的是我们能够获取到约 2 TiB 来自我们内部环境的真实 OpenTelemetry 追踪trace数据并将其作为我们基准测试的基石。我们将这些数据转换为带有 ZSTD 压缩的 ClickHouse Native format并将其选定为我们的输入数据格式。此前的基准测试已表明这是向 ClickHouse 发送数据最有效的格式能够以最低的开销实现最高的数据摄入吞吐量 (ingest throughput)。鉴于此这也是 OpenTelemetry 收集器向 ClickHouse 插入数据时所采用的格式从而确保它能代表真实世界的工作负载。以 ZSTD 对数据进行压缩不仅有益于磁盘空间利用和写入效率还能确保原始工作负载能代表真实的生产环境。对于希望使用 ClickCanon 的用户可以使用 clickhouse-local 将现有数据集转换为此格式。基准测试写入与限制吞吐能力在获得了真实数据后首要挑战便是控制数据摄入吞吐量 (ingest throughput)。鉴于不同用户的运营规模各异我们的基准测试旨在覆盖广泛的吞吐量和硬件配置范围从每月 1 TiB 到每月 20 PiB 的未压缩数据。我们最初设想的是使用 ClickHouse 客户端。其功能已然极其强大本可以让我们无需编写超出 shell 脚本范围的任何代码。然而在测试了多种客户端设置后我们无法找到一种可靠的方式在网络或磁盘层面限制未压缩数据每秒的字节吞吐量。我的下一个想法是通过自定义代理在 TCP 层面进行限制。ClickHouse 仍将负责 Native 数据的快速多线程解码和插入而我只需编写一个 TCP 速率限制器。尽管这种方法有效但我们仍需要对 ClickHouse 客户端进行编排以便对正在发送的各个数据块获得更多控制稍后详述。如果我们既要达到最高的吞吐量目标又要对数据进行操作那么相比于反复调整 ClickHouse 客户端的 CLI 参数我们更需要一种定制化的方法。鉴于我磁盘上已有用于 TCP 速度限制器的 Go 代码并且曾是ch-go和clickhouse-go这两个项目的主要维护者之一我决定为自己的多线程读取器/插入器编写一个原型。这种方法还让我们能够从数据流中提取更精确的指标例如压缩/未压缩字节数、行数、块数等这些都是在报告最终容量规划建议时至关重要的参考指标。作为 OpenTelemetry ClickHouse exporter 的维护者我也曾使用ch-go进行过实验为我们主要的日志/跟踪循环构建了一个零分配的摄取管道因此对优化并发和内存分配驾轻就熟。如果您不熟悉ch-go与clickhouse-goch-go是一个精简的底层客户端专注于性能优化而clickhouse-go是一个高级客户端其提供的接口更便于开发者使用。两者都具备出色的速度但ch-go提供了我们所需的内存控制能力。最终的管道是使用标准的 Go reader 接口构建的。磁盘管道按以下顺序运行文件、已压缩字节计数器、缓冲读取器、ZSTD 解压缩器、未压缩字节计数器它也兼作我们的速度限制器最后是一个ch-go块解码器。请注意这里需要对数据进行解压缩。因为我们的吞吐量限制是基于未压缩字节的所以节流throttling必须在解压缩之后才能发生。限速器是一个读取组件它以字节/秒为目标并通过反馈循环机制来维持这一目标。一个滑动窗口负责测量实际吞吐量同时一个轻量级控制器会逐步调整工作限制以接近目标值而令牌桶限速器则对每次读取操作强制执行该限制。在管道基础搭建完成后我们将目光转向为 ClickCannon 提供动力并实现所需吞吐量的并发管道。利用并发实现高性能在这个项目中并发既是解决方案也带来了诸多问题。最终的 ClickCannon 配置文件暗示了我们为优化工具并发性而不得不添加的一些可调参数包括 goroutine 数量、块淘汰机制以及缓冲通道大小等。user: enabled: true duration: 1h threads: 10 ramp_duration: 30s clickhouse_dsn: tcp://localhost:9000 connections_per_thread: 2 database: otel table: otel_traces dataset_unix_start: 1758585600 dataset_unix_end: 1758631499 workflows: - type: queries name: Example workload random: false think_time: min: 1s max: 5s time_anchor: now default_time_range: type: uniform round: 1m min: 15m max: 4h default_settings: max_execution_time: 60 time_range_cadence: per_query vars: Example: value Env: prod preflight_cadence: once preflight_queries: - sql: | SELECT max(Timestamp) AS DatasetMaxTS FROM {database:Identifier}.{table:Identifier} binds: [DatasetMaxTS] settings: max_execution_time: 30 queries: - name: Trace count sql: | SELECT count() FROM {database:Identifier}.{table:Identifier} WHERE Timestamp gt; {time_start:DateTime64(3)} AND Timestamp lt; {time_end:DateTime64(3)} settings: max_execution_time: 30 perf: p50: 500ms p90: 3s p95: 2s p99: 5s感兴趣的用户可以在这里找到配置选项的完整描述。https://github.com/ClickHouse/ClickCannon/blob/main/example.yaml首先我需要阐明我们为何需要并发单个线程不足以应对需求。我们很快发现单个线程很容易受到磁盘和 ZSTD 解压的瓶颈限制。在基于 NVMe 存储的 EC2 实例上进行测试时我们观察到的吞吐量仅有区区几百兆字节远低于磁盘的理论吞吐能力。最初我们简单地通过增加 goroutine 数量来解决这个问题每个 goroutine 负责处理自己的文件和数据插入。从架构上看这种简单的端到端管道易于理解和推理但它也将读取和插入的吞吐量紧密耦合在一起。如果解压缓慢插入操作就会停滞如果插入缓慢读取操作就会停滞。我们需要能够独立地扩展读取和插入操作这迫使我们重新思考架构设计。引入工作器为解决这一问题ClickCannon 中设计了两种类型的“工作器”磁盘工作器和插入工作器它们都遵循相同的模式。每个工作器还配备一个调度器使其能够独立扩展和运行。例如我们可以配置一个包含 20 个磁盘工作器和 20 个插入工作器的池。磁盘工作器会持续读取直到块池为空插入工作器会持续插入直到插入队列为空。只要磁盘工作器持续读取块插入工作器就会持续执行插入操作。只要插入工作器完成插入并将块返回到池中磁盘工作器就能继续读取。借助 Go channel (Go channel) 的强大能力系统中的所有组件都能正确地阻塞和协调从而保证整个流程的顺畅运行。我们会根据基准测试配置调整各项参数以确保不同类型的 worker (worker) 拥有充足的 goroutine (goroutine) 资源从而满足我们的吞吐量目标。磁盘调度器 (disk scheduler) 负责为指定的文件集分配足够的 worker并控制每个 worker 的处理速度上限。而插入调度器 (insert scheduler) 则确保始终有 N 个 worker 处于运行状态。需要注意的是ch-go仅提供单线程连接因此我们必须为每个插入 worker (insert worker) 自行处理其重连机制。将读取和插入操作解耦 (decoupling) 后我们获得了独立扩展各个阶段的灵活性。然而整体吞吐量 (throughput) 依然取决于每个 worker 的效率。因此必须精心设计 worker以避免它们成为性能瓶颈 (bottleneck)。可重用 block (block) 队列将磁盘 worker (disk worker) 与插入 worker 连接起来并确保管道 (pipeline) 效率的关键抽象就是 block。磁盘 worker 不再在不同阶段之间直接传递原始字节流 (byte streams)而是将数据解码为可重用的ch-goblock并通过一个共享队列将其传递给插入 worker。这种做法在读取和插入之间建立了一个清晰的边界同时最大限度地减少了内存分配 (allocations) 和内存复制 (memory copies)。ch-go的每一次读取调用都需要一个 block 来将字节流解码。为减少分配开销 (allocation overhead)block 可以在内存中预先分配其中包含已知的列类型 (column types) 和对行数的合理估算。这些 block 会被放入一个池中底层通过 Go channel 实现从而实现 block 的复用。每次读取调用会首先从 block 池 (block pool) 中获取一个 block对其进行解码然后将其推送到一个插入队列 (insert queue) 中该队列是另一个用于存储 block 指针的 Go channel。在插入端ch-go允许我们复用内存中完全相同的 block 结构并将已插入的 block 返回到池中。需要注意的是这种做法在ch-go中是完全安全的这也是其性能优于clickhouse-go模块的原因之一。这意味着我们可以从磁盘读取一个 block根据需要对其进行操作例如将时间戳 (timestamps) 调整为当前时间然后将其插入而无需为列数据 (column data) 进行任何新的内存分配。在每次使用前后虽然数据块block都会被彻底重置但它们所保留的内存则被有意地保留和复用。然而在长时间运行的基准测试中我们观察到内存使用量逐渐增加原因是内部数组为了适应超出预期的块而不断扩大并以扩大后的规模保留以备后续复用。鉴于基准测试可能持续数天乃至数周这种缓慢的内存累积最终成为了一个问题。为了解决这个问题我们增加了块回收block retirement逻辑。在达到可配置的使用次数后数据块会被彻底释放内存池会分配新的块进行替换。同样的原则也适用于连接connection因为其内部缓冲区也可能随时间推移而不断增大。在增加了这些保障措施后我们在同一进程上进行了超过30天的测试内存使用量保持平稳。模拟真实写入对插入操作进行基准测试并非仅仅运行一个插入命令然后读取服务器的响应延迟那么简单。我们需要确保数据块part正以正确的可配置的批处理大小写入磁盘并且数据能够长时间地以目标吞吐量throughput抵达。更重要的是数据的时间分布需要与该吞吐量相匹配。可观测性数据Observability data本质上是基于时序的如果事件时间戳之间的间隔不能真实反映数据插入的速率那么由此产生的存储布局、ClickHouse 合并行为以及查询模式将不再能代表真实的系统行为。例如如果我们以尽可能快的速度回放数据其速率不一定与数据最初捕获时的速率相同。这就是我们设置限速器speed limiter以及调整时间戳shift timestamps的原因。我们可以利用我们 2 TiB 的数据通过调整时间戳使每一行数据都像是以配置的吞吐量实时流入的。这确保了数据随时间推移的密度依然能代表目标工作负载并使数据块part的创建和压缩行为保持足够的真实性从而获得有实际意义的基准测试结果稍后当我们讨论生成数据时请记住压缩这一点。模拟用户在多次调整查询方案后我们意识到需要更先进的解决方案。虽然市面上有许多工具可供选择但没有一个能完全满足我们的特定需求。鉴于我们已自行开发了定制化的数据插入工具顺理成章地将其扩展以支持查询工作负载的需求成为了我们的选择。沿用磁盘和数据插入disk/insert角色所采用的调度器/工作器模式我们将其也应用于用户场景。每个 goroutine轻量级协程代表一个独立的用户——因此扩展用户规模只需增加更多 goroutine 即可实现。每个路由/用户将随机或顺序地遍历列表并执行查询并设置随机预热时间以模拟用户逐渐增加的情况从而避免 “惊群效应” 的发生。在每次查询或整个查询序列执行之前我们还可以运行一组“预检查询”pre-flight queries。这些是基于 ClickHouse 的参数化查询它们可以串联起来用于获取后续查询所需的上下文信息。例如如果我们需要根据服务名称过滤跟踪数据但又不希望硬编码服务名那么一个预检查询就可以从相同时间范围内的数据中随机选择一个服务名并将其结果值绑定到一个变量。随后任何依赖于服务名的查询都可以引用这个参数从而动态注入所需值。结合时间范围采样选项、随机延迟配置以及带种子的随机数生成这为我们运行真实的查询工作流提供了坚实的基础。最后用户查询还会基于当前时间戳进行回溯生成这意味着我们能够正确地采样已写入磁盘的新数据以及已合并的旧数据片段。这为可观测性提供了具有代表性的查询模式精确重现了用户通常查询最新数据的常见访问模式。生成真实的查询工作负载固然有用但如果没有良好的测量它们就只是一堆昂贵的压力测试。我们希望了解模式、索引、设置和硬件配置的改变如何影响实际用户体验。这意味着我们需要从基准测试的每个阶段收集指标并从应用程序而非数据库的角度测量延迟。尽管 ClickHouse 提供了查询日志但由于我们关注的是用户感受到的端到端延迟因此我们从查询调用本身的开始和结束处进行测量。那么这些指标最终去了哪里指标没错就是 ClickHouse监控此类基准测试有两方面。首先我们需要了解 ClickCannon 本身的运行状况例如 worker 是否能跟上、队列是否积压、吞吐量是否稳定以及查询延迟是否符合预期。其次我们需要将 ClickHouse 作为目标数据库进行监控因为基准测试的全部目的就是了解服务器在给定工作负载下的表现。对于 ClickCannon 管道该工具会以计数器 (counters)、仪表 (gauges) 和样本 (samples) 的形式捕获 45 种不同的指标。一个 Go channel 对这些指标进行缓冲然后一个 worker 会定期将它们刷新到独立的 ClickHouse 数据库中。我们使用 Grafana 仪表盘实时监控运行情况然后利用带有 Claude 的 ClickHouse MCP 对结果进行分析和比较。我们一些最宝贵的洞察正是通过 MCP 服务器分析原始指标数据得出的。为了评估模式更改和查询工作负载我们使用带有 ClickHouse MCP 服务器的 Claude 来比较基准测试运行并识别性能差异。ClickHouse 服务的各项指标在一个独立的 Grafana 仪表盘上进行观测。ClickHouse 已经暴露了大量的内部指标并提供了一个内置仪表盘这有助于在基准测试期间理解服务器行为。我们没有重复该功能而是依赖这些现有指标仅将开箱即用的仪表盘复制到 Grafana使 ClickCannon 专注于工作负载生成和测量。该程序有意不自行抓取服务器指标以确保监控行为不会影响被基准测试系统的性能。生成数据发布一个工具后要求用户自行获取 2 TiB 的数据才能运行这相当不便。因此我们还增加了使用代码定义配置文件来生成数据的功能。服务名称和属性映射键的唯一值经过加权和采样生成看起来真实的日志和追踪。尽管它无法像回放真实数据那样提供相同真实的负载和漂亮的 Span但它可以用于模拟边缘案例负载并且通常足够。ClickHouse 具有出色的压缩能力并能从重复的数据序列中获益。如果在不仔细考虑数据分布的情况下重复回放或循环使用少量样本数据集这很容易导致误导性的基准测试结果。在某些情况下合成数据实际上是更好的选择因为它允许显式控制基数 (Cardinality)、值分布和事件模式。我们自己也利用这些生成能力来重现我们没有样本数据的特定用户工作负载。例如我们能够控制追踪瀑布 (Trace Waterfall) 的 Span 数量和键这有助于我们测试“大海捞针式”查询特别是针对映射键以及大型数千个 Span追踪的索引性能。这也有助于缩小合成基准测试与实际观测之间的差距。生成的数据可以有意地降低可预测性从而使我们能够重现某些可观测性环境中常见的混乱工作负载、变化的基数和不均匀的访问模式。ClickHouse 的通用基准测试工具当我们完成可观测性工作负载的基准测试时我们意识到我们已经构建了一个远不止于此的通用工具。同样的基本能力使我们能够在受控吞吐量下重放遥测数据、模拟并发用户、执行真实的查询工作负载并收集详细的性能指标这些能力几乎可以应用于任何 ClickHouse 工作负载。无论是对实时分析、可观测性还是数据仓库进行基准测试其核心需求都保持一致。我们没有将该工具作为内部私有资产而是决定将 ClickCannon 开源使其成为 ClickHouse 的通用基准测试工具。结论当然ClickCannon 目前仍存在一些其他基准测试工具已经解决的不足之处。但它非常契合我们的需求尤其适用于我们关注的工作负载类型。ClickCannon 赋予我们极大的灵活性能够模拟真实的可观测性工作负载定制流水线的每个阶段并尝试那些传统工具难以实现的新想法同时还能满足我们设定的吞吐量目标。在解决了那些隐晦的内存错误并学习如何平衡各种测试参数后我们成功地从一台机器上实现了惊人的吞吐量。如果您想亲自尝试ClickCannon 已在 https://github.com/ClickHouse/ClickCannon 开源。在本系列的下一篇文章中我们将深入探讨如何使用 ClickCannon 对默认的 ClickStack schema 进行基准测试并加以改进。届时我们将详细介绍衡量变更的方法、如何持续优化我们的开箱即用配置以及在此过程中所取得的性能提升。更重要的是我们将展示这些基准测试如何帮助我们为 ClickStack 制定实用的容量规划建议使用户能够根据其指定的数据摄入速率和保留周期获得运行工作负载所需资源的真实估算。ClickHouse是面向 AI 时代打造的高性能实时分析数据库能够以极致性能处理海量数据分析任务。凭借高并发、低延迟和云原生架构ClickHouse 广泛应用于可观测性、数据仓库、实时分析及 AI 数据基础设施等场景。我们致力于帮助企业在公有云平台上构建安全、弹性且高性价比的实时分析与 AI 数据平台加速释放数据价值推动智能化创新与数字化转型。目前Trip.com、DiDi、Meta、Sony、Netflix、Deutsche Bank、Sierra、Cloudflare 等全球领先企业均在使用 ClickHouse 支撑其关键业务和数据分析平台。