前置条件
- 你的机器上已安装 clickhouse-local。请参阅 clickhouse-local 设置指南 了解如何开始使用。
你将构建的内容
在 OpenTelemetry 中,每个 trace span 都会携带一组资源属性——也就是描述产生遥测数据的实体的键值元数据 (如服务名称、主机、云区域、Kubernetes pod (容器组) 等) 。这些键的集合会因服务和环境而异,因此很适合使用 ClickHouse 的 Map 类型:键是动态的,并且因应用而异,但任意一行通常只包含其中少数几个。
在本快速入门中,你将使用 clickhouse-local 将来自 CSV file 的真实 OTel trace 数据加载到包含 Map(LowCardinality(String), String) 列的表中,并学习如何对 map 数据进行查询、过滤、聚合和优化。
下载示例数据
该数据集包含从一个演示微服务应用导出的 6,120 个 OTel trace span。每一行都包含 ResourceAttributes 和 SpanAttributes 两列,这两列中是以 JSON 映射形式存储的动态键值对。
将文件保存到一个便于引用的目录中,例如 ~/data/data-otel-traces.csv。
下载 data-otel-traces.csv (2.9 MB)
单行数据如下所示:
Timestamp: 2025-12-26 00:00:45.759467000
TraceId: 0da128e6e3c01bc38b6b43a33e5fa522
SpanId: 3774f759424e4006
ParentSpanId: 2fdd1e5b66605098
SpanName: orders receive
SpanKind: SPAN_KIND_CONSUMER
ServiceName: accountingservice
Duration: 5361
StatusCode: STATUS_CODE_UNSET
ResourceAttributes: {"host.name":"f19476836e47","os.type":"linux","process.pid":"1","process.command_args":"[\"./accountingservice\"]","process.executable.path":"...
SpanAttributes: {"network.transport":"tcp","messaging.destination.name":"orders","messaging.kafka.message.offset":"232260","messaging.message.body.size":"216"...创建表并加载数据
启动 clickhouse-local,并创建下列表,其 schema 与 CSV 相匹配。
关键列是 ResourceAttributes Map(LowCardinality(String), String)——在键类型上使用 LowCardinality,因为 OTel attribute 键通常来自一个相对较小且会重复出现的集合。
CREATE TABLE otel_traces
(
Timestamp DateTime64(9),
TraceId String,
SpanId String,
ParentSpanId String,
SpanName LowCardinality(String),
SpanKind LowCardinality(String),
ServiceName LowCardinality(String),
Duration UInt64,
StatusCode LowCardinality(String),
ResourceAttributes Map(LowCardinality(String), String),
SpanAttributes Map(LowCardinality(String), String)
)
ENGINE = MergeTree()
ORDER BY (ServiceName, SpanName, toUnixTimestamp(Timestamp));现在使用 file 表引擎加载 CSV。请将路径改为你保存该文件的位置:
INSERT INTO otel_traces
SELECT * FROM file('~/data/data-otel-traces.csv', CSVWithNames);确认数据已成功加载:
SELECT count() FROM otel_traces;你应该会看到 6,120 行。
查询数据
访问特定键 — 使用方括号语法从 map 中取出值。如果某一行中不存在该键,则会返回该值类型的默认值 (String 的默认值为空字符串) :
SELECT
ServiceName,
SpanName,
ResourceAttributes['host.name'] AS host,
ResourceAttributes['k8s.pod.name'] AS pod,
ResourceAttributes['deployment.environment'] AS env
FROM otel_traces
LIMIT 10;按 Map 值过滤 — 查找特定服务名称的所有 spans:
SELECT
Timestamp,
SpanName,
Duration / 1e6 AS duration_ms
FROM otel_traces
WHERE ResourceAttributes['service.name'] = 'cartservice'
ORDER BY Timestamp
LIMIT 10;检查某个键是否存在 —— 并非每个 span 都带有 Kubernetes 元数据。使用 mapContains 找出哪些 span 带有这些元数据:
SELECT
ServiceName,
SpanName,
mapContains(ResourceAttributes, 'k8s.node.name') AS has_node_info
FROM otel_traces
LIMIT 10;查看数据集中出现的所有键 —— 有助于了解埋点产生了哪些内容:
SELECT DISTINCT arrayJoin(mapKeys(ResourceAttributes)) AS key
FROM otel_traces
ORDER BY key;使用 ARRAY JOIN 将 map 展开成多行 — 让每个键值对各自成为一行,便于构建属性清单或为仪表盘提供数据:
SELECT
ServiceName,
key,
value
FROM otel_traces
ARRAY JOIN
mapKeys(ResourceAttributes) AS key,
mapValues(ResourceAttributes) AS value
WHERE ServiceName = 'cartservice'
LIMIT 20;使用 mapFilter 过滤 Map — 仅提取每个 span 中与 Kubernetes 相关的属性:
SELECT
ServiceName,
mapFilter((k, v) -> k LIKE 'k8s.%', ResourceAttributes) AS k8s_attrs
FROM otel_traces
WHERE mapContains(ResourceAttributes, 'k8s.pod.name')
LIMIT 10;查找错误 span 及其资源上下文 — 将常规列过滤条件与 Map 访问结合使用:
SELECT
Timestamp,
ServiceName,
SpanName,
ResourceAttributes['host.name'] AS host,
ResourceAttributes['k8s.pod.name'] AS pod,
SpanAttributes['error.type'] AS error_type,
SpanAttributes['error.message'] AS error_message
FROM otel_traces
WHERE StatusCode = 'STATUS_CODE_ERROR';使用 -Map 组合器对 Map 进行聚合
ClickHouse 的 -Map 聚合组合器可让你将任意聚合函数应用到 Map 列上,并对每个键分别进行聚合。结果本身也是一个 Map——每个键对应一个条目,其中包含聚合后的值。这对 OTel 指标尤其有用,因为 counter 或 gauge 通常存储为 map 值。
为便于演示,先创建一个小型指标表,其中每一行都将 HTTP 状态码计数记录为 Map(String, UInt64):
CREATE TABLE otel_http_status_counts
(
Timestamp DateTime,
ServiceName LowCardinality(String),
StatusCounts Map(String, UInt64)
)
ENGINE = MergeTree()
ORDER BY (ServiceName, Timestamp);
INSERT INTO otel_http_status_counts VALUES
('2025-12-26 10:00:00', 'cart-service', {'2xx': 150, '4xx': 12, '5xx': 3}),
('2025-12-26 10:01:00', 'cart-service', {'2xx': 200, '4xx': 8, '5xx': 1}),
('2025-12-26 10:00:00', 'inventory-service', {'2xx': 90, '4xx': 5}),
('2025-12-26 10:01:00', 'inventory-service', {'2xx': 110, '4xx': 3, '5xx': 2}),
('2025-12-26 10:00:00', 'payment-service', {'2xx': 50, '5xx': 10}),
('2025-12-26 10:01:00', 'payment-service', {'2xx': 45, '4xx': 2, '5xx': 15});现在使用 sumMap 按服务汇总各状态码的计数:
SELECT
ServiceName,
sumMap(StatusCounts) AS total_by_status
FROM otel_http_status_counts
GROUP BY ServiceName;-Map 后缀可用于任何聚合函数,因此你同样可以轻松使用 minMap、maxMap 或 avgMap:
SELECT
ServiceName,
avgMap(StatusCounts) AS avg_by_status,
maxMap(StatusCounts) AS peak_by_status
FROM otel_http_status_counts
GROUP BY ServiceName;你也可以将它与其他组合器结合使用。例如,sumMapIf 可以让你按条件聚合——这里仅对该服务已出现错误的分钟窗口求和:
SELECT
ServiceName,
sumMapIf(StatusCounts, StatusCounts['5xx'] > 0) AS totals_in_error_windows
FROM otel_http_status_counts
GROUP BY ServiceName;**为什么这对 OTel 很重要:**当你的 OTel collector 将按分钟统计的状态码明细写入 ClickHouse 时,sumMap 可让你通过一次查询将其汇总为按小时或按天统计的总计——无需 ARRAY JOIN,无需进行反透视,也无需预先知道完整的键集合。任何在任意行中出现的键都会自动包含在结果中。
针对频繁查询的键进行优化
如果你发现自己总是按同一个 Map 键进行过滤——host.name 就是一个常见例子——可以将它提取为物化列。这样可以避免每次查询时都在线性扫描整个 Map:
ALTER TABLE otel_traces
ADD COLUMN HostName String
MATERIALIZED ResourceAttributes['host.name'];对于现有数据,回填该列:
ALTER TABLE otel_traces MATERIALIZE COLUMN HostName;现在,WHERE HostName = 'prod-cart-01' 只会读取一个专用列,而不是整个 map。对于任何经常查询的 attribute,这都是 OTel ClickHouse schema 中推荐的模式。
关键要点
Map(LowCardinality(String), String)是 OTel 属性的惯用类型——既足够灵活,能处理不断变化的键集合,又能借助LowCardinality保持键存储的高效性。- 方括号语法 (
map['key']) 是访问值最常见的方式,但要记住它是线性扫描——对于只有几十个键的 Map 完全没问题,但对于几百个键就不太理想了。 - 物化列是解决之道:当某个 map 键变成高频过滤目标时,可将其提升为真正的列,以获得带索引的列式访问。
mapContains、mapKeys、mapValues、mapFilter和ARRAY JOIN为你提供了一套强大的工具,让你无需离开 SQL 就能探索和转换 Map 数据。-Map聚合组合器 (sumMap、avgMap、maxMap等) 会跨行按键分别聚合——非常适合对 OTel 指标计数器进行汇总,而无需预先知道键集合。它还可以与其他组合器配合使用 (例如sumMapIf) 。
后续步骤
接下来可查看以下快速入门:
或者深入阅读以下参考文档:
