日志与指标存储:可观测性后端、Grafana 与成本治理

以 ClickHouse 构建可观测性后端,覆盖日志表设计、指标 rollup 预聚合、Kafka 摄入管道、Grafana 数据源与查询模式、索引与投影加速、冷热分层与 TTL 成本治理,以及高基数与查询熔断防护,并给出可落地的生产容量规划方法。

前置:/clickhouse-kafka-engine/(Kafka 引擎摄入)、/clickhouse-materialized-views/(物化视图与预聚合)、/clickhouse-time-series-analytics/(时间序列分析)。

目录

1. 可观测性数据模型与选型

可观测性后端要同时承载三类形态迥异的数据:日志是宽表且字段高度可变,指标是窄表但时间线数量可达千万级,链路追踪介于两者之间。选型的关键不是「哪个库最快」,而是先判断基数与查询模式,再决定存储引擎与排序键。ClickHouse 的价值在于三者可用同一套 SQL 与列存引擎承载,代价是必须提前设计好分区键与排序键,否则高基数标签会把稀疏索引撑爆。

形态        典型行宽      每日量级       查询模式               关键设计
日志        1~10 KB      10 TB          标签过滤 + 全文匹配    时间分区 + 跳数索引
指标        10~100 B     500 亿点       rollup 降采样          AggregatingMergeTree
链路        1~5 KB       2 TB           trace_id 点查 + 聚合   主键前缀 + 布隆过滤

从成本角度看,日志占了可观测性总存储的七成以上,但查询频次远低于指标;指标点数最多,却是最需要低延迟返回的。把两者放进同一张表会两头不讨好:日志的宽字段会拖慢指标聚合的扫描,指标的高写入频率又会把日志分区的合并压力放大。生产上通常拆成 log 表与 metric 表,仅共享集群、TTL 与配额策略。

日志表    宽 schema + Map 承载可变字段 + 30 天 TTL + ZSTD(3)
指标表    AggregatingMergeTree + 90 天 TTL + Delta/ZSTD(1)
链路表    窄 schema + 主键前缀 trace_id + 7 天 TTL

工程要点:先用基数与查询模式做分流决策,再决定是否混表;日志与指标混表会让排序键两头不讨好。集群与副本的整体分层可参考 /clickhouse-introduction-architecture/。

2. 日志表设计与字段编码

日志表设计的核心是「把常量列压成字典、把可变字段塞进 Map、把时间列用 Delta 编码」。LowCardinality(String) 对 service、level、host 这类取值有限的列可显著降低存储与扫描成本;Map(String, String) 承载结构化属性,避免为每个新字段做 ALTER;DateTime64(9) 保留纳秒精度以对齐链路追踪。排序键把最常过滤的低基数列放前面,时间列放最后,兼顾点查与范围扫描。

CREATE TABLE logs.app_log
(
    ts        DateTime64(9) CODEC(Delta, ZSTD(3)),
    service   LowCardinality(String),
    env       LowCardinality(String),
    level     LowCardinality(String),
    host      LowCardinality(String),
    trace_id  String CODEC(ZSTD(1)),
    span_id   String CODEC(ZSTD(1)),
    msg       String CODEC(ZSTD(3)),
    attrs     Map(String, String) CODEC(ZSTD(3)),
    INDEX idx_msg msg TYPE tokenbf_v1(8192, 3, 0) GRANULARITY 4
)
ENGINE = MergeTree
PARTITION BY toDate(ts)
ORDER BY (service, level, ts)
TTL toDateTime(ts) + INTERVAL 30 DAY DELETE
SETTINGS index_granularity = 8192;

写查询时优先用 Map 的下标访问与 has 判断,而不是把 attrs 展开成 JSON 字符串再 LIKE。把高频过滤的 Map 键抽成物化列,能让跳数索引真正生效。

SELECT ts, service, msg
FROM logs.app_log
WHERE service = 'order-api'
  AND attrs['http.status'] = '500'
  AND ts > now() - INTERVAL 15 MINUTE
ORDER BY ts DESC
LIMIT 100;

工程要点:排序键的第一列必须是过滤频率最高且基数可控的列,时间列放最后;Map 用于低频字段,高频字段要物化成独立列,否则跳数索引形同虚设。字段选型细则见 /clickhouse-schema-modeling-best-practices/。

3. 指标存储与 rollup 预聚合

指标后端的正确姿势是「原始点只写不查、查询全部走 rollup」。原始指标表按秒级写入,保留 7 天用于排查;降采样表用 AggregatingMergeTree 存 sumState、quantileState、countState 等中间状态,查询时用对应的 -Merge 函数合并。中间状态可跨分区合并,因此 1 分钟粒度可以再上卷成 5 分钟、1 小时而不丢精度。

CREATE TABLE metrics.cpu_1m
(
    ts        DateTime CODEC(Delta, ZSTD(1)),
    metric    LowCardinality(String),
    host      LowCardinality(String),
    region    LowCardinality(String),
    sum_state AggregateFunction(sum, Float64),
    p99_state AggregateFunction(quantile(0.99), Float64),
    cnt_state AggregateFunction(count)
)
ENGINE = AggregatingMergeTree
PARTITION BY toDate(ts)
ORDER BY (metric, region, host, ts)
TTL ts + INTERVAL 90 DAY DELETE;

CREATE MATERIALIZED VIEW metrics.cpu_1m_mv TO metrics.cpu_1m AS
SELECT toStartOfMinute(ts) AS ts,
       metric, host, region,
       sumState(value)               AS sum_state,
       quantileState(0.99)(value)    AS p99_state,
       countState()                  AS cnt_state
FROM metrics.cpu_raw
GROUP BY ts, metric, host, region;

查询侧必须用 -Merge 组合器读取中间状态,且 GROUP BY 的维度要与建表排序键一致,才能命中主键索引。

SELECT host,
       sumMerge(sum_state)        AS total_cpu,
       quantileMerge(0.99)(p99_state) AS p99_cpu,
       countMerge(cnt_state)      AS samples
FROM metrics.cpu_1m
WHERE metric = 'node_cpu' AND ts >= now() - INTERVAL 6 HOUR
GROUP BY host
ORDER BY p99_cpu DESC;

若需要更粗的粒度,可以从 1 分钟表再上卷出 5 分钟表,用 toStartOfInterval 重写聚合,中间状态可无损合并,无需回读原始点。

CREATE MATERIALIZED VIEW metrics.cpu_5m_mv TO metrics.cpu_5m AS
SELECT toStartOfInterval(ts, INTERVAL 5 MINUTE) AS ts,
       metric, region, host,
       sumState(sum_state)            AS sum_state,
       quantileState(0.99)(p99_state) AS p99_state,
       countState()                   AS cnt_state
FROM metrics.cpu_1m
GROUP BY ts, metric, region, host;

工程要点:rollup 表只存中间状态、不存最终值,聚合函数必须成对使用 -State 与 -Merge;上卷时用 toStartOfInterval 重写 MV 即可,无需重算历史。更多预聚合模式见 /clickhouse-materialized-views/。

4. Kafka 摄入管道与物化视图

日志与指标的高吞吐写入统一走 Kafka 引擎表:Kafka 表只负责消费,物化视图负责解析并写入目标 MergeTree 表。kafka_num_consumers 应与分区数匹配,kafka_max_block_size 决定单批次写入的行数,过大导致单次插入超时,过小则放大 part 数量。解析环节把 JSON 的固定字段抽成列,剩余字段整体塞进 Map,避免为每类日志建一张表。

CREATE TABLE logs.kafka_queue
(
    raw String
)
ENGINE = Kafka
SETTINGS kafka_broker_list  = 'kafka-1:9092,kafka-2:9092,kafka-3:9092',
         kafka_topic_list   = 'app-log',
         kafka_group_name   = 'ch-log-ingest',
         kafka_format       = 'JSONEachRow',
         kafka_num_consumers = 8,
         kafka_max_block_size = 65536,
         kafka_skip_broken_messages = 100;

CREATE MATERIALIZED VIEW logs.kafka_to_app_log TO logs.app_log AS
SELECT
    parseDateTime64BestEffort(JSONExtractString(raw, 'ts'), 9) AS ts,
    JSONExtractString(raw, 'service') AS service,
    JSONExtractString(raw, 'env')     AS env,
    JSONExtractString(raw, 'level')   AS level,
    JSONExtractString(raw, 'host')    AS host,
    JSONExtractString(raw, 'trace_id') AS trace_id,
    JSONExtractString(raw, 'span_id')  AS span_id,
    JSONExtractString(raw, 'msg')      AS msg,
    JSONExtract(raw, 'attrs', 'Map(String, String)') AS attrs
FROM logs.kafka_queue;

消费位点由 ClickHouse 自己管理,重启后会从上次提交的 offset 继续;一旦 MV 写入失败,需要监控 system.kafka_consumers 的异常与 lag,而不是重启整个实例。

SELECT database, table, consumer_id, assignments.topic,
       num_messages_read, last_poll_time, exceptions.text
FROM system.kafka_consumers
WHERE num_messages_read > 0;

工程要点:Kafka 表只做消费、物化视图只做解析与转发,目标表负责存储;kafka_skip_broken_messages 必须设置以免单条脏数据卡死整个分区。参数调优见 /clickhouse-kafka-engine/。

5. Grafana 数据源与查询模式

Grafana 的 ClickHouse 数据源插件通过宏把面板的时间范围与采样间隔注入 SQL:$__timeFilter(ts) 展开为时间范围条件,$__timeInterval(ts) 按面板步长对齐时间桶,$__interval 与 $__interval_ms 提供原始间隔值。写面板 SQL 时务必让宏作用在排序键的时间列上,否则插件生成的 WHERE 无法命中分区裁剪。

SELECT $__timeInterval(ts) AS t,
       quantileMerge(0.95)(lat_state) AS p95,
       sumMerge(req_state)            AS qps
FROM metrics.http_latency_1m
WHERE $__timeFilter(ts)
  AND service = 'api-gateway'
  AND env = 'prod'
GROUP BY t
ORDER BY t;

日志面板则用 $__timeFilter 加变量过滤,$service 由 Grafana 的 query 变量从 system.tables 或维表拉取,避免硬编码。大盘的刷新间隔要与 $__timeInterval 的最小值协调,防止每个用户刷新都触发全分区扫描。

SELECT $__timeInterval(ts) AS t,
       level,
       count() AS cnt
FROM logs.app_log
WHERE $__timeFilter(ts)
  AND service = $service
GROUP BY t, level
ORDER BY t;

按主机维度展开的表格面板则直接聚合预聚合表,把 group by 交给 ClickHouse 而不是在面板层对原始点做聚合。

SELECT host,
       sumMerge(req_state)            AS requests,
       quantileMerge(0.99)(lat_state) AS p99_ms
FROM metrics.http_latency_1m
WHERE $__timeFilter(ts) AND service = 'api-gateway'
GROUP BY host
ORDER BY p99_ms DESC
LIMIT 20;

工程要点:宏必须落在时间列上才能触发分区裁剪;用 $__timeInterval 做降采样时,底层表应已按该粒度预聚合,避免在 Grafana 层做实时聚合。实时面板设计见 /clickhouse-real-time-analytics/。

6. 查询加速:索引、投影与预聚合

可观测性查询的加速手段按性价比排序是:先优化排序键与分区,再加跳数索引,最后才上投影。跳数索引适合「过滤后命中率低」的列,例如 trace_id、msg 的关键词、Map 中的某个键;投影适合「固定维度的另一种排序」,例如按 trace_id 点查与按 service 范围扫描并存。投影会随写入同步维护,写入放大与存储开销都要计入成本。

ALTER TABLE logs.app_log
    ADD INDEX idx_trace trace_id TYPE bloom_filter(0.01) GRANULARITY 4;
ALTER TABLE logs.app_log MATERIALIZE INDEX idx_trace;

ALTER TABLE logs.app_log
    ADD PROJECTION p_by_trace (SELECT * ORDER BY trace_id, ts);
ALTER TABLE logs.app_log MATERIALIZE PROJECTION p_by_trace;

验证索引是否生效,要看 system.query_log 中的 read_rows 与 result_rows 比值,以及 EXPLAIN indexes = 1 的输出。若跳数索引的过滤后比例接近 1,说明该索引几乎没筛掉数据块,应考虑换成投影或直接调整排序键。

EXPLAIN indexes = 1
SELECT count() FROM logs.app_log
WHERE trace_id = '4f2a9c1e8b7d3a06';

SELECT query_duration_ms, read_rows, result_rows,
       round(read_rows / result_rows) AS ratio
FROM system.query_log
WHERE type = 'QueryFinish' AND query LIKE '%logs.app_log%'
ORDER BY event_time DESC LIMIT 10;

工程要点:索引与投影都有维护成本,只给真正高频的过滤条件建;判断是否值得建的唯一标准是过滤后行数比例,而不是「有没有用上索引」。裁剪原理见 /clickhouse-query-pruning-indexes/。

7. 成本治理:压缩、分层与 TTL

可观测性数据量按天线性增长,成本治理的核心是「早压缩、早下沉、早删除」。压缩层面用列级 CODEC 组合:时间列用 Delta 编码、低基数列靠字典、文本列用 ZSTD(3) 平衡压缩率与解压速度,实测 10 TB 原始日志可压到约 1.2 TB。分层层面用 TTL 规则把 7 天内的热数据留在本地 NVMe,30 天后下沉到 S3 卷,365 天后删除。

ALTER TABLE logs.app_log MODIFY TTL
    toDateTime(ts) + INTERVAL 7 DAY   TO VOLUME 'hot',
    toDateTime(ts) + INTERVAL 30 DAY  TO VOLUME 'cold',
    toDateTime(ts) + INTERVAL 365 DAY DELETE;

ALTER TABLE logs.app_log MODIFY SETTING storage_policy = 'tiered';

分层依赖 storage_configuration 中的卷定义,S3 卷的元数据仍留在本地磁盘,读取冷数据时才回源对象存储,因此冷查询的 P95 会明显高于热数据,面板要接受这一取舍。

storage_configuration
  disks:   default(type=local) / cold(type=s3, endpoint=s3.internal/obs)
  volumes: hot(disk=default) / cold(disk=cold)
  move_factor = 0.2

工程要点:TTL 的删除与下沉都是后台合并触发的,不是瞬时生效;压缩比要按真实数据实测而非按文档估算,冷查询延迟要单独做 SLA。对象存储细节见 /clickhouse-s3-object-storage-integration/。

8. 高基数与查询熔断防护

可观测性最大的坑是高基数:把 trace_id、user_id、request_id 这类近乎唯一的标签做成排序键前缀或 GROUP BY 维度,会让内存中的哈希表爆炸,轻则查询变慢,重则打满内存触发 OOM。防护分三层:设计层禁止高基数列进排序键与维度;查询层用 max_memory_usage、max_execution_time、max_rows_to_group_by 与 group_by_overflow_mode 兜底;运维层用 query_log 找出「大户」并针对性限流。

SELECT user, normalized_query_hash,
       count() AS runs,
       sum(read_bytes) AS bytes,
       round(quantile(0.95)(query_duration_ms)) AS p95_ms
FROM system.query_log
WHERE event_time > now() - INTERVAL 1 HOUR
  AND type = 'QueryFinish'
GROUP BY user, normalized_query_hash
ORDER BY bytes DESC
LIMIT 20;

把限额写进 Settings Profile 并绑定到 Grafana 用的只读用户,可在不误伤 ETL 写入的前提下限制面板查询。read_overflow_mode = 'break' 让超限查询返回部分结果而不是报错,对监控面板更友好。

CREATE SETTINGS PROFILE obs_reader SETTINGS
    max_memory_usage = 20000000000,
    max_execution_time = 30,
    max_rows_to_group_by = 10000000,
    group_by_overflow_mode = 'break',
    readonly = 1;

ALTER USER grafana SETTINGS PROFILE obs_reader;

工程要点:高基数标签绝不做排序键或聚合维度;限额要写进 Profile 并按用户绑定,break 模式比直接报错更适合监控场景。监控指标见 /clickhouse-monitoring-maintenance/。

9. 生产实践与容量规划

容量规划从「每天多少原始数据、压缩比多少、保留多久」三个数出发倒推磁盘:压缩比取实测值,副本数决定总占用,再留 30% 的合并与临时空间余量。查询侧的容量要单独算:单日分区在 8 分片下约 1.2 TB,一个扫描 1 天的聚合查询若控制在 1.5 秒内,则单机扫描吞吐需达到约 800 MB/s,NVMe 阵列可以满足。

原始日志       10 TB/天
压缩比         ZSTD(3) 约 8:1 → 落盘 1.2 TB/天
副本数         2 → 2.4 TB/天
保留 30 天     约 72 TB 热数据
分片与副本     8 分片 × 2 副本
单分片承载     ~4.5 TB
查询 P95       扫描 1 天分区 < 1.5 s

上线后要持续观察三类信号:合并积压(system.merges 与 part 数量)、写入延迟(Kafka lag)、查询大户(query_log)。part 数量持续上涨说明插入批次过小或分区过细,需要调大 kafka_max_block_size 或放宽分区粒度。定期对旧分区做 OPTIMIZE ... FINAL 可减少 part 数,但会带来一次性的 IO 峰值。

监控项           阈值            处置
part 数/分区     > 300          调大批次或 OPTIMIZE
Kafka lag        > 100 万       增加 consumer 或分区
合并积压         > 50 个        检查磁盘 IO 与 CPU
查询 P95         > 3 s          检查索引命中与预聚合

当保留期需要从 30 天延长到 90 天时,改 TTL 只对新分区立即生效,历史分区要靠后台合并逐步下沉,期间磁盘水位会短暂抬升,扩容要先行。

扩容顺序    先加磁盘 → 再调 TTL → 最后观察合并进度
磁盘余量    下沉期间预留 40%
回滚手段    把 TTL 改回原值即可,不会删除已有数据

工程要点:容量按压缩后的落盘量算并留 30% 余量;上线后盯合并、延迟、大户三个信号,part 数是最灵敏的早期指标。调优经验见 /clickhouse-production-performance-tuning/。

10. 速查表与一句话记忆

下表汇总本文涉及的关键配置与推荐值,可作为搭建可观测性后端时的落地清单。

主题关键配置推荐值
日志表排序键ORDER BYservice, level, ts
时间列编码CODECDelta, ZSTD(3)
指标 rollup引擎与函数AggregatingMergeTree 加 sumState 与 quantileState
Kafka 摄入kafka_max_block_size65536
Grafana 时间宏过滤与采样$__timeFilter 与 $__timeInterval
冷热分层TTL TO VOLUME7 天 hot 与 30 天 cold
查询限额Settings Profilemax_memory_usage 与 max_execution_time
保留策略TTL DELETE日志 30 天 与 指标 90 天

一句话记忆:日志靠分区与编码压成本、指标靠 rollup 换延迟、Grafana 靠宏命中索引、高基数靠 Profile 兜底。

延伸阅读

  • /clickhouse-columnar-compression/ — 列存压缩原理与 CODEC 组合选择
  • /clickhouse-merge-tree-principle/ — MergeTree 存储结构与合并过程
  • /clickhouse-distributed-cluster/ — 分片与副本的集群拓扑设计
  • /clickhouse-query-cache-warming/ — 查询缓存与结果预热
  • /clickhouse-backup-dr/ — 备份与灾难恢复策略
  • /clickhouse-access-security/ — 用户、配额与行级权限
  • /clickhouse-production-case/ — 生产环境真实落地案例
  • 数据库专题

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「数据库」更多文章

  1. 用户自定义函数:executable UDF、SQL UDF 与性能边界
  2. 内存管理与落盘:查询内存、spill to disk 与 OOM 防护
  3. 集群扩容与升级:分片重平衡、平滑升级与滚动重启