- 可观测性
- 后端
【免费下载链接】highlight
highlight.io: The open source, full-stack monitoring platform. Error monitoring, session replay, logging, distributed tracing, and more.
导读
本文是 highlight.io(开源全栈可观测性平台)工程团队的技术实战分享,聚焦其告警系统中"大时间窗聚合计算过慢"这一真实痛点,讲解如何借助 ClickHouse 的-State/-Merge函数组合器,通过"先算小粒度中间态、后合并"的增量计算方案,将告警评估耗时从 1.24s 降到 0.11s、内存占用从 7.6GB 降到 82MB。读完本文,你将理解 ClickHouse 聚合态(AggregateFunction)数据类型的核心原理、告警评估流水线的完整设计,以及如何在生产环境中用max_block_number/_block_number做增量数据追踪。
背景:为什么 Highlight 选择 ClickHouse
Highlight 依赖 ClickHouse——一款为海量数据与实时分析而生的开源列式数据库,来存储和查询日志(logs)、链路(traces)、错误(errors)等时序数据,并借助它完成快速的聚合与过滤。列式存储、向量化执行、物化视图等特性,让 ClickHouse 在处理这类"写多读少、聚合密集"的分析型负载时表现出色。
不过,使用一款相对较新的数据库也意味着要直面其特有的工程挑战(Highlight 曾在 lw5-clickhouse-performance-optimization 中分享过 ClickHouse 性能调优经验)。本文要讨论的,正是如何利用 ClickHouse 的一组特定功能——聚合函数组合器——来解决告警系统遇到的性能瓶颈。
挑战:大时间窗告警评估的"重复扫描"困局
在优化告警系统时,团队遇到的核心矛盾是:基于大时间窗口计算告警是否触发,代价过于高昂。
告警评估天然需要"近乎实时"的反馈。设想用户设置了一条告警:"过去一小时内,网络请求时长的 p99(99 分位)超过 1 秒即触发"。为了让用户尽快收到通知,系统需要每分钟评估一次这条告警。
如果采用朴素实现,每分钟都要重新对整整一小时的窗口做全量计算,也就是:
- 每个告警、每小时要扫描同一份数据60 次;
- 告警数量多时,扫描次数与计算开销线性放大;
- 全量扫描耗时过长,且内存占用巨大,实时处理大体积数据流不可行。
问题的本质在于:可复用的中间结果没有被保留,重复劳动被白白浪费。解决方向也随之清晰——用 ClickHouse 的聚合函数做增量计算:先计算并存储更小的"部分结果",之后再把多个部分结果合并起来得到最终值。
简单聚合的增量合并:Count 与 Sum
对于Count、Sum这类简单聚合函数,优化路径非常直观:保存前一次计算的结果,增量计算并聚合中间结果。
举例来说,若设置一条日志告警"一小时内日志条数超过 100 即触发":
- 每分钟计算一次该分钟的日志条数并保存;
- 评估时加载最近 60 个分钟计数;
- 求和后与阈值 100 比较。
求和过程的中间步骤示意如下(原文图示):
[第1分钟计数] ─┐ [第2分钟计数] ─┼──► [sum 合并] ──► 与阈值比较 ──► 是否告警 [第3分钟计数] ─┘每分钟只需处理"新增的 1 分钟数据",而不是重扫整个小时窗口,这就是增量合并带来的直接收益。
复杂聚合的困境:中位数无法简单"汇总"
然而,当遇到更复杂的聚合函数时,上述思路会失效。以计算精确 p50(中位数)为例:
对 5 个窗口分别算出 5 个中间 p50 值,然后"rollup(汇总)"这 5 个结果——这是做不到的。
原因在于,中位数这类基于全量排序的统计量,其值依赖整个数据分布,局部窗口的中位数无法还原全局分布。要得到精确结果,只能保存每一个数据点再去计算——这与增量合并的初衷背道而驰。
破局:-State 与 -Merge 函数组合器
ClickHouse 强大的-State与-Merge组合器,恰好提供了两全其美的方案:
-State函数:返回计算过程的中间状态(intermediate state),而非最终结果;-Merge函数:接收多个中间状态并合并,输出最终结果。
其机制示意如下(原文图示,QS 表示 quantile state、QM 表示 quantile merge):
原始数据 ──► quantileState(QS)──┐ 原始数据 ──► quantileState(QS)──┼──► quantileMerge(QM)──► p50/p90/p99 结果 原始数据 ──► quantileState(QS)──┘这意味着:我们可以在多个小数据片段上分别运行-State函数并保存状态,之后随时用-Merge把状态合并成完整结果——状态体积远小于原始数据,因此"先算一次状态、多次合并"远比"每次都全量重算"高效。
uniq 与 quantile:内存有界的近似算法
ClickHouse 用内存高效的近似算法实现许多复杂聚合函数,Highlight 用到的两个典型例子是:
| 函数 | 作用 | 实现特点 |
|---|---|---|
uniq | 返回去重计数的近似值 | 基于采样算法(如 HyperLogLog 系思路) |
quantile | 计算近似的 p50 / p90 / p95 / p99 | 基于分位数采样算法 |
这些算法通过不同形式的采样,从底层数据分布中提取一组有代表性的值。因为是有有限最大尺寸的采样算法,其内存占用是有界的——这正是状态可以安全持久化、反复合并的前提。
状态函数的"逆运算"关系
uniqState/quantileState返回这些计算过程的底层状态表示;uniqMerge/quantileMerge则接收状态、返回结果值,二者互为逆运算:
uniqMerge(uniqState(x)) = uniq(x)一个关键推论是:uniqState可以针对多个小数据块分别运行、分别保存,之后再统一合并。中间状态比底层输入数据小得多,于是"状态只算一次、合并多次"成为现实。
实现方案:metric_history 表与增量流水线
Highlight 的整体方案可以概括为三句话:
每分钟加载全部新数据 → 对新数据计算中间状态 → 与既有状态合并,得到目标时间范围的聚合值。
表结构:每种聚合一个状态列
由于每个状态列的 ClickHouse 类型各不相同,实现中为每种支持的聚合函数单独设列。表结构如下(见 000108_create_metric_history_table.up.sql 与原文 schema):
CREATE TABLE default.metric_history ( `MetricId` UUID, `Timestamp` DateTime, `GroupByKey` String, `MaxBlockNumberState` AggregateFunction(max, UInt64), `CountState` AggregateFunction(count, UInt64), `UniqState` AggregateFunction(uniq, String), `MinState` AggregateFunction(min, Float64), `AvgState` AggregateFunction(avg, Float64), `MaxState` AggregateFunction(max, Float64), `SumState` AggregateFunction(sum, Float64), `P50State` AggregateFunction(quantile(0.5), Float64), `P90State` AggregateFunction(quantile(0.9), Float64), `P95State` AggregateFunction(quantile(0.95), Float64), `P99State` AggregateFunction(quantile(0.99), Float64) ) ENGINE = ReplicatedAggregatingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') ORDER BY (MetricId, Timestamp, GroupByKey) SETTINGS index_granularity = 8192要点解读:
AggregateFunction(...)类型列:ClickHouse 中-State函数产出的中间状态即以此数据类型存储;状态列所在的表引擎选用AggregatingMergeTree(及其副本版本ReplicatedAggregatingMergeTree),后台合并时会对同一排序键内的状态做自动合并,进一步压缩行数。- 按
(MetricId, Timestamp, GroupByKey)排序:天然支持"按指标、按分钟粒度、按分组键"的定位与聚合。 - 数据以分钟粒度计算:每分钟一个状态行,评估时按需合并最近 N 分钟的状态。
状态写入:从查询结果到状态列
状态写入发生在saveMetricHistory逻辑中(见 backend/clickhouse/query.go#L1406-L1469):当一次ReadMetrics查询携带SavedMetricState时,系统会按告警的聚合函数类型,将对应状态列写入metric_history:
Count→CountStateCountDistinct→UniqStateMin/Avg/Max/Sum→ 对应的MinState/AvgState/MaxState/SumStateP50/P90/P95/P99→ 对应的P50State/P90State/P95State/P99State
有分组(GroupBy)时还会同时写入GroupByKey。
状态读取:按聚合类型选择 Merge 函数
评估阶段的状态合并逻辑位于AggregateMetricStates(见 backend/clickhouse/metric_history.go#L61-L135),它根据告警的聚合器类型调用对应的-Merge函数还原结果:
| 聚合器 | 状态列 | 合并表达式 |
|---|---|---|
Count | CountState | countMerge(CountState) |
CountDistinct | UniqState | uniqMerge(UniqState) |
Min | MinState | minMerge(MinState) |
Avg | AvgState | avgMerge(AvgState) |
Max | MaxState | maxMerge(MaxState) |
Sum | SumState | sumMerge(SumState) |
P50 | P50State | quantileMerge(.5)(P50State) |
P90 | P90State | quantileMerge(.9)(P90State) |
P95 | P95State | quantileMerge(.95)(P95State) |
P99 | P99State | quantileMerge(.99)(P99State) |
查询按MetricId过滤、按时间范围裁剪,支持按GroupByKey分组,并可依据windowSeconds将结果切成等宽时间桶(Bucket),供异常检测等场景使用。
增量数据追踪:max_block_number 与 _block_number 双重过滤
增量方案的核心前提是"只处理新数据"。实现中通过两层机制识别新增数据:
第一层:按分区记录已消费的 max_block_number
GetBlockNumbers(见 backend/clickhouse/metric_history.go#L27-L53)从metric_history中读出每个分区(按天)上次已处理的max_block_number:
SELECT toString(toDate(date_trunc('day', Timestamp))) as Partition, maxMerge(MaxBlockNumberState) as LastBlockNumber FROM metric_history WHERE MetricId = {metricId} AND Timestamp >= {startDate} AND Timestamp < {endDate} GROUP BY 1这里MaxBlockNumberState列的妙处在于:它本身就是一个AggregateFunction(max, UInt64)状态,通过maxMerge还原出该分区内已处理的最大块号,作为下一次增量的起点。
第二层:块号过滤与去重
识别"有新数据的 part":applyBlockFilter(见 backend/clickhouse/query.go#L1376-L1404)会查询system.parts,找出分区匹配、且max_block_number大于上次记录值的 active part,用_part IN (...)限定扫描范围。
然而,仅靠 part 级过滤并不精确——某些 part 可能包含已读过的旧数据。为避免重复计数,底层表开启了allow_experimental_block_number_column(迁移见 000107_enable_bucket_number.up.sql,对traces、logs表执行):
ALTER TABLE traces MODIFY SETTING allow_experimental_block_number_column = true; ALTER TABLE logs MODIFY SETTING allow_experimental_block_number_column = true;开启后,每一行都会带上虚拟列_block_number(数据块级单调递增编号)。查询时再叠加行级过滤:
WHERE (_partition_id = {partition} AND _block_number > {last_block_number}) OR ...这样,即便某个 part 混有新老数据,也能精确跳过已处理的行,从源头杜绝双计。
告警评估主循环:从评估到触发的完整链路
整个增量方案的运转由告警监控任务驱动。WatchMetricAlerts(见 backend/jobs/metric-alerts/metric-alerts.go#L40-L68)是常驻循环:
- 以
time.Minute为周期(alertEvalFreq)触发一轮评估; - 拉取所有未禁用的告警,通过最大 40 个 worker(
maxWorkers)的 workerpool 并发处理(metric-alerts.go#L27-L29)。
单个告警的评估流程(processMetricAlert,metric-alerts.go#L81-L340)大致为:
- 确定评估窗口:以当前时间整分钟为基准,向前取
ThresholdWindow(默认 1 小时); - 读取已保存状态:
GetBlockNumbers取出各分区已处理的块号,构造SavedMetricState; - 增量读新数据:
ReadMetrics携带SavedMetricState,经applyBlockFilter只扫描新增 part 与新增行,同时saveMetricHistory将本次新数据的中间状态写入metric_history; - 合并出窗口值:
AggregateMetricStates对窗口内的全部状态做-Merge合并,得到该告警的聚合值(常量阈值或异常检测桶); - 阈值比较与冷却:将合并值与阈值比较(支持 Above / Below / Outside 等条件),并通过
getAlertStateChange结合上次告警时间与ThresholdCooldown决定状态(Alerting/AlertingSilently/Normal); - 派发通知:处于
Alerting状态的告警交由SendAlerts(见 backend/alerts/v2/alerts.go#L27-L112)分发到 Slack、Discord、Microsoft Teams、Email、Webhook 等渠道; - 记录状态变更:
WriteAlertStateChanges写入本次评估结果,供下次评估与冷却逻辑使用。
这套流水线让"每分钟评估"只付出"处理新增数据 + 合并状态"的代价,而非每小时 60 次全量扫描。
结论与实测收益
整体而言,借助 ClickHouse 的-State/-Merge函数组合器,Highlight 的告警评估过程获得了显著性能提升。在真实 Highlight 工作区对日志告警评估进行测试,实测数据为:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 评估耗时 | 1.24s | 0.11s(约10 倍加速) |
| 内存占用 | 7.6 GB | 82 MB(降低约99%) |
值得强调的是,这套收益并非来自某种特殊优化,而是来自一个通用范式:用有界内存的聚合状态替代原始数据,把"重复全量计算"变成"一次计算、多次合并"。对于任何基于大时间窗、高频评估的分析型告警系统,这都是一条可复用的路径。
延伸阅读
- 深入理解
AggregateFunction类型与-State/-Merge组合器的官方语义,可参考 ClickHouse 关于 AggregateFunction 数据类型 的文档; - 本文方案的完整落地代码:状态表结构与迁移 000108_create_metric_history_table.up.sql、块号开关 000107_enable_bucket_number.up.sql、状态读写 metric_history.go、块过滤与状态写入 query.go、告警评估主循环 metric-alerts.go;
- Highlight 的 ClickHouse 性能优化经验总结,见 lw5-clickhouse-performance-optimization。
- 可观测性
- 后端
【免费下载链接】highlight
highlight.io: The open source, full-stack monitoring platform. Error monitoring, session replay, logging, distributed tracing, and more.
相关推荐
Vitest 配置指南:expandSnapshotDiff 快照失败差异展示详解
Vitest 配置指南:expandSnapshotDiff 快照失败差异展示详解 导读 expandSnapshotDiff 是 Vitest 中控制快照(s
可观测性后端Android消息机制完全指南:Handler、Looper、MessageQueue源码解析
Android消息机制完全指南:Handler、Looper、MessageQueue源码解析 Android消息机制是Android开发的核心基础,其中Han
Dgraph缓存调优实战:从评估到性能倍增指南
Dgraph缓存调优实战:从评估到性能倍增指南 你还在为Dgraph数据库查询延迟高而烦恼吗?当数据量增长到百万级节点后,缓存配置不当导致的性能瓶颈是否让用户体
数据库图数据库分布式数据库后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考