4、基于时间窗口的聚合:固定窗口与滑动窗口
告警聚合里最基础也最实用的手段,就是时间窗口。说白了,就是把一段时间内发生的同类告警捏成一条。你想想看,一个服务挂了,监控系统可能在几秒内收到几十条“连接超时”告警,如果每条都发出去,值班手机能被打爆。
我刚开始做运维那会儿,就吃过这个亏。有一次凌晨三点,磁盘 IO 抖动,结果告警系统一口气发了 200 多条短信。第二天被领导叫去喝茶,问我:“你就不能合并一下?” 从那以后,我对时间窗口聚合的研究就再没停过。
核心思想: 将时间轴划分为若干窗口,窗口内的同类告警合并为一条,窗口结束时统一输出。
4.1 固定窗口聚合
固定窗口聚合,就是拿一把“时间尺子”把时间轴切成等长的段。比如 1 分钟一个窗口,0:00:00 到 0:00:59 是一个窗口,0:01:00 到 0:01:59 是下一个窗口。窗口内所有相同告警合并成一条。
实现起来很简单,伪代码也就几行:
// 固定窗口聚合伪代码
window_size = 60 // 窗口大小,单位秒
current_window_start = floor(current_timestamp / window_size) * window_size
// 每条告警到达时
def process_alert(alert):
window_key = floor(alert.timestamp / window_size) * window_size
key = (window_key, alert.host, alert.type)
if key not in window_map:
window_map[key] = {
'count': 0,
'first_seen': alert.timestamp,
'last_seen': alert.timestamp,
'severity': alert.severity
}
window_map[key]['count'] += 1
window_map[key]['last_seen'] = max(window_map[key]['last_seen'], alert.timestamp)
window_map[key]['severity'] = max(window_map[key]['severity'], alert.severity)
// 窗口结束时,输出聚合结果
def on_window_end(window_end_time):
for key, agg in window_map.items():
if key[0] == window_end_time - window_size:
emit_aggregated_alert(agg)
delete window_map[key]
这种方案最大的好处是简单。你不需要维护复杂的状态,每个窗口独立计算,天然适合分布式处理。我在一个日处理 500 万条告警的系统里用过,单机就能扛住,延迟控制在秒级。
我的习惯: 固定窗口的起始时间最好对齐整点(0 秒开始),这样排查问题时,看一眼时间戳就知道落在哪个窗口里,不用来回算偏移量。
4.2 滑动窗口聚合
固定窗口有个硬伤——边界问题。举个例子:
- 告警 A 发生在 0:00:58
- 告警 B 发生在 0:01:02
这两条告警只差了 4 秒,按理说应该合并。但固定窗口把它们分到了两个窗口里,结果就是两条独立的告警。值班同学看到两条几乎一样的告警,第一反应就是“系统又抽风了”。
滑动窗口就是为了解决这个问题。它不按固定的时间点切分,而是以每条告警到达的时间为起点,往前看一个窗口长度。说白了,窗口是“跟着告警走的”。
实现上,我一般用队列加过期机制:
// 滑动窗口聚合伪代码
window_size = 60 // 窗口大小,单位秒
sliding_window = {} // key -> 告警列表(按时间排序)
def process_alert(alert):
key = (alert.host, alert.type)
if key not in sliding_window:
sliding_window[key] = []
// 清理过期告警(超过 window_size 的)
cutoff = alert.timestamp - window_size
sliding_window[key] = [a for a in sliding_window[key] if a.timestamp >= cutoff]
// 加入当前告警
sliding_window[key].append(alert)
// 如果窗口内告警数达到阈值,触发聚合
if len(sliding_window[key]) >= 1:
// 合并窗口内所有告警
emit_aggregated_alert(sliding_window[key])
// 清空窗口,避免重复聚合
sliding_window[key] = []
你看,滑动窗口里,0:00:58 和 0:01:02 这两条告警,因为时间差只有 4 秒,肯定落在同一个窗口里,自然就合并了。
我曾经踩过的坑: 滑动窗口的内存占用比固定窗口大得多。如果告警种类多、频率高,队列里会积压大量未过期的告警。我见过一个系统,跑了半天内存就爆了。后来加了上限控制,每个 key 最多保留 1000 条,超过就强制聚合。
4.3 两种窗口的对比
| 维度 | 固定窗口 | 滑动窗口 |
|---|---|---|
| 实现复杂度 | 低,天然分片 | 中,需要维护队列 |
| 内存开销 | 低,窗口结束即释放 | 高,需要保留历史数据 |
| 边界问题 | 有,窗口边界处告警被拆分 | 无,窗口随告警移动 |
| 延迟 | 固定,窗口结束时输出 | 实时,告警到达即判断 |
| 适用场景 | 低频告警、批量处理 | 高频告警、实时性要求高 |
我个人习惯是:低频场景(比如每天几百条告警)用固定窗口就够了,简单可靠。高频场景(每分钟几百条以上)用滑动窗口,虽然复杂点,但能避免边界问题带来的误报。
4.4 窗口大小对聚合效果的影响
窗口大小怎么定?这是门学问。我见过不少团队,上来就拍脑袋设个 5 分钟,结果要么聚合太狠漏了关键告警,要么聚合太弱没起到降噪效果。
窗口大小的影响,我总结成一句话:窗口越大,聚合率越高,但延迟也越大,信息丢失风险越高。
具体来说:
- 窗口太小(比如 10 秒): 聚合效果差,突发告警还是会被拆成多条。适合对实时性要求极高的场景,比如交易系统。
- 窗口适中(比如 1-5 分钟): 大部分同类告警都能合并,延迟在可接受范围内。这是大多数场景的推荐值。
- 窗口太大(比如 30 分钟以上): 聚合率很高,但告警的时效性大打折扣。如果服务挂了,30 分钟后才收到一条聚合告警,黄花菜都凉了。
我的经验: 窗口大小最好根据告警类型动态调整。比如“磁盘使用率 > 90%”这种缓慢变化的告警,窗口可以设大一点(5-10 分钟);而“服务不可用”这种紧急告警,窗口要小(30 秒-1 分钟)。我在项目里就是这么做的,效果比统一窗口好很多。
4.5 知识体系:时间窗口聚合核心逻辑
下面这张图,把固定窗口和滑动窗口的核心逻辑画出来了。你看一眼就能明白两者的区别。
4.6 实战建议
说了这么多,最后给几条实在的建议:
- 先固定后滑动: 如果刚开始做告警聚合,先用固定窗口跑起来。等数据积累够了,再评估要不要上滑动窗口。
- 窗口大小从 60 秒起步: 我试过很多场景,60 秒是个不错的起点。聚合率能到 70-80%,延迟也低。
- 监控聚合效果: 上线后一定要看两个指标——聚合率和平均延迟。聚合率太低说明窗口太小,延迟太高说明窗口太大。
- 别忘了告警恢复: 聚合后的告警,恢复逻辑也要跟着改。窗口内的告警只要有一条恢复,整个聚合告警就应该恢复。
特别注意: 滑动窗口虽然解决了边界问题,但实现复杂度高,容易出 bug。我见过一个系统,滑动窗口的过期清理逻辑写错了,导致告警永远不输出。上线前一定要写单元测试,模拟各种时间边界情况。