本文字数: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 >= {time_start:DateTime64(3)}
              AND Timestamp <= {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-go block,并通过一个共享队列将其传递给插入 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 支撑其关键业务和数据分析平台。

 

Logo

1331

更多推荐