news 2026/9/25 7:30:56

Highlight 告警评估性能优化实录:ClickHouse -State/-Merge 增量合并实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Highlight 告警评估性能优化实录:ClickHouse -State/-Merge 增量合并实战
  • 可观测性
  • 后端

【免费下载链接】highlight

highlight.io: The open source, full-stack monitoring platform. Error monitoring, session replay, logging, distributed tracing, and more.

项目地址:https://gitcode.com/gh_mirrors/hi/highlight
点击查看免费下载

导读

本文是 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 即触发":

  1. 每分钟计算一次该分钟的日志条数并保存;
  2. 评估时加载最近 60 个分钟计数;
  3. 求和后与阈值 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→CountState
  • CountDistinct→UniqState
  • Min/Avg/Max/Sum→ 对应的MinState/AvgState/MaxState/SumState
  • P50/P90/P95/P99→ 对应的P50State/P90State/P95State/P99State

有分组(GroupBy)时还会同时写入GroupByKey。

状态读取:按聚合类型选择 Merge 函数

评估阶段的状态合并逻辑位于AggregateMetricStates(见 backend/clickhouse/metric_history.go#L61-L135),它根据告警的聚合器类型调用对应的-Merge函数还原结果:

聚合器状态列合并表达式
CountCountStatecountMerge(CountState)
CountDistinctUniqStateuniqMerge(UniqState)
MinMinStateminMerge(MinState)
AvgAvgStateavgMerge(AvgState)
MaxMaxStatemaxMerge(MaxState)
SumSumStatesumMerge(SumState)
P50P50StatequantileMerge(.5)(P50State)
P90P90StatequantileMerge(.9)(P90State)
P95P95StatequantileMerge(.95)(P95State)
P99P99StatequantileMerge(.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)是常驻循环:

  1. 以time.Minute为周期(alertEvalFreq)触发一轮评估;
  2. 拉取所有未禁用的告警,通过最大 40 个 worker(maxWorkers)的 workerpool 并发处理(metric-alerts.go#L27-L29)。

单个告警的评估流程(processMetricAlert,metric-alerts.go#L81-L340)大致为:

  1. 确定评估窗口:以当前时间整分钟为基准,向前取ThresholdWindow(默认 1 小时);
  2. 读取已保存状态:GetBlockNumbers取出各分区已处理的块号,构造SavedMetricState;
  3. 增量读新数据:ReadMetrics携带SavedMetricState,经applyBlockFilter只扫描新增 part 与新增行,同时saveMetricHistory将本次新数据的中间状态写入metric_history;
  4. 合并出窗口值:AggregateMetricStates对窗口内的全部状态做-Merge合并,得到该告警的聚合值(常量阈值或异常检测桶);
  5. 阈值比较与冷却:将合并值与阈值比较(支持 Above / Below / Outside 等条件),并通过getAlertStateChange结合上次告警时间与ThresholdCooldown决定状态(Alerting/AlertingSilently/Normal);
  6. 派发通知:处于Alerting状态的告警交由SendAlerts(见 backend/alerts/v2/alerts.go#L27-L112)分发到 Slack、Discord、Microsoft Teams、Email、Webhook 等渠道;
  7. 记录状态变更:WriteAlertStateChanges写入本次评估结果,供下次评估与冷却逻辑使用。

这套流水线让"每分钟评估"只付出"处理新增数据 + 合并状态"的代价,而非每小时 60 次全量扫描。

结论与实测收益

整体而言,借助 ClickHouse 的-State/-Merge函数组合器,Highlight 的告警评估过程获得了显著性能提升。在真实 Highlight 工作区对日志告警评估进行测试,实测数据为:

指标优化前优化后
评估耗时1.24s0.11s(约10 倍加速)
内存占用7.6 GB82 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.

项目地址:https://gitcode.com/gh_mirrors/hi/highlight
点击查看免费下载
上一篇:7步掌握炉石传说自动化:开源脚本完全指南
下一篇:Hearthstone-Script终极指南:5步实现炉石传说自动化游戏脚本

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 7:30:24

Windows下安全修改MAC地址的三种实操方法

1. 项目概述&#xff1a;为什么普通人也需要关心MAC地址&#xff1f;MAC地址&#xff0c;全称Media Access Control Address&#xff0c;是网卡出厂时烧录在硬件里的唯一物理标识符&#xff0c;就像身份证号之于人、VIN码之于汽车。它工作在OSI模型的第二层&#xff08;数据链路…

作者头像 李华
网站建设 2026/9/25 7:30:00

phpenv搭建PHP多版本管理工具

phpenv是一个简单易用的PHP版本管理工具&#xff0c;帮助开发者轻松管理多个PHP版本并实现快速切换。如果你需要在同一台机器上测试不同版本的PHP应用程序&#xff0c;phpenv就是你的完美解决方案&#xff01;&#x1f680;为什么选择phpenv&#xff1f;多版本PHP管理变得前所未…

作者头像 李华
网站建设 2026/9/25 7:28:33

Agenta SSTI漏洞深度解析:Jinja2沙箱逃逸与RCE利用链

1. 这不是普通模板注入&#xff1a;Agenta的{{ }}背后是沙箱逃逸RCE链的完整复现你有没有试过&#xff0c;在一个标榜“安全沙箱”的LLMOps平台里&#xff0c;只输入一行{{ 7*7 }}&#xff0c;页面就返回了49——然后你顺手改成{{ .__class__.__mro__[2].__subclasses__() }}&a…

作者头像 李华
网站建设 2026/9/25 7:27:12

OpenResearch实战指南:搭建可复现研究与高效协作的工作流

如果你最近逛开源社区或学术圈子&#xff0c;大概率刷到过 OpenResearch 这个关键词。有人把它当一种理念&#xff0c;有人直接拿它当具体项目的名字&#xff0c;但大多数人跟我第一次看到时一样&#xff0c;觉得它有点玄乎。其实把它拆开看就一句话&#xff1a;把整个研究过程…

作者头像 李华