做过监控大屏或日志平台的同学,大概率画过"当日日志量趋势"这种折线图:ClickHouse 里 GROUP BY 时间桶一查,图是出来了,但折线七零八落------没有日志的分钟在结果集里直接缺失,前端要么断线,要么把前后两个点硬连起来,低峰期看起来像"跳变"。更麻烦的是环比、同比、移动平均这类计算:缺桶会让分母悄悄变小,算出来的比率根本没法看。
根因很简单:GROUP BY 只返回"有数据的桶",而业务需要的是"完整等间距时间序列"。ClickHouse 从 21.8 开始为 ORDER BY 提供了 WITH FILL 修饰符,可以在数据库层把缺失的时间桶补出来,数值列默认补 0,也就是常说的"补零"。
这篇文章整理我们在可观测平台生产环境跑通的一整套方案:四张分级 TTL 的指标表(1 分钟 / 5 分钟 / 1 小时 / 1 天)+ 三个物化视图级联聚合 + 大屏侧的 WITH FILL 查询 。建表语句和生产 SQL 都来自真实环境(已脱敏改名),可以直接参考。表分层、分区与排序键的通用取舍在《ClickHouse 可观测数据建模实战》里详细写过,本文聚焦"时间序列补零"和"多粒度指标表"两个专题,不再重复。
一、整体设计:一张明细表 + 三档聚合表
可观测平台的指标查询有个典型特征:查询的时间范围越长,需要的粒度就越粗。看今天的心跳用 1 分钟粒度,看一周 5 分钟粒度足够,看一个月、一年就该用小时级、天级。如果所有查询都打在 1 分钟明细表上,一个"近一年趋势"就要扫几亿行,大屏刷一次等半分钟。
所以标准做法是按粒度分表 + 物化视图实时聚合,查询端按时间范围路由:
┌── mv ──→ obs_metric_5min (TTL 30 天,5 分钟粒度)
写入 ──→ obs_metric_1min ──┼── mv ──→ obs_metric_1hour (TTL 60 天,小时粒度)
(TTL 20 天) └── mv ──→ obs_metric_1day (TTL 90 天,天粒度)
大屏的时间范围选择器直接映射到表:当天查 1min 表、一周查 5min 表、一月查 1hour 表、一年查 1day 表。查询永远只扫"够用就好"的数据量,这就是这套表存在的意义。
二、四张表的建表语句
2.1 一分钟明细表(数据入口)
sql
CREATE TABLE obs_dw.obs_metric_1min (
app_code String COMMENT '应用系统编码',
data_center String COMMENT '数据中心',
item_code String COMMENT '指标编码(三级)',
item_name String COMMENT '指标名称(三级)',
item_value Decimal(20, 4) COMMENT '指标值(1 分钟内的均值)',
item_group String COMMENT '指标分组',
time DateTime COMMENT '统计时间(对齐到分钟)',
save_time DateTime DEFAULT now() COMMENT '入库时间'
) ENGINE = MergeTree
PARTITION BY toYYYYMMDD(time)
ORDER BY time
TTL time + toIntervalDay(20)
SETTINGS index_granularity = 8192;
三个决策说明一下:
Decimal(20, 4)而不是 Float64:指标值上游是流任务算好的分钟均值,Decimal 保住小数精度,前端展示和二次聚合不会累计浮点误差;ORDER BY time:这张表 20 天 TTL、按天分区,主要服务"最近 N 分钟全网扫描"类的大屏查询,时间放最前剪枝最狠。如果你的场景里"单个应用查一个月曲线"更高频,就应该把app_code, item_code放到时间前面,这个取舍在建模那篇里展开过;- TTL 20 天:明细只服务"当天 + 近几天"的查询,粗粒度查询走聚合表,明细没必要长留。
2.2 三档聚合表
sql
-- 5 分钟粒度:按天分区,保留 30 天
CREATE TABLE obs_dw.obs_metric_5min (
app_code String,
data_center String,
item_code String COMMENT '指标编码(三级)',
item_name String COMMENT '指标名称(三级)',
item_value Decimal(20, 4) COMMENT '5 分钟内的平均值',
item_group String,
time DateTime COMMENT '对齐到 5 分钟起点',
save_time DateTime DEFAULT now()
) ENGINE = MergeTree
PARTITION BY toYYYYMMDD(time)
ORDER BY (time, app_code, item_code)
TTL time + toIntervalDay(30)
SETTINGS index_granularity = 8192;
-- 1 小时粒度:按天分区,保留 60 天
CREATE TABLE obs_dw.obs_metric_1hour (
app_code String,
data_center String,
item_code String,
item_name String,
item_value Decimal(20, 4) COMMENT '1 小时内的平均值',
item_group String,
time DateTime COMMENT '对齐到小时起点',
save_time DateTime DEFAULT now()
) ENGINE = MergeTree
PARTITION BY toYYYYMMDD(time)
ORDER BY (time, app_code, item_code)
TTL time + toIntervalDay(60)
SETTINGS index_granularity = 8192;
-- 1 天粒度:time 用 Date 更省空间,按月分区,保留 90 天
CREATE TABLE obs_dw.obs_metric_1day (
app_code String,
data_center String,
item_code String,
item_name String,
item_value Decimal(20, 4) COMMENT '1 天内的平均值',
item_group String,
time Date COMMENT '对齐到天',
save_time DateTime DEFAULT now()
) ENGINE = MergeTree
PARTITION BY toYYYYMM(time)
TTL time + toIntervalDay(90)
SETTINGS index_granularity = 8192;
核心原则一句话:粒度越粗,保留越久 (20/30/60/90 天只是我们的配置,按合规和查询需求定)。天级表数据量小到一天可能只有几万行,所以分区从按天换成按月,避免分区数膨胀;time 也降级成 Date,四个字节省一半。排序键和明细表相反------聚合表的查询几乎总是"一段时间内一批应用的一批指标",(time, app_code, item_code) 让时间范围和维度过滤都能吃上索引。
三、物化视图级联:一次写入,三档聚合
3.1 三个视图全部挂在明细表上
这里有个容易做错的选择题:1hour 视图挂在哪张表上?挂在 5min 表上"层层套娃"看起来更省计算,但 avg(avg()) 是有偏估计------每个 5 分钟桶里的样本数不同,对均值再求均值和"对全部 1 分钟样本求均值"结果不一样。我们的做法是三个视图全部从 1min 明细表计算:
sql
CREATE MATERIALIZED VIEW obs_dw.mv_obs_metric_5min
TO obs_dw.obs_metric_5min AS
SELECT
app_code,
data_center,
replaceOne(item_code, '1MIN_', '5MIN_') AS item_code,
item_name,
item_group,
avg(item_value) AS item_value,
toStartOfInterval(time, INTERVAL 5 MINUTE) AS time
FROM obs_dw.obs_metric_1min
GROUP BY
app_code,
data_center,
replaceOne(item_code, '1MIN_', '5MIN_'),
item_name,
item_group,
toStartOfInterval(time, INTERVAL 5 MINUTE);
CREATE MATERIALIZED VIEW obs_dw.mv_obs_metric_1hour
TO obs_dw.obs_metric_1hour AS
SELECT
app_code,
data_center,
replaceOne(item_code, '1MIN_', '1HOUR_') AS item_code,
item_name,
item_group,
avg(item_value) AS item_value,
toStartOfHour(time) AS time
FROM obs_dw.obs_metric_1min
GROUP BY
app_code,
data_center,
replaceOne(item_code, '1MIN_', '1HOUR_'),
item_name,
item_group,
toStartOfHour(time);
CREATE MATERIALIZED VIEW obs_dw.mv_obs_metric_1day
TO obs_dw.obs_metric_1day AS
SELECT
app_code,
data_center,
replaceOne(item_code, '1MIN_', '1DAY_') AS item_code,
item_name,
item_group,
avg(item_value) AS item_value,
toDate(time) AS time
FROM obs_dw.obs_metric_1min
GROUP BY
app_code,
data_center,
replaceOne(item_code, '1MIN_', '1DAY_'),
item_name,
item_group,
toDate(time);
三个写法要点:
replaceOne前缀替换 :指标编码带粒度前缀(如1MIN_LOG_ERR_CNT→5MIN_LOG_ERR_CNT/1HOUR_/1DAY_),同一根曲线在不同粒度表里编码不同,查询端拿到"指标名 + 时间范围"就能唯一定位到表和编码,不需要额外维护映射表;toStartOfInterval/toStartOfHour/toDate做时间对齐:保证同一个桶的时间戳完全一致,这是后面 WITH FILL 能对齐步长的前提;- GROUP BY 必须与 SELECT 的非聚合列完全一致 ,
replaceOne(...)在 SELECT 和 GROUP BY 里要原样出现两遍。
代价也要说清楚:一次写入 1min 表会触发三次聚合计算(写入放大),对我们单表日均千万行的量级完全无感;如果明细写入量再大一个数量级,可以评估只挂一个视图、其余粒度用流任务直接写(AggregatingMergeTree 方案是另一条路线)。
四、大屏查询:WITH FILL 补零
4.1 不补零的查询长什么样
大屏上的"当日日志量 5 分钟趋势",直观写法:
sql
SELECT
toStartOfFiveMinute(window_start) AS time_bucket,
sum(line_counts) AS total_lines
FROM obs_dw.dws_log_linecnt -- 流任务每 5 分钟写入的日志行数汇总表
WHERE window_start >= toStartOfDay(now())
AND window_start <= now()
GROUP BY time_bucket
ORDER BY time_bucket;
没有日志的桶直接消失,返回结果可能是这样的:
| time_bucket | total_lines |
|---|---|
| 2026-10-07 08:10:00 | 1234 |
| 2026-10-07 08:20:00 | 1180 |
| 2026-10-07 08:25:00 | 1310 |
08:15 这个桶没有行,前端画出来的折线在 08:10~08:20 之间就是斜线直连,看起来 08:15 也有量。在 WITH FILL 之前,大家要么在前端按时间轴补点,要么用 arrayJoin(timeSlots(...)) 生成完整时间序列再 LEFT JOIN 数据,两种写法都又长又难维护。
4.2 WITH FILL 语法
WITH FILL 是 ORDER BY 表达式的修饰符,完整形式:
sql
ORDER BY expr WITH FILL [FROM 常量] [TO 常量] [STEP 常量]
- 只能用在 ORDER BY 的列上,按该列的值把缺口补齐;
FROM/TO可选,用来控制补齐的起止边界(首尾没有数据的时间段也能补出来);STEP是步长,数值列直接写数字;Date类型单位是天,DateTime类型单位是秒,也支持STEP INTERVAL 5 MINUTE的写法(老版本只支持数字步长,STEP 300等价于INTERVAL 5 MINUTE)。
4.3 生产 SQL:当日趋势完整版
把 4.1 的查询补上 WITH FILL 三件套:
sql
SELECT
toStartOfFiveMinute(window_start) AS time_bucket,
sum(line_counts) AS total_lines
FROM obs_dw.dws_log_linecnt
WHERE window_start >= toStartOfDay(now())
AND window_start <= now()
GROUP BY time_bucket
ORDER BY time_bucket
WITH FILL
FROM toStartOfDay(now())
TO toStartOfFiveMinute(now())
STEP INTERVAL 5 MINUTE;
三个参数逐个解释:
FROM toStartOfDay(now()):从当天 0 点开始补。就算第一条日志 8 点才出现,0:00~7:55 的桶也会以total_lines = 0出现在结果里------"开局缺零"是手工补点最容易漏的场景;TO toStartOfFiveMinute(now()):补到当前时间所在的桶,图表拉到"现在";STEP INTERVAL 5 MINUTE:步长 5 分钟,必须与分桶函数toStartOfFiveMinute严格一致,错位会出现半桶时间点。
错误日志趋势、告警趋势换个表名就是同一个模板:
sql
SELECT
toStartOfFiveMinute(window_start) AS time_bucket,
sum(line_counts) AS total_lines
FROM obs_dw.dws_errlog_linecnt -- 错误日志行数汇总表
WHERE window_start >= toStartOfDay(now())
AND window_start <= now()
GROUP BY time_bucket
ORDER BY time_bucket
WITH FILL
FROM toStartOfDay(now())
TO toStartOfFiveMinute(now())
STEP INTERVAL 5 MINUTE;
4.4 配合多粒度表:近 7 天小时曲线
时间范围一长就切到聚合表,WITH FILL 同样适用。近 7 天某个应用的错误数小时曲线,直接查 1hour 表:
sql
SELECT
time AS hour_bucket,
item_value AS err_cnt
FROM obs_dw.obs_metric_1hour
WHERE app_code = 'order-svc'
AND item_code = '1HOUR_LOG_ERR_CNT'
AND time >= now() - INTERVAL 7 DAY
ORDER BY hour_bucket
WITH FILL
FROM toStartOfHour(now() - INTERVAL 7 DAY)
TO toStartOfHour(now())
STEP INTERVAL 1 HOUR;
这里没有 GROUP BY(聚合在写入侧已经做完),查询只是按时间取点 + 补零,单应用 7 天小时数据就一百多行,毫秒级返回。
五、WITH FILL 的行为细节
用之前把这几条行为弄清楚,能少走弯路:
- 补出来的行,除了填充列,其它列都是类型默认值 :数值 0、空字符串、
1970-01-01。sum()补 0 正好是业务想要的;但"成功率"这类比率指标补 0 会造成"看起来全失败"的误导,要么前端把 0 渲染成断线,要么查询层包一层 CASE WHEN 区分"真 0"和"没数据"。更新的版本还提供INTERPOLATE子句,可以用相邻行的值插值填充其它列,适合"用上一个点的值顶替"的场景。 - 它认的是 ORDER BY,不是 GROUP BY :填充依据是排序键列的取值,所以
ORDER BY子句不能省;写DESC倒序时填充方向跟着倒,FROM要大于TO,步长仍写正值。 - 多列 ORDER BY 可以有多个 WITH FILL :比如
ORDER BY app_code, time WITH FILL ...各自带修饰符时,会形成类似"分组补零"的二维填充------不过多数场景单列时间填充就够用了,二维填充的行为建议先拿小数据验证。 - 版本要求 :21.8 起支持
WITH FILL;INTERVAL步长和INTERPOLATE是后续版本引入的,用老集群先确认版本,不行就退回数字步长。
六、踩坑清单
按疼痛程度排序,都是实际发生过的事:
- 物化视图的 GROUP BY 漏列,源表写入直接失败 。第一版视图 SELECT 里写了
item_group,GROUP BY 里漏了它,报Expression item_group is not in GROUP BY。要特别注意:默认情况下物化视图执行失败会阻断源表的 INSERT,等于把数据入口打挂了,比查询报错严重得多。 replaceOne前缀替换依赖编码规范 。编码里必须严格带1MIN_前缀;一旦某条数据不带前缀,replaceOne原样返回,这行会以1MIN_编码混进聚合表,查询端按5MIN_/1HOUR_前缀取数时它就"消失"了。编码规范要在写入端强约束。- avg of avg 偏差 。聚合视图一律从 1min 明细算,不要图省事从 5min 表往 1hour 表套。每个桶样本数不同时,
avg(avg(x))和avg(x)结果会稳定地差一截,报表对不上数很难排查。 - 步长与分桶函数错位 。
toStartOfFiveMinute配STEP INTERVAL 10 MINUTE这种写法,补出来的点和真实桶会错开,曲线上表现为锯齿或丢点。分桶函数和 STEP 永远成对修改。 - 比率型指标补 0 的误导(见第五节第 1 条),大屏上"0"和"无数据"必须视觉区分,这是和产品经理对过需求才能发现的坑。
- TTL 不是准点删除。四张表 20/30/60/90 天的 TTL 都靠 merge 异步清理,写入高峰期 merge 积压时磁盘不会立刻降下来,容量告警别按 TTL 边界卡预期。
- 明细则算便宜则算贵。三视图挂一张明细表的写入放大要心里有数:我们日均千万行无感,但如果你的明细是十亿级,就该减少视图、把粗粒度聚合挪到流任务侧做。
写在最后
时间序列补零是个小需求,但把它做对是三层配合的结果:存储层 用多粒度表 + 分级 TTL 控制扫描量,写入层 用物化视图级联把聚合做成"顺便的事",查询层用 WITH FILL 一行修饰符替代前端补点和小表 JOIN 的祖传代码。三者拼起来,大屏任意时间范围的曲线都是毫秒级返回且永不断线。
你们项目的补零是在 SQL 层做还是前端做?有没有踩过 WITH FILL 或者物化视图更疼的坑?欢迎评论区聊聊。
相关阅读: