Skip to content

필사 모드: 把观测数据放进 ClickHouse 意味着什么 — schema、rollup、TTL 与职责划分

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — 当你需要翻查 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 的保留组合决定了成本。

数据保留期相对大小能回答的问题
原始 span7~14 天1.0单个请求的完整路径、任意属性过滤
1 分钟 rollup90~400 天0.005 以下按服务的趋势、发布前后对比、SLO 计算
只单独保留错误 span90 天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 的职责划分

三个都用看起来很浪费,但它们各自回答不同的问题。想把它们合并成一个的尝试,最后大多以丢掉其中一个的长处收场。

维度PrometheusOpenSearchClickHouse
数据模型时序,标签集合倒排索引文档列式表
最擅长的事秒级聚合、告警评估全文检索、任意字段查询大量扫描、任意聚合、join
基数脆弱,必须做预算管理对字段数量敏感相对宽容
保留期数月(需要降采样)数周(受成本约束)数月到数年
延迟秒级秒级秒到分钟级
适合回答的问题现在是不是出问题了,要不要告警这个请求为什么失败了过去 30 天里出现过什么模式

一种现实中的部署方式是这样的。

  • 告警和 SLO 评估交给 Prometheus。 它需要秒级评估和低成本查询,这个领域几乎没有理由换成别的工具。
  • 最近日志的全文检索交给 OpenSearch。 像「包含这条错误信息的日志」这类查找文本的查询,倒排索引有压倒性优势。作为代价,保留期要设得短一些。
  • 长期保留与任意分析交给 ClickHouse。 原始 trace、原始日志、rollup 全都放在这里,负责那些需要 join 才能回答的问题。

三者一起使用时,必须守住的一点是标识符的一致性。service.nametrace_iddeployment.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 两部分来定。最后再选属性的存储方式。只要一开始就把这三件事定对,其余的都可以后面再改。

现在就能做的一项检查,是把各字段的压缩比拉出来看看。如果一个压缩不下去的字段占了存储成本的一半,那就先重新问一遍,这个字段是不是真的需要。

值得继续深入的资料。

현재 단락 (1/321)

「我想看过去 30 天里,发往某个支付渠道、耗时超过 3 秒的请求,按租户分布是怎样的。」

작성 글자: 0원문 글자: 12,295작성 단락: 0/321