Resmo 是一款使用 API 从云和 SaaS 工具收集配置数据的工具,用户可以使用 SQL 探索这些数据以提出他们想要的任何问题。Resmo 附带数千条预构建的基于 SQL 的规则和问题,并通过过滤器、自由文本搜索或图表提供收集数据的可视化探索功能。客户可以创建自己的规则或使用自动化来通过各种渠道接收通知,当数据或规则状态发生变化时。
从数千个 API 收集配置数据会导致大量网络调用,尤其是在考虑需要多个请求才能检索完整数据的 RESTful API 时。尽管 API 服务大部分时间都在运行,但由于各种原因,每个服务或每个客户都可能会出现偶发性问题,例如网络故障或服务器宕机。因此,拥有可靠的可观察性对于监控系统运行状况、检测异常和快速识别故障根本原因至关重要。
然而,传统的日志方法可能过于冗长且难以查询。另一方面,聚合指标可能无法提供足够的上下文,使其在检测和诊断特定问题方面不太有用。因此,Resmo 利用跟踪,这提供了对请求流及其相关响应的更好视图。这种方法使我们能够有效地存储和查询跟踪,考虑到每个客户每 10 分钟数千个 API 调用,跟踪的数量会迅速增加。如今,Resmo 数据收集每天产生超过 3 亿个跨度,并且随着客户规模的扩大而迅速增长。
对于大量跨度的常用方法是采样。但是,这会导致盲点,使难以识别很少发生的执行非正常路径中的问题。为了避免这种情况,Resmo 选择使用 OpenTelemetry 进行完整跟踪(无采样),但我们仍然需要成本效益。许多供应商按摄取的事件数和每 GB 的数据量收费,如果不进行任何采样,可能会花费很多钱。此外,只有少数供应商允许您对这些数据运行自定义 SQL 查询。
起初,我们想使用 S3 和 Athena。我们已经对 OpenTelemetry 收集器进行了分叉,以便直接生成 Parquet 文件到 S3,添加非常常见的跨度属性(即租户和用户 ID)作为其他列,以便高效地查询。但是,即使 Athena 可以查询大量数据,它也存在固定的 2-3 秒启动延迟。对于最简单的查询来说,这可能会令人讨厌,例如,我们仅通过 ID 或简单的切片和切块聚合查找跟踪。此外,为了提高效率,Parquet 文件每 60 秒生成一次。Athena 的性能会因生成的文件数量而下降,最终我们仍然会获得延迟数据。认识到 Athena 可能不是前进的最佳方式,我们决定尝试 ClickHouse。OpenTelemetry 贡献者存储库中已经有一个导出器可用。虽然它处于 Alpha 状态,但自从我们 3 个月前的实现以来,我们没有遇到任何问题。
存储和压缩
我们托管自己的 ClickHouse 实例,因为 OpenTelemetry Java 代理的自动检测也可以将敏感值放入跨度中。这也是解决问题的一种经济有效的方法。使用单个 c7g.xlarge 节点,该节点基于 Graviton3,具有 4 个 CPU 和 400 GB 的预配 EBS 磁盘,我们可以存储超过 40 亿个跨度。这会占用磁盘上的 275 GiB,未压缩时为 3.40 TiB - 压缩率为 92%,非常令人印象深刻!如果查询正在扫描大量数据,计算通常是 IO 绑定的,无法完全利用所有 CPU。如果我们使用具有本地磁盘和 S3 作为分层存储的实例,我们可能会获得更好的性能和更长的保留时间。但是,除非我们扫描数十亿行,否则我们当前的设置基本足够。
定制和经验教训
为了提高常见查询的性能,我们为那些经常在查询、监控器和仪表板中使用的查询添加了物化列:例如租户和用户 ID,以及一些跨度特定字段,例如 URL。这些新列是 LowCardinality 字符串,显著提高了查询性能,而不会影响存储或压缩率。添加新的物化列时,请务必从 system.mutations 表中检查其状态,以避免出现问题。虽然 ALTER TABLE 命令可能很快完成,但实际的物化过程需要读取所有数据并执行表达式。
为了实现分布式跟踪,我们使用了 Opentelemetry 收集器与 ClickHouse 的开箱即用配置和 Java 代理。虽然自动 Java 检测在大多数情况下都有效,但我们还在跨度中添加了一些手动检测,例如上下文特定标签,例如租户 ID、用户 ID、域特定标签或与我们的业务功能相关的自定义度量。
检测、查询和洞察
传统的分布式跟踪服务通常侧重于使用持续时间的基本监控,以及用于服务发现、查找瓶颈和关键路径的简单分组聚合。虽然这些都有用,但编写 SQL 的能力给了我们前所未有的灵活性。作为 Resmo 的 SQL 爱好者,我们渴望将这种灵活性提供给我们的客户,以便他们可以轻松地提出任意问题。
ClickHouse 的另一个有用功能是它可以轻松地连接到 Postgres,允许我们将其用于可观察性查询。例如,我们可以将跨度中的用户和租户 ID 与 Postgres 数据库中的实际账户名称和账户状态进行连接。在我们未来的路线图中,我们希望尝试使用 MaterializedPostgreSQL 引擎来提高性能。我们还计划利用字典来简化一些查询。
我们使用 Grafana 来可视化数据。 ClickHouse 插件 拥有一个不错的查询构建器,也提供了对时间序列过滤有用的宏,允许我们可视化跨时间的跨度。 我们还使用 IntelliJ IDEA 和 DataGrip 连接到 ClickHouse 并编写我们的查询。 CLI 上的进度条使其变得如此棒,让我们清楚地了解查询运行的状况。 起初,看到即使是复杂的查询也能如此快地执行,我们感到很惊讶,但现在我已经习惯了,无法想象使用其他工具。
虽然有一些像 SigNoz 和 Uptrace 这样的开源选项使用 ClickHouse 作为基础,但我们选择直接使用 ClickHouse 来加深对它的理解,特别是当我们开始将一些高流量数据迁移到该平台时。 我们在下面列出了一些我们最常用的查询,以供他人参考。
跨度的平均和百分位持续时间
这是最明显、最容易的查询之一,也是最常用的查询之一。 因为我们经常想要搜索异常值,并从那里深入到特定的时间范围、函数、租户或客户。 请记住,始终包含时间戳过滤器,否则查询将最终扫描所有数据。 为了便于快速探索,我们创建了视图,例如 traces5m、traces1h、traces4h 和 traces1d,以避免每次都通过时间戳进行过滤。 我们还创建了一个参数化视图,它可以过滤任意小时,以简化临时查询。
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 来存储我们的应用程序数据。 跨度是通过 ClickHouse 使用自动检测生成的。 我们为从 SQS 处理的每个传入 HTTP 请求或异步消息创建一个具有 account_id
和 user_id
的新根跨度。
我们产品中的速率限制功能是使用各种键手动检测的。 但是,为了避免测试期间出现错误,E2E 测试免于此检测,并通过 HTTP 标头传递共享密钥以实现快速实现。 为了监控使用情况,在发生速率限制检查的位置,跨度将使用另一个属性进行标识。
由于内部速率限制跨度缺少帐户 ID 属性,因此需要利用 SQL 联接来通过引用根跨度来识别内部跨度中的帐户 ID。 然后将跨度与包含帐户名称的 Postgres 表联接,无需将帐户 ID 复制到每个跨度中,也不需要在手动检测中查找帐户名称。 尽管看起来很复杂,但这是一个很好的例子,说明了我们为什么喜欢 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 的见解
尽管自动检测提供了很多有价值的信息,但遍历所有信息可能会很困难。 映射函数 可以通过计算键的数量和评估其值的唯一性来帮助分析 SpanAttributes。 这种方法可以帮助您更深入地探索数据。 但是,请注意,该查询读取所有 SpanAttributes 映射,这些映射构成大部分跟踪数据。 因此,查询一天的数据可能需要相当长的时间 - 在我们的案例中约为 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 │ 2903676 │ 2907976 │
│ thread.name │ 2811101 │ 312486284 │
│ http.url │ 1737533 │ 31551967 │
│ requestId │ 62068 │ 62078 │
│ net.peer.name │ 1352 │ 147641850 │
│ aws.bucket.name │ 1179 │ 757770 │
│ http_request.referer │ 356 │ 6904 │
│ rpc.method │ 315 │ 28193140 │
│ db.sql.table │ 53 │ 2822582 │
└──────────────────────────────────────┴─────────┴───────────┘
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 查询进行查询,导致仅访问了 53 个表,而 280 万个数据库调用。 像消息或请求 ID 这样的字段往往是唯一的。
分析数据的分布和键可以使您创建物化字段,并提供更多机会更深入地探索数据。
跟踪卫生和调试
尽管自动检测非常有用,但它可能会生成过多的跨度,从而难以遍历它们。 此外,如果您添加自己的跨度和属性,您的数据集可能会变得拥挤,从而使调试更具挑战性。 为了确定您是否正在收集有价值的数据,您可以再次简单地使用 SQL!
一种选择是评估每个跟踪的跨度数量。 这对我们来说尤其重要,因为我们的一些跟踪包含许多网络调用。 每个网络调用都表示为一个跨度。 理想情况下,单个跟踪应尽可能包含单元工作以确保可靠性,而不应包含数千个网络调用。 下面的 SQL 查询计算每个跟踪的跨度数量并创建一个直方图。 为了可视化 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─────────────────────────────────────┐
│ 1 │ 144 │ 6232778.75 │ ███████████████████████████████████████ │
│ 144 │ 453 │ 1263445 │ ███████████████████████████████████ │
│ 453 │ 813 │ 206123.75 │ ██████████████████████████████▌ │
│ 813 │ 1300 │ 28555 │ █████████████████████████▋ │
│ 1300 │ 1844 │ 923.75 │ █████████████████ │
│ 1844 │ 2324 │ 166.125 │ ████████████▊ │
│ 2324 │ 2898 │ 94.5 │ ███████████▎ │
│ 2898 │ 3958 │ 24.625 │ ████████ │
│ 3958 │ 5056 │ 65 │ ██████████▍ │
│ 5056 │ 5537 │ 12.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 个跨度。 这可能是一个问题。 但是,在 Resmo,我们执行了大量分页调用,并且必须在一些集成中对每个分页调用执行额外的调用,因此预期会产生这种级别的跨度量。
搜索 Java 异常消息
由于我们的后端使用 Kotlin,因此自动检测会将任何异常消息以预期格式放置在跨度的 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 仪表板,以直观地识别外部服务性能。
总结
在 3 个月内,我们使用 ClickHouse 部署了一个生产跟踪解决方案,问题很少。 凭借高压缩率、全面的 SQL 支持以及出色的查询时间(即使在中等硬件上也是如此),我们推荐 ClickHouse 作为您的跟踪数据的存储库。
访问:www.resmo.com
要详细了解如何在 ClickHouse 中存储跟踪数据,请参阅我们最近的博客 关于使用 OpenTelemetry 的博客。