前置:/clickhouse-kafka-engine/(Kafka 引擎摄入)、/clickhouse-materialized-views/(物化视图与预聚合)、/clickhouse-time-series-analytics/(时间序列分析)。
目录
- 1. 可观测性数据模型与选型
- 2. 日志表设计与字段编码
- 3. 指标存储与 rollup 预聚合
- 4. Kafka 摄入管道与物化视图
- 5. Grafana 数据源与查询模式
- 6. 查询加速:索引、投影与预聚合
- 7. 成本治理:压缩、分层与 TTL
- 8. 高基数与查询熔断防护
- 9. 生产实践与容量规划
- 10. 速查表与一句话记忆
- 延伸阅读
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 BY | service, level, ts |
| 时间列编码 | CODEC | Delta, ZSTD(3) |
| 指标 rollup | 引擎与函数 | AggregatingMergeTree 加 sumState 与 quantileState |
| Kafka 摄入 | kafka_max_block_size | 65536 |
| Grafana 时间宏 | 过滤与采样 | $__timeFilter 与 $__timeInterval |
| 冷热分层 | TTL TO VOLUME | 7 天 hot 与 30 天 cold |
| 查询限额 | Settings Profile | max_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/ — 生产环境真实落地案例
- 数据库专题
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。