聚合与趋势------分钟到月级趋势如何高效查询
引言
监控系统每秒写入百万条指标,但用户看的是"过去 7 天的 QPS 趋势"。如果每次查询都扫描原始数据,集群迟早扛不住。ClickHouse 的 Materialized View(MV)+ AggregatingMergeTree 提供了一套优雅的多级 Rollup 方案:写入时自动聚合,查询时直接读预计算结果。
本篇将深入 MV 的执行机制、性能代价,以及如何设计 Raw → 10min → 1h → 1d 的多分辨率聚合链路。
1. MV 是同步计算,不是后台任务
很多人第一次接触 MV 时会以为它类似 ETL 定时任务。事实恰好相反:
INSERT 原始数据 → 触发 MV 的 SELECT → 结果写入目标表
这三步在同一次 INSERT 的执行路径中同步完成。这意味着:
- MV 的 SELECT 越复杂,INSERT 延迟越高
- MV 数量越多,每次写入的扇出越大
- 如果目标表写入失败,原始表的 INSERT 也会失败
验证方式------查看 system.query_log,你会发现每次 INSERT 后紧跟着 MV 触发的子查询:
sql
SELECT query, type, query_kind
FROM system.query_log
WHERE query LIKE '%mv_target_table%'
ORDER BY event_time DESC
LIMIT 5;
核心认知:MV 不是"免费午餐",它用写入时的 CPU 换取查询时的 IO。
2. MV 的性能成本在哪里
| 成本维度 | 说明 |
|---|---|
| CPU | MV 的 SELECT 在 INSERT 线程中执行,GROUP BY、函数计算都消耗 CPU |
| 内存 | 聚合状态(AggregateFunction)需要在内存中维护 hash table |
| 写放大 | 1 条 INSERT 触发 N 个 MV,磁盘写入量翻 N 倍 |
| 延迟 | INSERT 的 latency = 原始写入 + 所有 MV 执行时间之和 |
| Merge 压力 | 目标表产生大量小 part,后台 merge 线程压力增大 |
实际经验值:
- 3 个简单 MV(SUM/COUNT)对写入延迟的影响约 15-30%
- 超过 5 个 MV 时建议评估是否拆分写入链路
- MV 的 SELECT 中避免 JOIN,否则写入性能会断崖式下降
3. Raw / 10min / 1h 表的职责划分
多分辨率 Rollup 的核心思想是:不同时间粒度的查询,命中不同的表。
| 表 | 粒度 | TTL | 典型查询场景 |
|---|---|---|---|
metrics_raw |
原始(秒级) | 3 天 | 实时告警、最近 1 小时明细 |
metrics_10min |
10 分钟 | 30 天 | 过去 24 小时趋势图 |
metrics_1h |
1 小时 | 180 天 | 周报、月度趋势 |
metrics_1d |
1 天 | 3 年 | 年度同比、容量规划 |
原始表定义:
sql
CREATE TABLE metrics_raw (
ts DateTime,
host String,
metric String,
value Float64
) ENGINE = MergeTree()
PARTITION BY toYYYYMMDD(ts)
ORDER BY (metric, host, ts)
TTL ts + INTERVAL 3 DAY;
关键设计原则:
- 原始表保留最短时间,用 TTL 自动清理
- 聚合表保留更长时间,数据量小得多
- 查询层根据时间范围自动路由到对应的表
4. AggregatingMergeTree + MV 的正确组合
AggregatingMergeTree 的核心能力是:在后台 merge 时继续聚合。配合 MV,实现"写入时预聚合 + merge 时再聚合"的两阶段计算。
第一步:创建目标表
sql
CREATE TABLE metrics_10min (
ts_bucket DateTime,
host String,
metric String,
value_sum AggregateFunction(sum, Float64),
value_max AggregateFunction(max, Float64),
value_count AggregateFunction(count, Float64)
) ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMMDD(ts_bucket)
ORDER BY (metric, host, ts_bucket)
TTL ts_bucket + INTERVAL 30 DAY;
第二步:创建 MV
sql
CREATE MATERIALIZED VIEW mv_metrics_10min
TO metrics_10min
AS SELECT
toStartOfTenMinutes(ts) AS ts_bucket,
host,
metric,
sumState(value) AS value_sum,
maxState(value) AS value_max,
countState(value) AS value_count
FROM metrics_raw
GROUP BY ts_bucket, host, metric;
第三步:查询时用 Merge 函数
sql
SELECT
ts_bucket,
host,
sumMerge(value_sum) AS total,
maxMerge(value_max) AS peak,
countMerge(value_count) AS cnt
FROM metrics_10min
WHERE metric = 'cpu_usage'
AND ts_bucket >= now() - INTERVAL 24 HOUR
GROUP BY ts_bucket, host
ORDER BY ts_bucket;
注意 sumState / sumMerge 的配对关系:
| 阶段 | 函数 | 作用 |
|---|---|---|
| MV 写入 | sumState(value) |
将值编码为中间聚合状态 |
| 查询 | sumMerge(value_sum) |
将多个中间状态合并为最终结果 |
如果直接用 sum(value_sum) 查询 AggregatingMergeTree,会得到错误结果或报错。
5. 多级 Rollup 的写入与查询策略
链式 MV:从 10min 再聚合到 1h
sql
CREATE TABLE metrics_1h (
ts_bucket DateTime,
host String,
metric String,
value_sum AggregateFunction(sum, Float64),
value_max AggregateFunction(max, Float64),
value_count AggregateFunction(count, Float64)
) ENGINE = AggregatingMergeTree()
PARTITION BY toYYYYMM(ts_bucket)
ORDER BY (metric, host, ts_bucket)
TTL ts_bucket + INTERVAL 180 DAY;
CREATE MATERIALIZED VIEW mv_metrics_1h
TO metrics_1h
AS SELECT
toStartOfHour(ts_bucket) AS ts_bucket,
host,
metric,
sumMergeState(value_sum) AS value_sum,
maxMergeState(value_max) AS value_max,
countMergeState(value_count) AS value_count
FROM metrics_10min
GROUP BY ts_bucket, host, metric;
注意这里用的是 sumMergeState 而不是 sumState------因为输入已经是聚合状态,需要先 merge 再重新编码为 state。
查询路由策略
应用层根据查询的时间范围选择表:
时间范围 <= 1 小时 → metrics_raw
时间范围 <= 24 小时 → metrics_10min
时间范围 <= 30 天 → metrics_1h
时间范围 > 30 天 → metrics_1d
也可以用 ClickHouse 的 VIEW 封装路由逻辑,但实践中应用层路由更灵活、更可控。
6. 为什么不能一张表打天下
"直接在原始表上 GROUP BY 不行吗?" 我们用数据说话:
假设场景:1000 台主机 × 50 个指标 × 每秒 1 条 = 5000 万条/天
| 查询 | 扫描原始表 | 扫描 10min 聚合表 | 加速比 |
|---|---|---|---|
| 最近 1 小时趋势 | 1.8 亿行 | 30 万行 | 600x |
| 最近 7 天趋势 | 35 亿行 | 504 万行 | 700x |
| 最近 30 天趋势 | 150 亿行 | 2160 万行 | 700x |
除了扫描量,还有以下问题:
- 原始表 TTL 3 天后数据就没了,30 天趋势根本查不到
- 大范围 GROUP BY 消耗大量内存,可能触发 OOM
- 并发查询时集群负载飙升,影响写入稳定性
多级 Rollup 的本质是用空间换时间,用写入时的计算换查询时的速度。在时序、监控、日志分析等场景中,这是标准做法。
总结
| 要点 | 说明 |
|---|---|
| MV 是同步的 | INSERT 路径中执行,影响写入延迟 |
| 用 AggregatingMergeTree | 支持两阶段聚合,merge 时继续合并 |
| State/Merge 函数配对 | 写入用 xxxState,查询用 xxxMerge |
| 链式 MV 用 MergeState | 从聚合表再聚合时用 xxxMergeState |
| 查询路由 | 根据时间范围命中不同粒度的表 |
| 控制 MV 数量 | 超过 5 个需要评估写入链路压力 |
下一篇我们讨论 ClickHouse 中最容易踩坑的操作------UPDATE 和 DELETE。