ClickStack 如何加速 ClickHouse 的可观测性
本文字数:15989;估计阅读时间:40 分钟
作者:Mike Shi
Meetup活动
ClickHouse 上海第3届 Meetup 讲师招募中,欢迎讲师在文末扫码报名!
ClickHouse 已成为现代可观测性 (observability) 的首选存储引擎。其列式架构和执行模型使其在处理海量日志、追踪和宽事件数据时表现出卓越的速度。Netflix、Tesla、Anthropic 和 OpenAI 等公司都依赖它来驱动高要求的遥测 (telemetry) 工作负载。
但仅仅数据库速度并不能保证可观测性性能。为了在繁重、高基数 (high cardinality) 工作负载下持续提供低延迟查询,查询必须经过优化以适应引擎的内部机制。ClickStack 通过将用户界面 (UI) 与 ClickHouse 紧密集成,将优化最佳实践直接嵌入到查询的生成和执行方式中,从而弥补了这一差距。
在本文中,我们将探讨这种集成如何加速当今的可观测性,以及我们计划如何进一步扩展这些优化。
ClickHouse 的设计本身就旨在实现高速。其性能源于存储层和查询处理层两方面的创新。
然而,这些架构优势只有在查询被编写以充分利用它们时,才能转化为实际的速度。可观测性工作负载要求严苛,而编写不佳的查询可能会绕过剪枝 (pruning)、膨胀中间状态,并浪费 CPU 和内存。在 ClickHouse 之上构建可观测性解决方案,不仅仅是简单地暴露任意 SQL 接口。查询必须与引擎存储、剪枝和处理数据的方式保持一致。
即使拥有成熟的查询分析器 (query analyzer),优化仍然至关重要。我们尚未达到所有查询都能自动重写为最有效形式的阶段。
ClickStack 通过将可观测性 UI (observability UI) 与 ClickHouse 本身紧密耦合来解决这个问题。它不仅仅简单地传递用户生成的 SQL (SQL),而是精心构建和重写查询,以确保它们以尽可能最高效的方式执行。这包括将复杂查询分解为更小的阶段、重塑查询以最大化剪枝 (pruning)、以及在考虑 CPU (CPU) 和内存 (memory) 使用的同时最小化数据读取量等技术。其目标是使查询模式 (query patterns) 持续与 ClickHouse 引擎的优势保持一致。
下文我们将探讨其中几项优化,以及我们计划如何随着时间的推移,将它们作为规范化的 API (API) 暴露出来,从而让其他用户在 ClickStack 界面之外也能利用这些查询制定策略。
ClickStack 中最常见的访问模式之一是简单搜索。用户打开搜索对话框以浏览日志 (logs) 或追踪 (traces),通常是最近 15 分钟或最近一小时内的数据。偶尔,他们会将搜索范围扩展到几天甚至几周。用户的意图很少是检索所有数据。相反,他们是在扫描,寻找信号、模式或特定的事件。
关键的见解在于,我们不需要在返回数据之前就获得完整的结果集 (result set)。我们只需足够的行数来填充首个页面。通过增量 (incrementally) 交付结果,用户几乎可以立即看到数据并开始调查。实际上,大多数用户在深入翻阅历史数据之前会细化 (refine) 他们的查询。这种行为使我们能够优化“首次结果时间 (time to first result)”,而不是追求“全范围完整性 (full range completeness)”。一个朴素的 (naive) 实现可能会对整个请求范围发出一个单一的查询:
SELECT *
FROM logs
WHERE timestamp BETWEEN now() - INTERVAL 30 DAY AND now()
ORDER BY timestamp DESC
LIMIT 500;
这会迫使 ClickHouse 在应用偏移量 (offset) 之前,扫描并排序整个 30 天的数据范围,这可能导致读取远超所需的数据量。
ClickStack 则采取了逐步搜索的方式,从最新的时间窗口 (window) 开始:
SELECT *
FROM logs
WHERE timestamp >= now() - INTERVAL 6 HOUR
ORDER BY timestamp DESC
LIMIT 500;
如果找到的行数不足,它会扩展到更旧的时间窗口,例如前 6 小时、然后 12 小时、再 24 小时,且仅在每个限定窗口内应用分页 (pagination)。如果已累积了足够的结果,我们就可以终止后续的扫描。
这种方法与 ClickHouse 的 optimize_read_in_order 能力天然契合。当 ORDER BY 子句与表的主键 (primary key) 对齐时,ClickHouse 无需单独的全局排序即可按键顺序读取数据。在 ClickStack 中,OpenTelemetry 表可以按基于时间的键(例如 toStartOfMinute(timestamp))排序,因此降序时间查询与物理布局保持一致。结合有界时间窗口,这使得 ClickHouse 能够快速返回最新行,同时最大程度减少额外的排序或扫描开销。
图表绘制也采用了类似的技术,但目标有所不同。在搜索场景中,我们优化的是首次结果的快速响应时间,并可能提前终止。而对于图表,用户期望在完整时间范围内获得完整的可视化呈现。因此,我们没有运行一个大型的聚合查询 (aggregation query),而是将时间范围分割成与粒度对齐的窗口并独立执行。
例如,一个 30 天、5 分钟分辨率的图表,否则可能需要对数十亿行数据进行单次聚合。ClickStack 并未将其作为单一的整体查询执行,而是将时间范围划分为与桶对齐的窗口。每个窗口都成为独立的查询,扫描更小部分的分区。
这些查询可以并行运行,其结果在客户端按顺序拼接。窗口与桶边界对齐,以确保聚合桶不会被分割。最终呈现出渐进式加载的效果。
这样做至关重要,因为对数十亿行数据执行单一大型聚合可能独占集群资源,甚至导致查询超时。分块处理限制了每次扫描的范围,从而降低了内存消耗,并支持渐进式渲染。
ClickStack 的另一项早期优化是自动使用物化列 (materialized columns) 处理映射属性 (map attributes)。
可观测性数据本质上是半结构化的。诸如 Kubernetes 标签和 span 属性之类的资源属性,通常使用 Map 类型 以任意键值对的形式存储。这种方式支持灵活的数据摄入,用户无需提前定义所有可能的列。然而,在运行时查询 map 键开销很大。ClickHouse 必须读取整个 map 结构,处理其中所有的键,这会增加 I/O 和 CPU 的使用。
最近,用户开始使用 JSON 类型,该类型会为每个属性创建一个专用的类型化子列。这减轻了 Map 类型的缺点,但同时也带来了额外的插入开销。
考虑一个简化的追踪表 schema:
CREATE TABLE otel.otel_traces
(
`Timestamp` DateTime64(9) CODEC(Delta(8), ZSTD(1)),
`TraceId` String CODEC(ZSTD(1)),
`SpanId` String CODEC(ZSTD(1)),
`ServiceName` LowCardinality(String) CODEC(ZSTD(1)),
`ResourceAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`SpanAttributes` Map(LowCardinality(String), String) CODEC(ZSTD(1)),
`Duration` UInt64 CODEC(ZSTD(1)),
-- Materialized column extracted at ingest time
`PodName` String MATERIALIZED ResourceAttributes['k8s.pod.name'],
INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
INDEX idx_duration Duration TYPE minmax GRANULARITY 1
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, SpanName, toDateTime(Timestamp));
在没有进行物化的情况下,筛选器会是这样:
ResourceAttributes['k8s.pod.name'] = 'payments-7f9d8c'
进行物化后,查询会变为:
PodName = 'payments-7f9d8c'
通过在数据摄入时将属性提取到物理列中,可以避免运行时对 Map 数据的提取操作。ClickHouse 只需读取所需列,而不必扫描和解码整个 Map 结构。此外,常规列还能受益于更好的压缩和更有效的剪枝(pruning)功能。
ClickStack 能够自动检测常用属性何时被物化。如果用户基于 k8s.pod.name 进行筛选,生成的查询会透明地指向 PodName 列。这样,用户在查询常用属性时能获得快速筛选体验,在高数据量下也能保持稳定性能,而无需自行管理 schema 优化。
ClickStack 最近的一项优化是自动使用物化视图。在 ClickStack 中,用户可以从数据源构建仪表板、图表、搜索体验、会话回放和服务地图,其中每个数据源都映射到一个底层的 ClickHouse 表。自今年年初以来,数据源还可以附加一个或多个增量物化视图,这些视图旨在预聚合那些最常见且聚合量大的可视化内容。
在 ClickHouse 中,增量物化视图并非一个静态快照。它更像是处于常驻状态的触发器:当新数据插入到源表时,该视图会对每个插入的数据块运行聚合操作,并将生成的聚合状态写入一个单独的目标表。随着时间的推移,这些局部状态会在后台合并,从而生成与你在查询时聚合原始数据所得到的结果相同,但成本却大大降低。
实际上,用户将查询的成本从查询阶段转移到写入阶段,这些成本会分摊到所有写入操作中,从而使读取时的性能变得轻量且高效。
让我们来看一个具体的例子。假设一个常见的可视化图表需要“按服务和状态码分组的每分钟请求计数和平均持续时间”:
SELECT
toStartOfMinute(Timestamp) AS time,
ServiceName,
StatusCode,
count() AS count,
avg(Duration) AS avg_duration
FROM otel.otel_traces
WHERE Timestamp >= now() - INTERVAL 24 HOUR
GROUP BY time, ServiceName, StatusCode
ORDER BY time;
-- results omitted for brevity
38210 rows in set. Elapsed: 0.790 sec. Processed 166.45 million rows, 2.99 GB (210.65 million rows/s., 3.79 GB/s.) Peak memory usage: 598.18 MiB.
为了避免每次仪表板加载时都从原始 traces(追踪数据)中重新计算这些数据,我们可以创建一个目标表来存储聚合状态:
CREATE TABLE otel.otel_traces_1m
(
`Timestamp` DateTime,
`ServiceName` LowCardinality(String),
`StatusCode` LowCardinality(String),
`count` SimpleAggregateFunction(sum, UInt64),
`avg__Duration` AggregateFunction(avg, UInt64)
)
ENGINE = AggregatingMergeTree
ORDER BY (Timestamp, ServiceName, StatusCode);
接着,我们定义一个增量 物化视图 (Materialized View),它能够在数据写入时持续维护这些聚合状态:
CREATE MATERIALIZED VIEW otel_v2.otel_traces_1m_mv
TO otel.otel_traces_1m
AS
SELECT
toStartOfMinute(Timestamp) AS Timestamp,
ServiceName,
StatusCode,
count() AS count__,
avgState(Duration) AS avg__Duration
FROM otel.otel_traces
GROUP BY Timestamp, ServiceName, StatusCode;
这样,查询预聚合表就变得轻量化,占用资源更少:
SELECT
toStartOfMinute(Timestamp) AS time,
ServiceName,
StatusCode,
sum(count) AS count,
avgMerge(avg__Duration) AS avg_duration
FROM otel_v2.otel_traces_1m
WHERE Timestamp >= now() - INTERVAL 24 HOUR
GROUP BY time, ServiceName, StatusCode
ORDER BY time;
38246 rows in set. Elapsed: 0.027 sec. Processed 41.22 thousand rows, 1.57 MB (1.52 million rows/s., 57.80 MB/s.) Peak memory usage: 21.34 MiB.
在这个示例中,我们的查询速度提升了 30 倍,内存占用减少了 28 倍。
一旦物化视图创建完成,用户只需将其注册到一个数据源即可:
当可视化图表或告警运行时,ClickStack 会评估基础表和所有已注册的视图,为每个兼容的候选项重写查询,并基于 ClickHouse 的 EXPLAIN ESTIMATE 成本模型来选择最优方案。该模型会指示查询需要读取的行数:
EXPLAIN ESTIMATE
SELECT
toStartOfMinute(Timestamp) AS time,
ServiceName,
StatusCode,
sum(count) AS count,
avgMerge(avg__Duration) AS avg_duration
FROM otel.otel_traces_1m
WHERE Timestamp >= (now() - toIntervalHour(24))
GROUP BY
time,
ServiceName,
StatusCode
ORDER BY time ASC
┌─database─┬─table──────────┬─parts─┬──rows─┬─marks─┐
1. │ otel_v2 │ otel_traces_1m │ 1 │ 41220 │ 5 │
└──────────┴────────────────┴───────┴───────┴───────┘
1 row in set. Elapsed: 0.006 sec.
如果有多个物化视图可以满足查询,ClickStack 会自动选择能够最大限度减少扫描行数和数据粒度的视图。如果没有任何兼容的视图,系统会回退到源表进行查询,这样仪表板无需任何修改即可正常运行,同时在条件允许时仍能享受查询加速带来的好处。
从最终用户的角度来看,这种查询加速是完全自动的。用户可以像往常一样构建仪表板和探索数据,无需重写查询、更改图表定义或选择特定的表。当存在兼容的物化视图时,ClickStack 会透明地将查询路由到该视图。
唯一可见的变化是性能的提升,以及用户界面中一个不显眼的加速指示器。一个闪电图标表明当前的可视化数据正从某个物化视图中提供。用户可以点击此图标查看具体选择了哪个视图,并确认查询确实得到了加速。除此之外,用户体验保持不变,只是在大规模数据下也能获得更快的性能。
ClickHouse 提供了多种数据跳过索引 (data skipping indices),包括 MinMax、set、Text 和 Bloom 过滤器。这些索引在 granule 级别存储元数据,通常每个 granule 包含约 8,192 行。它们不索引单个行,而是允许 ClickHouse 在读取整个 granule 之前判断是否可以跳过它。最快处理的数据是您从未读取的数据。
用户可以将 MinMax 索引附加到数值列,将 Bloom 过滤器附加到字符串列,或者将文本索引用于分词全文搜索 (tokenized full text search)。然而,为了有效利用这些索引,查询必须以与索引表达式匹配的方式编写。并非所有函数都能利用所有索引类型。这是 ClickHouse 中一项刻意的设计选择,旨在确保正确性和可预测行为。
ClickStack 会检测表上定义的数据跳过索引,并重写查询,以确保分析器能够正确推断其用途。这确保了使用正确的索引感知函数,从而最大限度地减少 IO 并避免不必要的 granule 扫描。
考虑用户使用 Lucene 风格查询字符串搜索日志的常见情况。他们不会编写 SQL。
考虑全文日志 schema:
CREATE TABLE otel_logs (
Body String,
...
INDEX idx_body_text Body TYPE text(tokenizer = splitByNonAlpha)
)
假设用户在特定时间段内搜索词 "error"。一个简单的实现可能会发出以下查询:
SELECT *
FROM otel_logs
WHERE (Timestamp >= '2026-01-01')
AND (Timestamp < '2026-03-14')
AND (Body ILIKE '% error %');
1 row in set. Elapsed: 0.708 sec. Processed 91.56 million rows, 14.91 GB (129.37 million rows/s., 21.06 GB/s.)
这种做法可行,但未能利用文本索引。然而,ClickStack 会检测到索引可用,并使用专门设计用于利用文本索引的 hasAllTokens() 函数:
SELECT *
FROM otel_logs
WHERE (Timestamp >= '2026-01-01')
AND (Timestamp < '2026-03-14')
AND hasAllTokens(Body, 'error');
1 row in set. Elapsed: 0.029 sec. Processed 2.86 million rows, 22.92 MB (97.87 million rows/s., 784.96 MB/s.)
对于像 "connection refused" 这样的多词短语,ClickStack 会将索引使用与确认过滤器结合起来,以保留排序语义:
SELECT *
FROM otel_logs
WHERE (Timestamp >= '2026-01-01')
AND (Timestamp < '2026-03-14')
AND hasAllTokens(Body, 'connection refused')
AND (lower(Body) LIKE lower('%connection refused%'));
结果是对文本索引进行单次多 token 查找,从而大幅减少了扫描的 granule。
如果要利用 Bloom 过滤器,也需要类似的处理。在这种情况下,ClickStack 会检测用于 Bloom 过滤器索引的表达式,并确保将其与适当的匹配函数结合使用。考虑以下简化的日志 schema:
CREATE TABLE otel_logs (
Body String,
INDEX idx_body_bloom tokens(lower(Body))
TYPE bloom_filter(0.001)
GRANULARITY 8
)
注意:我们将主体转换为小写,以实现不区分大小写的匹配。
假设用户搜索“error”,这不仅需要使用 hasToken 函数,还需要将其与 lower 函数结合使用,以确保索引被有效利用。ClickStack 能够检测到此表达式,并在最终转换的 SQL 中体现如下:
SELECT *
FROM otel_logs
WHERE (Timestamp >= '2026-01-01')
AND (Timestamp < '2026-03-14')
AND hasAll(
tokens(lower(Body)),
tokens(lower('error'))
);
关键在于,查询的左侧表达式必须与存储的索引表达式完全匹配。这使得 ClickHouse 能够激活布隆过滤器 (Bloom filter),从而跳过那些确定不包含该 token 的数据颗粒 (granules)。
同样的原理也适用于基于 Map 的列,例如默认 OTel 表中的 LogAttributes 和 ResourceAttributes。这些列通常在 mapKeys(...) 和 mapValues(...) 上拥有布隆过滤器索引,其设计目的就是,当某个属性键或值不存在时,能够跳过相应的数据颗粒。
当用户搜索以下内容时:
LogAttributes.error.message:"Failed"
ClickStack 必须做的不仅仅是简单地将其转换为:
LogAttributes['error.message'] ILIKE '%Failed%'
为了激活 mapKeys(LogAttributes) 上的布隆过滤器,ClickStack 会附加一个索引提示 (index hint),向查询优化器 (planner) 传递信号,表明正在访问该键:
AND indexHint(mapContains(LogAttributes, 'error.message'))
这个提示并不会改变查询的正确性,它只是告知 ClickHouse 返回那些匹配过滤条件的数据颗粒,但无需实际读取它们(从而节省了 Map 数据的 I/O 访问)。这使得 ClickHouse 能够跳过那些完全不包含该键的整个数据颗粒。对于高基数半结构化数据,这能够在任何行级别评估发生之前,就能排除数据集的大部分内容。
ClickHouse 中的跳过索引功能强大,但它们仅在查询表达式与索引定义精确匹配时才能发挥作用。函数使用上的微小差异可能决定是跳过数据颗粒还是全面扫描,进而直接影响查询的性能——或快或慢。
通过检查 Schema 定义并重写查询,使其精确匹配索引表达式,ClickStack 确保已定义的索引能够被实际利用,从而在无需用户手动调优 SQL 的情况下,提供可预测的性能表现。
在 ClickHouse 中,主键在数据剪枝 (data pruning) 中扮演着核心角色。与传统数据库中主键用于强制实现唯一性不同,在 ClickHouse 中,主键定义了数据的物理存储和排序顺序。因此,那些使用与主键对齐的表达式进行过滤或排序的查询,能够使得查询引擎快速排除大量数据范围,而无需进行全量扫描。
在 ClickStack 中,用户可以自由定义自己的模式(schema)和主键(primary key),并使其与常见的访问模式保持一致。不过,我们为 OpenTelemetry 日志、跟踪和指标提供了明智的默认配置,这些配置针对常见的可观测性工作负载进行了优化。这些配置通常将时间分量与时间戳(Timestamp)结合。例如,一个常见的主键可能如下所示:
ORDER BY (toStartOfMinute(Timestamp), ServiceName)
这种结构允许查询根据时间和服务高效地剪枝数据。
为确保主键得到充分利用,ClickStack 会重写时间戳过滤器,使其与键中使用的表达式保持一致。例如,如果用户基于某个时间范围进行过滤,初始查询可能如下所示:
SELECT *
FROM otel_logs
WHERE Timestamp >= '2026-03-14 10:00:00'
AND Timestamp < '2026-03-14 11:00:00'
ORDER BY Timestamp DESC;
如果表按 toStartOfMinute(Timestamp) 排序,ClickStack 会扩充过滤器以匹配键表达式:
SELECT *
FROM otel_logs
WHERE toStartOfMinute(Timestamp) >= toStartOfMinute('2026-03-14 10:00:00')
AND toStartOfMinute(Timestamp) < toStartOfMinute('2026-03-14 11:00:00')
AND Timestamp >= '2026-03-14 10:00:00'
AND Timestamp < '2026-03-14 11:00:00'
ORDER BY toStartOfMinute(Timestamp) DESC;
通过在过滤器中包含主键表达式,ClickHouse 可以更彻底地剪枝分区(partitions)和粒度(granules)。在实践中,这能显著减少扫描的数据量。
同样,当主键使用更粗粒度的表达式(例如 toStartOfDay(Timestamp))时,此优化同样适用。ClickStack 会自动在派生表达式和原始时间戳上添加过滤器,以确保在狭窄的时间窗口内实现精确过滤,同时仍能高效地进行索引剪枝。在内部测试中,这种方法使查询延迟降低了约 25%,对于复杂查询,可获得更大的性能提升。
ClickStack 的某些功能需要分析海量数据集以生成可视化洞察(visual insights)。对数十亿行数据执行这些查询将消耗大量计算资源,并可能显著增加延迟和资源消耗。为了保持界面响应速度,同时仍能提供准确的洞察,ClickStack 采用了智能采样技术,该技术在减少读取数据量的同时,仍能保留具有代表性的结果。
采样的目标不仅仅是简单地减少数据集大小,它还必须确保在必要时样本是确定性的,并且在统计学上能代表更大的数据集。根据功能的不同,ClickStack 会采用不同的采样策略,以在准确性和性能之间取得平衡。
以下是 ClickStack 中采样应用的几个示例。
事件增量(Event Deltas)——确定性部分偏移采样
事件增量 (Event Deltas) 功能 旨在比较选定时间序列区域内事件的属性分布(称之为“异常值”),与该区域外事件的属性分布(称之为“正常值”)。为此,它需要从每个组中检索一小部分具有代表性的事件的完整行数据。
例如,假设用户将持续时间介于 500 到 1000 之间、且处于特定日期范围内的事件子集选作“正常值”。一种朴素的采样方法可能会尝试通过以下方式获取数据行:
SELECT *
FROM otel_traces
WHERE Timestamp >= 1700000000
AND Timestamp <= 1700003600
AND Duration >= 500
AND Duration <= 1000
ORDER BY rand()
LIMIT 1000;
然而,在 ClickHouse 中,LIMIT 操作是在数据行被读取和过滤之后才应用的。当它与 ORDER BY rand() 结合使用时,会导致全表扫描和全局排序。
ClickStack 则采用了一种基于内部行地址的两阶段确定性采样技术。
WITH PartIds AS (
SELECT tuple(_part, _part_offset)
FROM otel_traces
WHERE Timestamp >= 1700000000
AND Timestamp <= 1700003600
AND Duration >= 500
AND Duration <= 1000
ORDER BY cityHash64(SpanId) DESC
LIMIT 1000
)
_part 和 _part_offset 列表示数据行在 ClickHouse 分区中的内部存储位置。为了确保样本在不同查询之间保持一致性,ClickStack 使用 cityHash64(SpanId) 对数据行进行排序。由于 Span ID 是随机生成的标识符,它们的哈希值能够使数据行均匀分布。这样便无需依赖 rand() 函数即可生成稳定的样本。此外,有效样本大小也是自适应的,具体计算方式为 sampleSize = clamp(500, ceil(totalRows * 0.01), 5000)。
从此查询返回的偏移量结果用于选择相应的数据行子集。
SELECT *
FROM otel_traces
WHERE Timestamp >= 1700000000
AND Timestamp <= 1700003600
AND Duration >= 500
AND Duration <= 1000
AND indexHint((_part, _part_offset) IN PartIds)
ORDER BY cityHash64(SpanId) DESC
LIMIT 1000;
将这些地址封装在 indexHint() 中,能够让查询规划器剔除不包含所选数据行的颗粒 (granules),同时避免实际读取这些数据。最终结果是一个确定性样本,它避免了对整个数据集进行扫描。
分面 (Facets) 的值分布采样
另一种常见的工作流程是在经过筛选的数据集中展示热门属性值。在 ClickStack 中进行搜索时,分面会与搜索结果一同显示,以揭示存在哪些字段,并为这些字段提供具有代表性的值样本。这有助于用户快速了解数据的结构,并指导他们进一步细化筛选条件。
对数十亿行数据计算精确的分布情况将是十分昂贵的。因此,ClickStack 转而执行自适应的模数采样。例如,假设我们希望为资源属性 http.status_code 生成值。
WITH tableStats AS (
SELECT
count() AS total,
greatest(CAST(total / 100000 AS UInt32), 1) AS sample_factor
FROM otel_logs
WHERE Timestamp >= '2024-01-01'
AND Timestamp < '2024-03-01'
)
SELECT
SpanAttributes['http.status_code'] AS value,
count() AS count
FROM otel_logs
WHERE Timestamp >= '2026-01-01'
AND Timestamp < '2026-03-01'
AND cityHash64(Timestamp, rand()) %
(SELECT sample_factor FROM tableStats) = 0
GROUP BY value
ORDER BY count DESC
LIMIT 100;
sample_factor 会动态调整采样率,以确保无论数据集大小如何,都能处理大约 100,000 行。这使得查询保持快速,同时仍能生成具有代表性的数据分布。
与增量采样技术不同,该查询仍然会扫描匹配的行,但显著减少了传递给 GROUP BY 的行数,而 GROUP BY 是大部分计算开销的来源。
请注意,如果用户希望获取某一列的完整值集,他们可以选择“Show More”以获取对数据集的完整分析。
事件模式采样
ClickStack 还 提供了 Event Patterns 功能,使用户能够识别重复出现的日志模板和异常。
在幕后,该功能使用了 Drain3,这是一种高性能的日志模板挖掘算法。Drain3 通过固定深度的解析树,增量构建相似日志消息的集群,使其即使在大型数据集上也能快速识别模式。
ClickStack 没有在摄取 (ingestion) 阶段运行聚类,而是在查询 (query) 阶段执行。这使用户能够在任何经过筛选的数据子集中动态分析模式。如果在摄取阶段运行聚类,将给 ClickStack 的摄取速率带来巨大的开销,要知道 ClickStack 的摄取速率可以达到每秒数 GB,处理 PB 级数据。
要了解更多关于 Event Patterns 的信息,请参阅我们的 专题博客文章。
为了保持分析的交互性,ClickStack 在聚类之前对具有代表性的事件子集进行采样:
WITH
now64(3) AS ts_to,
ts_to - INTERVAL 900 SECOND AS ts_from,
tableStats AS (
SELECT count() AS total
FROM otel_logs
WHERE TimestampTime >= ts_from
AND TimestampTime <= ts_to
)
SELECT
Body,
TimestampTime,
SeverityText,
ServiceName
FROM otel_logs
WHERE TimestampTime >= ts_from
AND TimestampTime <= ts_to
AND if(
(SELECT total FROM tableStats) <= 10000,
1,
cityHash64(TimestampTime, rand()) % greatest(CAST((SELECT total FROM tableStats) / 10000, 'UInt32'), 1) = 0
)
LIMIT 10000;
该查询会自适应地采样最多 10,000 个事件,以确保聚类能在几秒内完成,同时仍能捕获主要模式和异常模式。
这些采样策略突出了 ClickStack 设计中一个贯穿始终的主题:交互式可观测性需要在准确性、性能和资源使用之间取得平衡,其许多功能都依赖于对底层数据库引擎的巧妙运用。
上述许多优化涉及精心的查询重写或算法技术。然而,ClickStack 性能的很大一部分都得益于与 ClickHouse 配合使用的恰当设置。
ClickHouse 是一个持续演进的系统,几乎每个版本都会引入新的性能特性和执行优化。要充分利用这些改进,用户需要了解其适用场景并启用正确的设置,以确保优化措施得到有效利用。ClickStack 持续跟踪这些进展并相应调整其查询设置,确保新的优化能使可观测性 (Observability) 工作负载受益,且无需用户进行任何手动配置。
其中一个例子是 Top-N 查询优化 (Top-N query optimization)。诸如“显示最新日志”、“热门错误消息”或“最慢请求”之类的查询通常采用 ORDER BY … LIMIT N 的形式。近期 ClickHouse 版本通过 use_skip_indexes_for_top_k 设置,引入了 基于跳跃索引(Skip Index)的 Top-N 过滤 功能。这使得引擎能够在读取任何行之前,利用跳跃索引中的元数据来剔除整个数据颗粒 (granule)。ClickHouse 无需先扫描整个表再进行排序,而是能够提前剪枝大量数据。在对 ClickStack 典型的日志搜索工作负载进行测试时,仅这一项优化就带来了 2-3 倍的性能提升,具体增益取决于数据分布。
另一个近期改进是 跳跃索引(Skip Index)的流式评估(Streaming Evaluation)。过去,ClickHouse 在读取表数据之前会先评估跳跃索引,这可能导致启动延迟,尤其当索引本身较大时。现在,现代版本将索引评估与数据读取交错进行,使得引擎能够在执行过程中动态地跳过数据颗粒 (granule)。
① 索引扫描、数据颗粒选择和 ② 查询执行是并发进行的
这显著减少了查询启动时间,并提升了带 LIMIT 子句的查询性能,因为一旦找到足够数量的行,引擎便能立即停止索引评估和数据读取。更多详情可参见此处。
最后,ClickStack 利用了 延迟物化 (Lazy Materialization) 技术,这是一种较新的优化手段,它将非必要列的加载推迟到查询计划实际需要时才进行。例如,在执行以下查询时:
ORDER BY Timestamp DESC
LIMIT 100;
ClickHouse 可以首先仅使用排序列来识别出 Top 行,然后才为这些特定的行获取其余的列数据。这显著减少了 I/O 和内存使用,对于包含众多属性的宽型可观测性表(Observability Table)尤其有效。
默认情况下,ClickHouse 仅在结果集相对较小时应用此优化。基于典型的 ClickStack 访问模式,我们发现即使是显著更大的结果集也能从这种行为中受益。因此,ClickStack 提高了阈值 (query_plan_max_limit_for_lazy_materialization),使得惰性物化 (lazy materialization) 适用于更广泛的查询。
单独来看,这些改进可能显得微不足道。但总体而言,它们代表了构建高性能可观测性平台的一个重要原则:性能在于持续利用整个技术栈中的微小优化。
上述所有优化都源于一个简单的原因:用户不应该需要考虑如何编写完美的 SQL 查询来分析可观测性数据。ClickStack 抽象并隐藏了这些细节。
目前,上述所有优化主要通过 ClickStack 界面本身公开。用户界面 (UI) 生成查询,应用适当的设置,重写谓词 (predicates),并选择最有效的执行策略。用户只需向他们的数据提问。
我们的长期目标是,通过一套专门构建的 API,使这些优化在 UI 之外也可用。这些 API 不会暴露原始 SQL 端点,而是将常见的可观测性任务抽象为聚焦的操作。例如,一个端点可能用于检索服务的最新错误,识别异常跟踪 (anomalous traces),或者计算随时间变化的延迟趋势 (latency trends)。在内部,这些操作可能涉及多个查询、优化的执行策略和精心调优的设置,但对外它们表现为简单、高阶的函数。
这种方法有几个好处。它允许开发者将 ClickStack 直接嵌入到他们自己的可观测性工作流和应用程序中,而无需深入的 ClickHouse 专业知识。它还为自动化和 AI 驱动的分析提供了更可靠的接口。
我们最近推出的 Notebooks 体验(目前为私有预览版),已经在使用这些内部工具。Notebooks 并未依赖 LLM 来生成复杂的 SQL 查询,而是调用专为特定分析任务设计的端点。这些端点封装了 ClickStack 的最佳查询策略,从而提供更优的性能和更可预测的结果。实际上,这也提高了可靠性,因为大型语言模型目前还无法稳定生成高度优化的 ClickHouse SQL。
展望未来,我们计划逐步开放这些工具供公众使用。外部应用程序将能够直接调用这些工具,或通过 Model Context Protocol (MCP) 等协议进行连接,从而赋能 AI 驱动的可观测性体验。这将使开发者能够构建具有与 ClickStack 接口相同性能表现的自定义工具、助手和工作流。
这是一项持续演进的工作。其中包括定义合适的抽象、构建稳定的 API,以及引入认证和访问控制机制。但我们的目标十分明确:让 ClickStack 的性能优势惠及各处,赋能任何人在 ClickHouse 之上构建快速、可扩展的可观测性解决方案。
我们正为上海活动招募讲师,如果你有独特的技术见解、实践经验或 ClickHouse 使用故事,非常欢迎你加入我们,成为这次活动的讲师,与大家分享你的经验。
/END/
试用阿里云 ClickHouse企业版
轻松节省30%云资源成本?阿里云数据库ClickHouse 云原生架构全新升级,首次购买ClickHouse企业版计算和存储资源组合,首月消费不超过99.58元(包含最大16CCU+450G OSS用量)了解详情:https://t.aliyun.com/Kz5Z0q9G
征稿启示
面向社区长期正文,文章内容包括但不限于关于 ClickHouse 的技术研究、项目实践和创新做法等。建议行文风格干货输出&图文并茂。质量合格的文章将会发布在本公众号,优秀者也有机会推荐到 ClickHouse 官网。请将文章稿件的 WORD 版本发邮件至:[email protected]