博客 / 用户案例

我们如何使用 ClickHouse 存储 OpenTelemetry Traces 并提升我们的可观测性水平

author avatar
Mustafa Akın
2023年4月14日 - 17 分钟阅读

Resmo 是一款使用 API 从云和 SaaS 工具收集配置数据的工具,用户可以使用 SQL 探索这些数据,以提出他们想要的任何问题。Resmo 附带数千个基于 SQL 的预构建规则和问题,还提供通过过滤器、自由文本搜索或图形对收集的数据进行可视化探索的功能。客户可以创建自己的规则或使用自动化功能,以便在数据发生更改或规则状态发生变化时通过各种渠道接收通知。

从数千个 API 收集配置数据会导致大量的网络调用,尤其是在考虑到需要多次请求才能检索完整数据的 RESTful API 时。尽管 API 服务在大多数时候都能正常工作,但由于多种原因,每个服务或每个客户都可能偶尔出现网络故障或服务器中断等问题。因此,拥有坚如磐石的可观测性来监控系统健康状况、检测异常情况并快速识别故障的根本原因至关重要。

然而,传统的日志方法可能过于冗长且难以查询。另一方面,聚合指标可能无法提供足够的上下文,使其在检测和诊断特定问题方面的作用不大。因此,在 Resmo,我们使用追踪,它可以更好地了解请求的流程及其关联的响应。这种方法使我们能够有效地存储和查询追踪,考虑到每位客户每 10 分钟数千次的 API 调用,追踪数据量会迅速增加。如今,Resmo 数据收集每天生成超过 3 亿个 span,并且随着客户规模的扩大,这个数字还在迅速增加。

处理过量 span 的常用方法是采样。然而,这可能会导致盲点,使得难以识别发生在执行的非 happy path 上的问题,而这些问题很少发生。为了避免这种情况,在 Resmo,我们选择使用完整的追踪(不采样)和 OpenTelemetry,但我们仍然需要具有成本效益。许多供应商按摄取的事件数量和每 GB 数据量收费,如果不进行任何采样,这可能会花费很多钱。此外,只有少数供应商允许您对这些数据运行自定义 SQL 查询。

起初,我们想使用 S3 和 Athena。我们 fork 了 OpenTelemetry collector,以将 Parquet 文件直接生成到 S3,并将非常常见的 span 属性(即租户 ID 和用户 ID)添加为额外的列,以便高效查询。但是,即使 Athena 可以查询大量数据,它也具有 2-3 秒的固定启动延迟。这对于最简单的查询来说可能很烦人,即我们仅通过 ID 或简单的切片和切块聚合来查找追踪。此外,为了提高效率,Parquet 文件每 60 秒生成一次。Athena 的性能会因生成的文件数量而降低,最终我们仍然会获得延迟数据。意识到 Athena 可能不是前进的最佳方式,我们决定尝试 ClickHouse。在 OpenTelemetry contrib 仓库中已经有一个可用的导出器。尽管它处于 alpha 状态,但自从我们 3 个月前实施以来,我们没有遇到任何问题。

存储和压缩

我们托管了自己的 ClickHouse 实例,因为 OpenTelemetry Java Agent 的自动检测也可能将敏感值放入 span 中。这也是一种经济高效地解决问题的方法。使用单个 c7g.xlarge 节点,配备 4 个基于 Graviton3 的 CPU 和 400 GB 的预配置 EBS 磁盘,我们可以存储超过 40 亿个 span。这在磁盘上消耗 275 GiB,解压缩后为 3.40 TiB - 压缩率高达 92%,非常令人印象深刻!如果查询扫描大量数据,则计算通常受 IO 限制,并且无法充分利用所有 CPU。如果我们使用带有本地磁盘和 S3 作为分层存储的实例,我们可能会获得更好的性能和更长的保留期。但是,我们当前的设置在扫描数十亿行之前基本足够。

自定义和经验教训

为了提高常用查询的性能,我们为那些在查询、监控和仪表板中经常使用的列添加了 物化列:例如租户 ID 和用户 ID,以及一些 span 特定的字段,如 URL。这些新列是 LowCardinality 字符串,并在不影响存储或压缩率的情况下显着提高了查询性能。在添加新的物化列时,始终从 system.mutations 表中检查其状态,以避免出现问题。虽然 ALTER TABLE 命令可能很快完成,但实际的物化过程需要读取所有数据并执行表达式。

为了实现分布式追踪,我们使用了 Opentelemetry Collector with ClickHouseJava agent 的开箱即用配置。虽然自动 Java 检测在大多数情况下都有效,但我们还在 span 中添加了一些手动检测形式的上下文特定标签,例如租户 ID、用户 ID、特定于域的标签或与我们的业务功能相关的自定义指标。

检测、查询和洞察

传统的分布式追踪服务通常侧重于使用持续时间进行基本监控,以及用于服务发现、查找瓶颈和关键路径的简单 group by 聚合。虽然这些都很有用,但编写 SQL 的能力为我们提供了前所未有的灵活性。作为 Resmo 的 SQL 爱好者,我们热衷于向我们的客户展示这种灵活性,以便他们可以轻松提出任意问题。

ClickHouse 的另一个有用功能是它可以轻松地 连接到 Postgres,从而允许我们在可观测性查询中使用它。例如,我们可以将 span 中的用户 ID 和租户 ID 连接到 Postgres 数据库中的实际帐户名称和帐户状态。在我们的未来路线图中,我们希望尝试 MateralizedPostgreSQL 引擎以提高性能。我们还计划使用 字典来简化一些查询。

对于数据可视化,我们使用 Grafana。ClickHouse 插件,它具有出色的查询构建器,还提供了用于时间序列过滤的有用宏,允许我们可视化 span 随时间的变化。我们还使用 IntelliJ IDEA 和 DataGrip 连接到 ClickHouse 并编写我们的查询。CLI 上的进度条非常棒,它清楚地指示了查询的运行状况。起初,看到即使是复杂的查询也运行得如此之快,这令人惊讶,但现在我已经习惯了,并且无法想象使用其他任何东西。

resmo-query.png

虽然有一些开源选项(如 SigNoz 和 Uptrace)使用 ClickHouse 作为其基础,但我们选择直接使用 ClickHouse,以加深我们对它的理解,特别是当我们开始将一些高容量数据迁移到该平台时。我们在下面收录了一些更常用的查询,以使其他人受益。

Span 的平均持续时间和百分位数持续时间

这是最明显且最容易实现的,但也是最常用的之一。因为我们经常想要搜索异常值,并从那里向下钻取到特定的时间范围、功能、租户或客户。请记住始终包含时间戳过滤器,否则查询将最终扫描所有数据。为了使快速探索更容易,我们创建了视图,如 traces5mtraces1htraces4htraces1d,以避免始终按时间戳进行过滤。我们还创建了一个参数化视图,可以过滤任何任意小时数,以简化临时查询

CREATE VIEW traces AS
SELECT *
FROM otel_traces
WHERE Timestamp > (NOW() - toIntervalHour({hr:Int}))

SELECT COUNT(*) FROM traces(hr=5)

与我们通常期望的相比,扫描所有数据的查询也完成得相当快,并且主要受磁盘带宽的限制。目前我们使用 EBS,但我们也计划尝试使用 S3 支持的存储和本地磁盘、各种 CPU 类型和内存设置。

SELECT
    SpanName,
    COUNT(*),
    avg(DurationMs),
    quantile(0.9)(DurationMs) AS p90,
    quantile(0.95)(DurationMs) AS p95,
    quantile(0.99)(DurationMs) AS p99
FROM otel.otel_traces
WHERE Timestamp > NOW() - toIntervalHour(24)
GROUP BY 1
ORDER BY p99 DESC
LIMIT 10

将 Aurora PostgreSQL 中的数据与 ClickHouse 结合

在 Resmo,我们主要使用 Aurora Postgres 来存储我们的应用程序数据。Span 使用 ClickHouse 通过自动检测生成。我们为每个传入的 HTTP 请求或从 SQS 处理的异步消息创建一个新的根 span,其中包含 account_iduser_id

我们产品中的速率限制功能通过各种键进行手动检测。但是,为了避免在测试期间出现错误,E2E 测试免于此检测,并且通过 HTTP 标头传递共享密钥以实现快速实施。为了监控使用情况,速率限制检查发生的 span 通过另一个属性进行标识。

由于内部速率限制 span 缺少帐户 ID 属性,因此有必要利用 SQL 连接通过引用根 span 在内部 span 中识别此属性。然后,span 与包含帐户名称的 Postgres 表连接,从而无需将帐户 ID 复制到每个 span 并在手动检测中查找帐户名称。虽然它看起来可能很复杂,但它很好地说明了为什么我们喜欢 SQL。

SELECT a.name AS accountName,
       count(1)
FROM otel.otel_traces t
JOIN default.accounts a ON t.account_id = a.id
WHERE Timestamp > NOW() - interval 48 HOUR
  AND ParentSpanId = ''
  AND TraceId IN
    (SELECT DISTINCT TraceId
     FROM otel.otel_traces
     WHERE Timestamp > NOW() - interval 48 HOUR
       AND SpanName = 'RateLimiterService.checkLimit'
       AND SpanAttributes['rate_limiter.bypassed'] = 'true')
  AND account_id != ''
GROUP BY 1

获取 SpanAttributes 的洞察

虽然自动检测提供了大量有价值的信息,但要浏览所有信息可能具有挑战性。Map 函数可以帮助分析 SpanAttributes,方法是计算键的数量并评估其值的唯一性。这种方法可以帮助您更深入地探索数据。但是,需要注意的是,查询会读取所有 SpanAttributes map,这构成了追踪数据的大部分。因此,查询一整天的数据可能需要相当长的时间 - 在我们的例子中约为 90 秒。然而,考虑到此查询读取的大量数据,性能仍然非常出色。请注意,出于隐私原因,已删除部分输出。

WITH x AS
    (
        SELECT untuple(arrayJoin(SpanAttributes)) AS x
        FROM traces1d
    )
SELECT
    `x.1` AS key,
    uniqExact(`x.2`) AS unqiues,
    count(1) AS total
FROM x
GROUP BY 1
ORDER BY 2 DESC
LIMIT 50

┌─key──────────────────────────────────┬─unqiues─┬─────total─┐
│ poller.sqs.message_id                │ 29036762907976 │
│ thread.name                          │ 2811101312486284 │
│ http.url                             │ 173753331551967 │
│ requestId                            │   6206862078 │
│ net.peer.name                        │    1352147641850 │
│ aws.bucket.name                      │    1179757770 │
│ http_request.referer                 │     3566904 │
│ rpc.method                           │     31528193140 │
│ db.sql.table                         │      532822582  │
└──────────────────────────────────────┴─────────┴───────────┘

50 rows in set. Elapsed: 93.042 sec. Processed 314.64 million rows, 141.35 GB (3.38 million rows/s., 1.52 GB/s.)

显然,最常见的键是线程名称,这些名称是自动创建的。然而,我们的 Aurora 数据库始终通过 SQL 查询进行查询,导致在 280 万次数据库调用中仅访问了 53 个表。像消息 ID 或请求 ID 这样的字段往往是唯一的。

分析数据的分布和键可以使您能够创建物化字段,并提供更多机会来更深入地探索数据。

追踪卫生和调试

虽然自动检测可能非常有利,但它可能会生成大量的 span,从而难以浏览它们。此外,如果您添加自己的 span 和属性,则数据集可能会变得拥挤,从而使调试更具挑战性。要确定您是否正在收集有价值的数据,您可以再次简单地使用 SQL!

一种选择是评估每个追踪的 span 数量。这对我们来说尤其重要,因为我们的一些追踪包含许多网络调用。每个网络调用都表示为一个 span。理想情况下,单个追踪应尽可能多地包含单元工作以提高可靠性,而不应包含数千个网络调用。下面的 SQL 查询计算每个追踪的 span 数量并创建直方图。为了在 CLI 中可视化输出,我们可以使用内置的 bar 函数。此外,log 函数用于创建更易于理解的图形。

WITH histogram(10)(total) AS hist
SELECT
    round(arrayJoin(hist).1) AS lower,
    round(arrayJoin(hist).2) AS upper,
    arrayJoin(hist).3 AS cnt,
    bar(log(cnt), 0, 32) AS bar
FROM
(
    SELECT
        TraceId,
        COUNT(*) AS total
    FROM traces1d
    GROUP BY 1
    ORDER BY 2 ASC
)

Query id: 74363105-dc0b-4d07-a591-f4096d7302cf

┌─lower─┬─upper─┬────────cnt─┬─bar─────────────────────────────────────┐
│     11446232778.75 │ ███████████████████████████████████████ │
│   1444531263445 │ ███████████████████████████████████     │
│   453813206123.75 │ ██████████████████████████████▌         │
│   813130028555 │ █████████████████████████▋              │
│  13001844923.75 │ █████████████████                       │
│  18442324166.125 │ ████████████▊                           │
│  2324289894.5 │ ███████████▎                            │
│  2898395824.625 │ ████████                                │
│  3958505665 │ ██████████▍                             │
│  5056553712.5 │ ██████▎                                 │
└───────┴───────┴────────────┴─────────────────────────────────────────┘

10 rows in set. Elapsed: 7.958 sec. Processed 314.47 million rows, 15.41 GB (39.52 million rows/s., 1.94 GB/s.)

在过去的 24 小时内,有超过 1000 个追踪每个都包含超过 1300 个 span。这可能构成一个问题。但是,在 Resmo,我们进行了大量的分页调用,并且必须在某些集成中为每个分页调用执行额外的调用,因此这种 span 量是预期的。

搜索 Java 异常消息

由于我们的后端使用 Kotlin,因此自动检测会将任何异常消息以预期格式放置在 Span 的 Events 字段中。我们可以使用 ClickHouse 字符串函数搜索这些消息。通常,我们会为重要异常创建警报并将其路由到 OpsGenie。然而,数据收集容易出现间歇性错误,因此我们对其进行聚合。以下查询使用 hasToken 函数搜索异常消息中的令牌。您还可以使用 multiSearchAny 函数,但它会在字符串中的任何位置查找,并且可能会产生大量结果。

最令人印象深刻的部分是,此数据根本没有索引(例如,使用 倒排索引),并且搜索一整天的数据仍然可以在 5 秒内完成。

SELECT
    SpanName,
    arrayJoin(Events.Attributes)['exception.message'] AS message,
    count(1)
FROM traces1d
WHERE (message != '') AND hasToken(lower(message), 'exceeded')
GROUP BY
    1,
    2
ORDER BY 3 DESC
LIMIT 20

10 rows in set. Elapsed: 4.583 sec. Processed 314.64 million rows, 20.67 GB (68.66 million rows/s., 4.51 GB/s.)

显示物化列的压缩统计信息

添加物化列可以加快查询速度,但它们也会带来额外的存储成本。在为 SpanAttributes 中的每个属性创建物化列之前,您应该确定性能是否能证明额外的存储成本是合理的。虽然 SpanAttributes 和 ResourceAttributes 占用最多的空间,但目前它们具有令人印象深刻的 14-15 倍压缩率。我们的物化列的大小也可以在结果中看到,其中 http.url 是一个值得注意的例子。尽管未压缩大小为 59 GB,但它压缩为 3.30 GB,压缩率为 18 倍 - 因为大多数 URL 往往是相似的。像 integration_id 这样的低基数列具有更令人印象深刻的 80 倍压缩率。

SELECT
    name,
    name,
    formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
    formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
    round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE table = 'otel_traces' AND default_expression != ''
GROUP BY name
ORDER BY sum(data_compressed_bytes) DESC

┌─name──────────────┬─compressed_size─┬─uncompressed_size─┬──ratio─┐
│ DurationMs        │ 14.64 GiB       │ 14.72 GiB         │   1.01 │
│ http.url          │ 2.57 GiB        │ 46.00 GiB         │  17.92 │
│ topDomain         │ 163.23 MiB      │ 28.19 GiB         │ 176.84 │
│ account_id        │ 96.93 MiB       │ 6.73 GiB          │  71.15 │
│ net.peer.name     │ 79.11 MiB       │ 7.36 GiB          │   95.2 │
│ db_statement      │ 67.39 MiB       │ 4.64 GiB          │  70.51 │
│ integration_id    │ 51.50 MiB       │ 7.17 GiB          │ 142.61 │
│ account_domain    │ 46.33 MiB       │ 4.55 GiB          │ 100.48 │
│ datasave_resource │ 41.01 MiB       │ 4.09 GiB          │ 102.01 │
│ user_id           │ 27.86 MiB       │ 3.74 GiB          │ 137.29 │
│ integration_type  │ 16.89 MiB       │ 3.70 GiB          │  224.5 │
│ rpc.service       │ 12.29 MiB       │ 3.70 GiB          │ 308.63 │
│ rpc.system        │ 12.21 MiB       │ 3.70 GiB          │ 310.65 │
│ account_name      │ 12.15 MiB       │ 3.70 GiB          │ 312.13 │
│ hostname          │ 12.13 MiB       │ 3.70 GiB          │ 312.76 │
└───────────────────┴─────────────────┴───────────────────┴────────┘

一个特别有趣的列是 topDomain,它从自动检测值(如 net.peer.domain)中提取域名,以查明网络问题。虽然不是完美的解决方案,而且这似乎很 hacky,但它提供了足够好的摘要,我们可以在以后深入研究。我们使用以下函数按点拆分反转的域名,获取第 1 个和第 2 个,然后再次反转它。

  `topDomain` String DEFAULT reverse(concat(splitByChar('.', reverse(net.peer.name))[1], '.', splitByChar('.', reverse(net.peer.name))[2]))

通过在摄取时计算和物化此表达式,我们可以轻松创建以下 Grafana 仪表板,以直观地识别外部服务性能

Markdown Image

总结

在 3 个月内,我们使用 ClickHouse 部署了一个生产追踪解决方案,问题极少。凭借高压缩率、完整的 SQL 支持和出色的查询时间(即使在适度的硬件上),我们推荐 ClickHouse 作为追踪数据的存储。

访问:www.resmo.com

要阅读有关在 ClickHouse 中存储追踪数据的更多信息,请参阅我们最近关于使用 OpenTelemetry 的博客

分享此帖子

订阅我们的新闻通讯

随时了解功能发布、产品路线图、支持和云产品!
正在加载表单...
关注我们
X imageSlack imageGitHub image
Telegram imageMeetup imageRss image
©2025ClickHouse, Inc. 总部位于加利福尼亚州湾区和荷兰阿姆斯特丹。