
基于时间滑动窗口的告警合并算法用 Jaccard 相似度合并 5 分钟内的重复报错在大促当夜最忙乱的时刻值班工程师的手机经常会遭遇一种极其烦人却又难以根除的骚扰“同因异文”的连环轰炸。某个核心微服务的下游缓存节点偶发超时在随后的五分钟时间窗口内手机连续震动了 180 次。点开短信一看内容千篇一律01:15:02 来自10.244.3.15:8080的Redis command timeout after 2000ms [keyuser_session_9821]01:15:04 来自10.244.3.18:8080的Redis command timeout after 2000ms [keyuser_session_4412]01:15:07 来自10.244.3.22:8080的Redis command timeout after 2000ms [keyuser_session_6531]……很多工程师尝试通过简单的字符串相等alert1 alert2来做去重。但在真实的生产环境中这种朴素做法几乎 100% 会失效。因为每一条报错日志里都不可避免地掺杂着动态变化的实例 IP 地址、端口号、随机生成的请求 TraceID 以及变化的时间戳。字面比较认为它们“完全不同”于是像机关枪一样向值班通道连续扫射。为了在毫秒级内将这几百条同源噪音合并为一条干净的“聚合事件单”我们必须在监控通知流管道中引入基于滑动时间窗口Sliding Time Window与 Jaccard 集合相似度的动态告警合并算法。为什么选择 Jaccard 相似度轻量、无状态与抗噪声在海量告警流每秒数千条的实时计算场景下调用庞大的深度学习文本嵌入模型如 BERT往往太重、开销太高甚至会因为模型推理延迟导致关键告警堆积在管道内存中。相比之下集合论中的Jaccard 相似度系数Jaccard Index具有无与伦比的工程优势纯纯的集合交并集运算计算耗时微秒级对文本中插入的动态随机变量如单个变化的 IP 或 ID极度钝化内存占用极小单节点可轻松常驻数十万个滑动时间桶。数学定义极为简洁对于两个经过分词提炼后的告警特征词元集合 $A$ 与 $B$它们的 Jaccard 相似度定义为两者的交集元素个数与并集元素个数的比值$$J(A, B) \frac{|A \cap B|}{|A \cup B|}$$当两个告警除了 IP 地址不同、其余关键错误模式如Redis、command、timeout、order-svc完全一致时两者的特征词元重合度极高$J(A, B)$ 通常在0.80 到 0.95 之间远远超过预设的合并阈值而两个真正异构的故障如一个是 Redis 超时另一个是内存 OOM由于关键错误特征词毫无交集$J(A, B)$ 通常会迅速跌落到0.15 以下被系统清晰地判定为两起独立故障。滑动窗口与动态聚合桶的算法流水线整个合并引擎运行在告警通知网关的前置队列中包含三个核心阶段词元化与特征降噪Tokenization Normalization收到原始告警后首先用正则将具体的 IP 地址\d\.\d\.\d\.\d、纯数字序列和 32 位十六进制哈希替换为统一通配符[VAR]然后切分为独立的词元Tokens构成该告警的特征指纹集合。滑动时间窗口探测Sliding Window Lookup维护一个生命周期为 300 秒5 分钟的活跃聚合桶列表。淘汰掉所有超过 5 分钟未更新的过期桶。阈值判定与首警策略First-Alert Policy新告警进入后遍历当前所有活跃桶。如果与某桶的特征中心相似度 $\ge 0.75$则直接打入该桶桶内计数器自增同时就地静默该次手机短信推送如果未匹配到任何桶则新建一个聚合桶并立即向工程师手机发出首条预警短信。在 5 分钟窗口结束时系统向内部群推送一条聚合总结卡片“在过去 5 分钟内该同源故障累计触发了 180 次已自动为您完成合并”。生产级告警合并算法的 Python 实现import re import time from typing import List, Set, Dict, Any, Optional class AlertEvent: def __init__(self, alert_id: str, service: str, raw_message: str, timestamp: float): self.alert_id alert_id self.service service self.raw_message raw_message self.timestamp timestamp self.tokens: Set[str] self._tokenize(raw_message) def _tokenize(self, text: str) - Set[str]: # 1. 抹除易变的动态变量IP、数字ID、UUID text re.sub(r\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b, [IP], text) text re.sub(r\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b, [UUID], text) text re.sub(r\b\d{4,}\b, [NUM], text) # 2. 提取有效词元转小写并拆分 words re.findall(r[a-zA-Z_\[\]], text.lower()) return set(words) class AlertClusterBucket: def __init__(self, bucket_id: str, initial_alert: AlertEvent): self.bucket_id bucket_id self.service initial_alert.service self.created_at initial_alert.timestamp self.last_seen_at initial_alert.timestamp self.representative_message initial_alert.raw_message self.feature_tokens set(initial_alert.tokens) self.alert_count 1 def update(self, alert: AlertEvent): self.last_seen_at alert.timestamp self.alert_count 1 # 增量更新特征集合平滑特征中心 self.feature_tokens.update(alert.tokens) class JaccardAlertAggregator: def __init__(self, window_seconds: int 300, similarity_threshold: float 0.75): self.window_seconds window_seconds self.similarity_threshold similarity_threshold self.active_buckets: List[AlertClusterBucket] [] def _calculate_jaccard(self, set_a: Set[str], set_b: Set[str]) - float: if not set_a or not set_b: return 0.0 intersection len(set_a.intersection(set_b)) union len(set_a.union(set_b)) return intersection / float(union) if union ! 0 else 0.0 def process_incoming_alert(self, alert: AlertEvent) - Dict[str, Any]: now alert.timestamp # 1. 淘汰已超出滑动窗口生命周期的聚合桶 self.active_buckets [ b for b in self.active_buckets if now - b.last_seen_at self.window_seconds ] # 2. 遍历现有活跃桶计算 Jaccard 相似度 best_match_bucket: Optional[AlertClusterBucket] None highest_similarity 0.0 for bucket in self.active_buckets: # 基础硬性隔离不同服务之间不合并 if bucket.service ! alert.service: continue sim self._calculate_jaccard(alert.tokens, bucket.feature_tokens) if sim highest_similarity: highest_similarity sim best_match_bucket bucket # 3. 判定是否合并 if best_match_bucket and highest_similarity self.similarity_threshold: best_match_bucket.update(alert) return { action: SUPPRESS_AND_COUNT, bucket_id: best_match_bucket.bucket_id, current_count: best_match_bucket.alert_count, should_notify_sms: False, # 静默短信 reason: f相似度 {highest_similarity:.2f} {self.similarity_threshold}并入现有聚合桶 } # 4. 未匹配到相似桶新建聚合组 new_bucket_id fBUCKET-{alert.service}-{int(now)} new_bucket AlertClusterBucket(new_bucket_id, alert) self.active_buckets.append(new_bucket) return { action: CREATE_AND_DISPATCH, bucket_id: new_bucket_id, current_count: 1, should_notify_sms: True, # 首条告警立即触达 reason: 新类型异常首警立即外发并开启 5 分钟聚合窗口 }生产落地的三条避坑准则同服务是相似度计算的前提边界绝对不能跨服务无脑计算 Jaccard。如果订单服务报了超时用户服务也报了超时虽然两者的错误字符串相似度高达 90%但它们属于完全不同的两个微服务。跨服务强行合并会导致用户中心的值班人误以为订单中心的人已经接单造成严重的跨域漏报。动态滑动窗口要以last_seen_at刷新滑动窗口的判定依据应当是“距离最后一条告警到达的时间”而不是“距离第一条告警到达的时间”。如果故障持续了 15 分钟只要每隔几秒钟有相似告警进入该桶就应该持续保持活跃而不是每隔 5 分钟就重新炸一次手机。窗口结束自动补发汇总卡片被静默的告警必须有最终交代。当聚合桶在连续 5 分钟内没有再收到任何新告警时调度器自动触发“关闭桶”事件在内部群推送完整汇总卡片附带首尾时间戳与波及实例总数为后续复盘提供完整的时间跨度依据。通过在数据传输管线上架设起这套基于 Jaccard 相似度的滑动窗口算法我们在大促期间成功化解了超过 90% 的重复告警短信让一线工程师的手机真正恢复了应有的宁静与威慑力。