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 知识体系:时间窗口聚合核心逻辑

下面这张图,把固定窗口和滑动窗口的核心逻辑画出来了。你看一眼就能明白两者的区别。

时间窗口聚合核心逻辑 固定窗口聚合 窗口 1 (0-60s) 窗口 2 (60-120s) 窗口 3 (120-180s) 告警A 58s 告警B 62s 边界! 滑动窗口聚合 窗口 (58s - 118s) 窗口 (62s - 122s) 告警A 58s 告警B 62s A和B都在窗口内 → 合并 窗口大小对聚合效果的影响 • 窗口太小(10-30秒):聚合率低,适合实时性要求高的场景 • 窗口适中(1-5分钟):聚合率和延迟平衡,推荐大多数场景使用 • 窗口太大(30分钟+):聚合率高但延迟大,可能错过紧急告警 • 动态窗口:根据告警类型调整大小,紧急告警用小窗口,慢性告警用大窗口

4.6 实战建议

说了这么多,最后给几条实在的建议:

  1. 先固定后滑动: 如果刚开始做告警聚合,先用固定窗口跑起来。等数据积累够了,再评估要不要上滑动窗口。
  2. 窗口大小从 60 秒起步: 我试过很多场景,60 秒是个不错的起点。聚合率能到 70-80%,延迟也低。
  3. 监控聚合效果: 上线后一定要看两个指标——聚合率和平均延迟。聚合率太低说明窗口太小,延迟太高说明窗口太大。
  4. 别忘了告警恢复: 聚合后的告警,恢复逻辑也要跟着改。窗口内的告警只要有一条恢复,整个聚合告警就应该恢复。

特别注意: 滑动窗口虽然解决了边界问题,但实现复杂度高,容易出 bug。我见过一个系统,滑动窗口的过期清理逻辑写错了,导致告警永远不输出。上线前一定要写单元测试,模拟各种时间边界情况。