- Published on
把观测数据放进 ClickHouse 意味着什么 — schema、rollup、TTL 与职责划分
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 引言 — 当你需要翻查 30 天的 trace 才能回答一个问题
- 为什么列式存储适合观测数据
- Trace schema — 排序键就是一切
- 日志 schema — 属性该用 Map 还是 JSON
- 分区、TTL 与分层存储
- 用物化视图做 rollup
- 从 collector 写入 ClickHouse
- Prometheus、OpenSearch、ClickHouse 的职责划分
- 结语 — schema 决策事后很难反悔
引言 — 当你需要翻查 30 天的 trace 才能回答一个问题
「我想看过去 30 天里,发往某个支付渠道、耗时超过 3 秒的请求,按租户分布是怎样的。」
这类问题一旦提出,大多数观测技术栈就会卡住。Prometheus 不知道单个请求是什么样子。trace 后端只保留 7 天。如果存进了搜索引擎,翻查 30 天数据的过程中集群会开始晃动。
当观测数据涨到每天数 TB 规模、这类问题又开始反复出现时,你就需要一个分析型数据库了。ClickHouse 常常在这个位置上出现,原因是观测数据的性质天然适合列式存储。
本文会设计一份真实的 schema。内容以 ClickHouse 26.5 stable 为准验证;如果要用长期稳定版本,26.3 LTS 系列是替代方案。25.8 LTS 的支持将在 2026 年 8 月底结束,不建议用于新建设。原生 JSON 类型在 25.3 版本中已经达到生产可用。
为什么列式存储适合观测数据
三件事叠在了一起。
第一,查询很窄。 即便 trace 表里有 25 个字段,「按服务统计 p99」这类查询实际要读的字段也只有服务名和耗时两个。行式存储必须把 25 个字段全部从磁盘读出来,列式存储只读这两个。
第二,值会重复。 服务名只有几十种,span 名只有几百种,状态码也就十来种。相同的值连续排列时,压缩率会变得极高。时间戳靠差分编码收缩,低基数字符串靠字典编码收缩。
第三,写入是只追加的。 观测数据从不会被更新。这与 MergeTree 所假设的工作负载完全吻合。
想体会压缩率有多高,最快的办法是亲自量一遍。
SELECT
table,
formatReadableSize(sum(data_uncompressed_bytes)) AS raw,
formatReadableSize(sum(data_compressed_bytes)) AS compressed,
round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 1) AS ratio
FROM system.columns
WHERE database = 'otel'
GROUP BY table
ORDER BY sum(data_compressed_bytes) DESC;
-- 也可以按字段拆开看。压缩不下去的字段往往占了成本的大头
SELECT
name,
type,
formatReadableSize(data_compressed_bytes) AS compressed,
round(data_uncompressed_bytes / data_compressed_bytes, 1) AS ratio
FROM system.columns
WHERE database = 'otel' AND table = 'otel_traces'
ORDER BY data_compressed_bytes DESC
LIMIT 15;
如果某个字段的压缩比接近 1,原因通常有两种:要么是随机字符串(trace ID、UUID),要么是自由格式文本。trace ID 没办法,但自由文本可以拆到单独的字段里,留出应用不同编解码器的空间。
Trace schema — 排序键就是一切
MergeTree 里最重要的决定是 ORDER BY。数据会在磁盘上按这个顺序排列,查询也会利用这个顺序跳过不需要读的数据块。排序键一旦选错,别的手段都补不回来。
OpenTelemetry ClickHouse exporter 生成的默认表,是把 trace 按服务名、span 名、时间的顺序排序的。这是一个针对「按服务和操作维度翻查」这类查询做过优化的选择。
CREATE TABLE otel.otel_traces
(
Timestamp DateTime64(9) CODEC(Delta(8), ZSTD(1)),
TraceId String CODEC(ZSTD(1)),
SpanId String CODEC(ZSTD(1)),
ParentSpanId String CODEC(ZSTD(1)),
TraceState String CODEC(ZSTD(1)),
SpanName LowCardinality(String) CODEC(ZSTD(1)),
SpanKind LowCardinality(String) CODEC(ZSTD(1)),
ServiceName LowCardinality(String) CODEC(ZSTD(1)),
ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
ScopeName String CODEC(ZSTD(1)),
ScopeVersion String CODEC(ZSTD(1)),
SpanAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
Duration UInt64 CODEC(ZSTD(1)),
StatusCode LowCardinality(String) CODEC(ZSTD(1)),
StatusMessage String CODEC(ZSTD(1)),
Events Nested (
Timestamp DateTime64(9),
Name LowCardinality(String),
Attributes Map(LowCardinality(String), String)
) CODEC(ZSTD(1)),
Links Nested (
TraceId String,
SpanId String,
TraceState String,
Attributes Map(LowCardinality(String), String)
) CODEC(ZSTD(1)),
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
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_span_attr_key mapKeys(SpanAttributes) 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))
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
有三点值得留意。
LowCardinality(String) 该用在哪里。 只用在取值种类大致在一万以内的字符串上。它会建立字典,让压缩和过滤都变快。如果把它挂在 TraceId 这种值几乎唯一的字段上,字典会无限膨胀,反而更糟。
布隆过滤器索引的作用。 由于排序键是服务名和 span 名,用单个 trace ID 查询时用不上排序顺序。布隆过滤器能快速判断「这个数据块里没有这个 trace ID」,从而减少需要读取的数据块。但这只是辅助手段,替代不了排序键。
ttl_only_drop_parts = 1。 让 TTL 过期按 part 处理,而不是按行处理。由于分区是按天划分的,过期那一天对应的 part 会整块消失。没有这个设置的话,TTL 清理会触发 part 重写,磁盘 I/O 会大幅增加。
如果想调整排序键,先看看哪类查询占大多数。
| 主要查询 | 推荐的 ORDER BY | 代价 |
|---|---|---|
| 按服务做延迟分析 | (ServiceName, SpanName, Timestamp) | 按 trace ID 查询要依赖布隆过滤器 |
| 按单个 trace ID 查询占绝大多数 | (TraceId) 或单独的查询表 | 时间范围扫描效率变差 |
| 大多是按租户分析 | (TenantId, ServiceName, Timestamp) | 需要把租户字段提升为物理字段 |
| 主要是探索最近时间段 | (toStartOfHour(Timestamp), ServiceName) | 过滤较早的区间效率变低 |
如果两种访问模式都重要,就再建一张表。多花一倍的存储成本,换来两类查询都变快。在 ClickHouse 里这是常见的选择。
日志 schema — 属性该用 Map 还是 JSON
日志表的排序键不一样。由于日志的查询绝大多数是按时间范围加服务来收窄的,所以把时间放在前面,但不要切得太细。
CREATE TABLE otel.otel_logs
(
Timestamp DateTime64(9) CODEC(Delta(8), ZSTD(1)),
TraceId String CODEC(ZSTD(1)),
SpanId String CODEC(ZSTD(1)),
TraceFlags UInt8,
SeverityText LowCardinality(String) CODEC(ZSTD(1)),
SeverityNumber UInt8,
ServiceName LowCardinality(String) CODEC(ZSTD(1)),
Body String CODEC(ZSTD(1)),
ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
LogAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
-- 把经常用来过滤的键提升为物理字段
HttpRoute LowCardinality(String) MATERIALIZED LogAttributes['http.route'],
HttpStatus UInt16 MATERIALIZED toUInt16OrZero(LogAttributes['http.response.status_code']),
ErrorType LowCardinality(String) MATERIALIZED LogAttributes['error.type'],
INDEX idx_trace_id TraceId TYPE bloom_filter(0.001) GRANULARITY 1,
INDEX idx_body Body TYPE tokenbf_v1(32768, 3, 0) GRANULARITY 1,
INDEX idx_severity SeverityNumber TYPE set(16) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, toStartOfFiveMinutes(Timestamp), Timestamp)
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS index_granularity = 8192, ttl_only_drop_parts = 1;
MATERIALIZED 字段是这里的关键装置。它在插入的那一刻就把值从 Map 里取出来,另存成一个字段。查 Map 每次都要去找键,而物理字段可以直接读取,所以常用的过滤条件会快得多。存储成本会增加,但低基数字段压缩效果好,实际增幅并不大。
存储属性的方式有两种选择。
| 项目 | Map(String, String) | JSON 类型 |
|---|---|---|
| 类型保留 | 全部拍平成字符串 | 保留原始类型 |
| 查询时的读取量 | 哪怕只要一个键也要读整个 Map | 只读对应的子字段 |
| schema 变化 | 自由 | 自由,子字段自动生成 |
| 子键数量多时 | 压缩和查询一起变差 | 可通过子字段上限来控制 |
| 工具兼容性 | 到处都能用 | 需要 25.3 以上版本 |
| 迁移 | 基准 | 已有表需要重建 |
Map 最大的弱点是没法做部分读取。哪怕只想读一个 LogAttributes['http.route'],也得把那一行的整个 Map 从磁盘读出来并解压。对于有 40 个属性的日志来说,这个代价不小。JSON 类型会把每个子键都当作独立字段来存,所以没有这个问题。
-- JSON 类型的用法示例 — 给子字段数量设一个上限
CREATE TABLE otel.otel_logs_json
(
Timestamp DateTime64(9) CODEC(Delta(8), ZSTD(1)),
ServiceName LowCardinality(String) CODEC(ZSTD(1)),
SeverityText LowCardinality(String) CODEC(ZSTD(1)),
TraceId String CODEC(ZSTD(1)),
Body String CODEC(ZSTD(1)),
Attributes JSON(max_dynamic_paths = 512) CODEC(ZSTD(1))
)
ENGINE = MergeTree
PARTITION BY toDate(Timestamp)
ORDER BY (ServiceName, toStartOfFiveMinutes(Timestamp), Timestamp)
TTL toDateTime(Timestamp) + toIntervalDay(30)
SETTINGS ttl_only_drop_parts = 1;
-- 显式指定类型来访问子路径,这样才能用上索引和统计信息
SELECT
ServiceName,
Attributes.http.route::LowCardinality(String) AS route,
count() AS c,
quantile(0.99)(Attributes.duration_ms::Float64) AS p99
FROM otel.otel_logs_json
WHERE Timestamp >= now() - INTERVAL 1 HOUR
AND SeverityText = 'ERROR'
GROUP BY ServiceName, route
ORDER BY c DESC
LIMIT 20;
max_dynamic_paths 扮演的角色,相当于搜索引擎里的字段数上限。超过上限的路径会被挤到单独的共享存储里,查询会变慢,但集群不会崩溃。它从结构上缓解了类似「映射爆炸」这样的事故。
判断标准很简单。如果属性键大体可预测、数量又少,Map 就够用了。如果键集合因服务而异、还在持续增长,那 JSON 类型更合适。
分区、TTL 与分层存储
分区默认按天划分。会有把它切得更细的冲动,但最好忍住。分区一多,part 数就会增加;part 一多,合并负载和元数据开销就会变大。如果一天的数据量有数 TB,可以考虑按小时分区,但在此之前先看看 TTL 和排序键能不能解决问题。
TTL 不只用来删除,也用来做数据迁移。把最近的数据放在快盘上,把旧数据放到又慢又便宜的存储上。
-- 定义存储策略 (写在 config.xml 或单独的配置文件里)
-- hot: 本地 NVMe,cold: 对象存储
ALTER TABLE otel.otel_traces
MODIFY TTL
toDateTime(Timestamp) + INTERVAL 3 DAY TO VOLUME 'hot',
toDateTime(Timestamp) + INTERVAL 14 DAY TO VOLUME 'cold',
toDateTime(Timestamp) + INTERVAL 90 DAY DELETE;
用迁移型 TTL 时,有件事必须确认清楚。查询已经迁到对象存储的数据会产生网络往返,速度会慢得多。你需要提前告诉用户,「保留 90 天」不等于「90 天内查询速度都一样快」。不然某天就会有人对 60 天前的数据做全量扫描,把集群拖垮。
也要确认 TTL 是不是真的在生效。
-- 每个分区的大小与最早的数据
SELECT
table,
partition,
formatReadableSize(sum(bytes_on_disk)) AS size,
sum(rows) AS rows,
min(min_time) AS oldest
FROM system.parts
WHERE database = 'otel' AND active
GROUP BY table, partition
ORDER BY partition
LIMIT 10;
-- 正在等待和正在进行的合并
SELECT table, elapsed, progress, num_parts, formatReadableSize(memory_usage) AS mem
FROM system.merges
WHERE database = 'otel';
-- part 数一多,插入就会开始被拒绝
SELECT table, count() AS parts
FROM system.parts
WHERE database = 'otel' AND active
GROUP BY table
ORDER BY parts DESC;
part 数值得持续关注。小批量插入太频繁会导致 part 暴增,合并一旦跟不上,插入本身就会被拒绝。常规应对方式是加大 exporter 的批量大小、开启异步插入。
用物化视图做 rollup
把原始 trace 保留 30 天是很贵的。但大多数仪表盘查询要的并不是原始数据,而是聚合值。如果物化视图能在插入的那一刻就做好聚合,就可以让原始数据保留得短一些,聚合数据保留得长一些。
-- 1) 用来存放聚合结果的表
CREATE TABLE otel.trace_rollup_1m
(
Bucket DateTime,
ServiceName LowCardinality(String),
SpanName LowCardinality(String),
SpanKind LowCardinality(String),
Calls AggregateFunction(count),
Errors AggregateFunction(countIf, UInt8),
DurationQ AggregateFunction(quantiles(0.5, 0.9, 0.99), Float64),
DurationSum AggregateFunction(sum, Float64)
)
ENGINE = AggregatingMergeTree
PARTITION BY toYYYYMM(Bucket)
ORDER BY (ServiceName, SpanName, SpanKind, Bucket)
TTL Bucket + toIntervalDay(400);
-- 2) 原始表每次插入时自动做聚合
CREATE MATERIALIZED VIEW otel.trace_rollup_1m_mv TO otel.trace_rollup_1m AS
SELECT
toStartOfMinute(Timestamp) AS Bucket,
ServiceName,
SpanName,
SpanKind,
countState() AS Calls,
countIfState(StatusCode = 'Error') AS Errors,
quantilesState(0.5, 0.9, 0.99)(Duration / 1e6) AS DurationQ,
sumState(Duration / 1e6) AS DurationSum
FROM otel.otel_traces
GROUP BY Bucket, ServiceName, SpanName, SpanKind;
-- 3) 查询时把状态合并起来
SELECT
ServiceName,
SpanName,
countMerge(Calls) AS calls,
countIfMerge(Errors) AS errors,
round(countIfMerge(Errors) / countMerge(Calls), 4) AS error_ratio,
arrayElement(quantilesMerge(0.5, 0.9, 0.99)(DurationQ), 3) AS p99_ms
FROM otel.trace_rollup_1m
WHERE Bucket >= now() - INTERVAL 30 DAY
GROUP BY ServiceName, SpanName
ORDER BY calls DESC
LIMIT 30;
这里有三个陷阱。
第一,物化视图是一个插入触发器。它不会处理原始表里已经存在的数据。建好视图之后如果想回填历史数据,需要另外跑一次 INSERT SELECT。
第二,原始表和视图的 TTL 是相互独立的。如果目的就是把原始表缩短到 7 天、同时让视图保留 400 天,它确实会按这个方式运作。但如果修改了视图的定义,定义变更前后的聚合含义就会不一样,所以修改定义时新建一个视图和一张新表会更安全。
第三,分位数必须以状态的形式保存。如果把按分钟计算的 p99 直接存成一个数字,之后再取平均,那就已经不是 p99 了。必须用 quantilesState 保存中间状态,在查询时用 quantilesMerge 合并,跨多个时间桶的分位数才能近似成立。
原始数据和 rollup 的保留组合决定了成本。
| 数据 | 保留期 | 相对大小 | 能回答的问题 |
|---|---|---|---|
| 原始 span | 7~14 天 | 1.0 | 单个请求的完整路径、任意属性过滤 |
| 1 分钟 rollup | 90~400 天 | 0.005 以下 | 按服务的趋势、发布前后对比、SLO 计算 |
| 只单独保留错误 span | 90 天 | 0.02 | 罕见错误的长期模式 |
从 collector 写入 ClickHouse
让 exporter 自动生成 schema 这件事,只建议在开发环境里做。生产环境要自己管理 DDL,并关掉自动生成。排序键和 TTL 理应因组织而异,而自动生成的 schema 事后要改,就意味着要重建整张表。
# otel-collector.yaml
exporters:
clickhouse:
endpoint: tcp://clickhouse.observability.svc:9000?dial_timeout=10s
database: otel
username: otel_writer
password: ${env:CLICKHOUSE_PASSWORD}
# 生产环境自己管理 DDL
create_schema: false
logs_table_name: otel_logs
traces_table_name: otel_traces
compress: lz4
async_insert: true
timeout: 10s
sending_queue:
enabled: true
num_consumers: 10
queue_size: 10000
retry_on_failure:
enabled: true
initial_interval: 5s
max_elapsed_time: 300s
processors:
# ClickHouse 偏爱大批量。小批量插入太频繁会导致 part 暴增
batch:
timeout: 10s
send_batch_size: 20000
send_batch_max_size: 50000
service:
pipelines:
traces:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [clickhouse]
logs:
receivers: [otlp]
processors: [memory_limiter, batch]
exporters: [clickhouse]
批量大小是核心设置。ClickHouse 的设计前提是一次接收几万行数据。每秒几百次的小批量插入会大量产生 part,最终又变成合并负载反噬回来。
下面整理了实际会遇到的一些故障。
| 症状 | 原因 | 排查方式 | 应对 |
|---|---|---|---|
| 插入被拒绝 | 活跃 part 数超限 | system.parts 里的 part 数 | 加大批量大小、开启异步插入、重新审视分区方式 |
| 只有某个查询特别慢 | 过滤条件没用上排序键 | EXPLAIN 里读取的 mark 数 | 重新设计排序键,或加一张辅助表 |
| 磁盘比预期更快占满 | TTL 没有按 part 处理 | 各分区的大小与最早时间 | 检查 ttl_only_drop_parts,审视分区方式 |
| 内存超限导致查询失败 | GROUP BY 基数过高 | 查询日志里的内存用量 | 使用预聚合视图,给单次查询设内存上限 |
| 查询旧数据非常慢 | 已迁移到对象存储层 | 存储策略与 part 所在位置 | 向用户说明分层结构,引导使用 rollup 表 |
| 日志和 trace 关联不上 | TraceId 格式不一致 | 对比两边的样本 | 在 collector 里统一表示方式 |
查询有没有用上排序键,用 EXPLAIN 来确认。
EXPLAIN indexes = 1
SELECT count()
FROM otel.otel_traces
WHERE ServiceName = 'checkout-api'
AND Timestamp >= now() - INTERVAL 1 HOUR;
-- 读取到的 mark 数应该只占总数的极小一部分
Prometheus、OpenSearch、ClickHouse 的职责划分
三个都用看起来很浪费,但它们各自回答不同的问题。想把它们合并成一个的尝试,最后大多以丢掉其中一个的长处收场。
| 维度 | Prometheus | OpenSearch | ClickHouse |
|---|---|---|---|
| 数据模型 | 时序,标签集合 | 倒排索引文档 | 列式表 |
| 最擅长的事 | 秒级聚合、告警评估 | 全文检索、任意字段查询 | 大量扫描、任意聚合、join |
| 基数 | 脆弱,必须做预算管理 | 对字段数量敏感 | 相对宽容 |
| 保留期 | 数月(需要降采样) | 数周(受成本约束) | 数月到数年 |
| 延迟 | 秒级 | 秒级 | 秒到分钟级 |
| 适合回答的问题 | 现在是不是出问题了,要不要告警 | 这个请求为什么失败了 | 过去 30 天里出现过什么模式 |
一种现实中的部署方式是这样的。
- 告警和 SLO 评估交给 Prometheus。 它需要秒级评估和低成本查询,这个领域几乎没有理由换成别的工具。
- 最近日志的全文检索交给 OpenSearch。 像「包含这条错误信息的日志」这类查找文本的查询,倒排索引有压倒性优势。作为代价,保留期要设得短一些。
- 长期保留与任意分析交给 ClickHouse。 原始 trace、原始日志、rollup 全都放在这里,负责那些需要 join 才能回答的问题。
三者一起使用时,必须守住的一点是标识符的一致性。service.name、trace_id、deployment.environment.name 这三个值在三套系统里必须完全一致,才能在工具之间自由跳转。在 collector 里统一归一化一次,让各个 exporter 直接使用这个值。
processors:
transform/normalize:
error_mode: ignore
trace_statements:
- context: resource
statements:
# 把用旧名字进来的属性统一成当前的命名约定
- set(attributes["deployment.environment.name"], attributes["deployment.environment"])
where attributes["deployment.environment.name"] == nil
and attributes["deployment.environment"] != nil
- delete_key(attributes, "deployment.environment")
结语 — schema 决策事后很难反悔
在 ClickHouse 里,容易反悔的事和难以反悔的事分得很清楚。加索引、改 TTL、加物化视图,这些都可以在运行中完成。而改排序键和改分区键,实质上等于重建整张表。
所以引入的顺序应该是这样的。先写下哪些查询每天会跑上千次,这些查询的过滤条件应该成为排序键的前几位。接着把保留期拆成原始数据和 rollup 两部分来定。最后再选属性的存储方式。只要一开始就把这三件事定对,其余的都可以后面再改。
现在就能做的一项检查,是把各字段的压缩比拉出来看看。如果一个压缩不下去的字段占了存储成本的一半,那就先重新问一遍,这个字段是不是真的需要。
值得继续深入的资料。