Skip to content

打分算法

三部分:所有粒度共用的衰减打分核、判定「什么算一次抖动」的计分规则、以及把加分与消警的边沿固化成持久告警时序存档


衰减打分的数学

所有粒度共用同一个衰减核。设某主体在时刻 t0 的分数为 s0,半衰期为 T,则任意 tt0 的当前分为:

s(t)=s02(tt0)/T

代码里就是这一行(各粒度各有一份,参数不同):

python
def _decayed(score, anchor_iso, now_epoch):
    anchor = parse_iso_utc(anchor_iso)
    if anchor is None or now_epoch <= anchor.timestamp():
        return score            # 锚点缺失或超前:不衰减
    elapsed = now_epoch - anchor.timestamp()
    return score * 0.5 ** (elapsed / _HALF_LIFE_S)

每来一次 flap 事件,先把旧分衰减到事件时刻,再 +1、把锚点前移到当下:

snew=sold2Δ/T+1

稳态分与阈值定标

若某主体以固定间隔 τ 持续抖动(速率 r=1/τ),分数不会无限涨,而是收敛到稳态值。把每次事件当作一次 +1 冲激、其后按半衰期衰减,稳态是等比级数求和:

s=112τ/TrTln2=Tτln2

这个闭式是阈值定标的依据——想让「每 15 s 抖一次」刚好压过报警线,就反解出半衰期与阈值。判定「抖动中」就是拿当前分和阈值比:s(t)THRESHOLD

两组参数:

  • 会话级:半衰期 1800 s(30 min),阈值 3.0。需连续 4 份抖动快照(约 20 min)过线;3 次断连在任意间隔下都不过线。
  • 前缀级:半衰期 600 s(10 min),阈值 20.0。前缀抖动节奏远快(单前缀可秒级抖),故半衰期取小。阈值 20 约等于「每 44 s 抖一次并持续」的稳态;「每 20 s 一次」的稳态约 43,约 9 min 过线,阈值放其一半留余量。

前缀级半衰期在 agent 计分器与控制面两侧各有一份,必须同参,否则打分口径分叉(阈值只在控制面读侧判定,agent 不感知)。

为什么锚定加续衰,而不是定时降温? 分数只在两个时刻被触碰:事件到来时(加分并重锚)、读取时(从锚点续衰到当前)。中间不需要后台任务周期性下调。「不再抖的会话自动滑出榜单」是读取时续衰的自然结果,零维护成本。


什么算一次「变化」

计分只认真实抖动,存疑一律不计——这是「宁可漏检」原则的落地。

会话级

两类事件各计一次 +1

  1. 离开 Established——state 实变且 state 是 Established:会话断了。
  2. 隐性重连——前后两份快照的 state 是 Established,但 since 变了:约 5 min 间隔内会话至少断建了一整轮,快照节奏抓不到中间态,since(本次建立时刻)是唯一痕迹。

不计分的情况

  • 非 Established 状态间换挡(Active → Connect → OpenSent…)是重试的机械噪声——计入 transitions_total 但既不计分也不写历史行。一条长期 down 的会话每份快照换一挡、一天约 288 条,不到一天就把限额内的真实 flap 历史挤光。
  • 恢复(→ Established)本身不计分,否则一次断连会被计两次。规则:只有 state 是 Established(或判定为隐性重连)才计分。
  • 隐性重连只在两侧 since 都是带时分秒的完整时间戳时才判定。分辨率不足、或「日期 ↔ 完整时间戳」形态切换造成的字符串假变化,宁可漏检。
  • 某会话首次出现时只立比对基准,不计转移——没有「之前」可比。

转移历史 bgp_flap_transitions 是 append-only,kind ∈ {state, restart}只落涉及 Established 的转移,每节点裁剪到最近若干条。captured_at 记的是观测到转移的快照时刻,不是转移实际发生时刻——分辨率就是快照节奏。

前缀级

对某 (prefix, 归因 peer) 执行 +1(这就是榜单 changes / total_changes 的口径):

  1. withdraw——某条路径被撤回(_paths 里有记录,pop 出来,按它记下的归因对端计分)。
  2. 路径实变——同一 (family, prefix, path_id) 重新宣告,但 路径指纹 = hash((AS_PATH, next-hop)) 变了。

不计分的情况

  • 同路径的重复宣告(指纹没变)——刷新与属性噪声。
  • 首见_paths 里没有该 key):采集会话建连时的全表首灌天然没有 withdraw、也没有「之前的指纹」可比,首灌一条都不计。分数从零开始,只随真实抖动增长。

低于剪枝分 0.5 的条目直接从计分表剪掉(防内存无界),落盘取 Top 200。

changes 是里程表,score 是时速表。 changes不衰减的累计计数,反映该路由历史上抖过多少次;score指数衰减的当下强度,不抖了会自己滑落。一条前缀可能 changes 上万但此刻 score 已很低。


归因:第一跳是对端,最后一跳是起源

AddPath 让计分器看到 BIRD 的每一条路径。每条按 AS_PATH 剥掉本节点自身 AS 的前缀后:

  • 第一跳归因到直接对端(「抖动从哪家入向进来」);
  • 最后一跳归因为起源(「前缀在哪里起源」);
  • 自起源与空路径归 0(未归因)。

withdraw 消息不带 AS_PATH,所以宣告时就把 path_id → (归因, 起源, AS_PATH) 记进 _paths,撤回时回查。

计分器为此维护四张表(后两张服务根因定位,只对上榜抖动前缀攒、随分数剪枝):

_paths:         (family, prefix, path_id) → (归因 peer_asn, 起源 origin_asn, 路径指纹, AS_PATH)
_scores:        (prefix, peer_asn)        → [score, anchor_ts, changes, last_change_ts, origin_asn]
_path_churn:    (prefix, AS_PATH)         → [churn, anchor]              # 每条 distinct 路径的抖动权重
_prefix_events: prefix                    → [changes, withdrawn, anchor] # 前缀级总变化与彻底消失计数

每次「变化」除了给 (prefix, peer) 打分,还给卷入的路径记 churn(新旧路径各 +1、同半衰期衰减)、给前缀记事件(总变化 +1;若这次导致该前缀在本节点再无任何在宣告路径withdrawn +1)。

dump 时对每条上榜前缀导出 ≤12 条 distinct AS_PATH——优先在宣告的幸存路径,再按 churn 补。定位靠稳定幸存路径给起源免责,不能被高 churn 挤掉。这些证据加上前缀级 changes 与 withdrawn 随上报送控制面,是根因定位的输入。字段可选,旧 agent 不发时控制面忽略。


跨节点归组

同一条路由抖动通常被多个节点同时看见(iBGP 传播)。读取时按 (prefix, peer_asn) 跨节点归组,是 fleet 级关联的第一步:

  • fleet_view(min_score, limit)——前缀 flap 榜。每组取跨节点 max_score 排序,nodes 列出哪些节点看到、各自分数;另附 feeds(各节点喂送健康度与上报新鲜度,超 300 s 标 stale)。
  • peer_aggregate(min_score)——「哪家对端最抖」榜的数据层。先按 (前缀, 对端) 归组取跨节点 max(与 fleet 榜同口径),再按对端 AS 收拢,返回每家的 prefix_count / prefixes_flapping / score_max / nodes / last_change_at。未归因(peer_asn = 0)不参与。

把边沿固化成持久告警

衰减分是「当下」视图:抖动停了、分数消退,历史就不可见了。告警事件流把「进入 / 退出抖动」固化成持久边沿事件,即便现在不抖,也能回看「昨晚 3 点这条会话抖过 20 分钟」。

代码app/services/flap_alerts.pyFlapAlert 模型。一个背景循环(60 s 一轮)对会话级与前缀级各算一遍当前衰减分(复用同一套半衰期常量,避免口径分叉),做边沿判定:

  • 上穿告警线且当前无在开告警 → 一条,记 started_atpeak_score、波及节点。
  • 下穿消警线(告警线的一半,迟滞设计)→ 该条,记 resolved_at。消警线取一半,让告警线附近来回波动的分数不开关乒乓。
  • 在开期间持续刷新 last_score / peak_score 与波及节点集合(前缀级取持续期间看到该抖动的节点并集)。

告警线与榜单线分层

会话级两线同参(3.0,会话断建少而重)。前缀级榜单的 flapping 线 20 只作可观测标记,开单线单独抬到 100

依据是生产实测:DN42 的低烈度背景抖动是常态,按榜单线开单会积出数百条常开告警(峰值中位数才 50 出头、最老的三天不消),告警彻底失去事件含义;分数 ≥100 的只有几十条,才是人能逐条处置的量级。

即便如此,分层后的 100 仍比同类工具的默认告警参数灵敏得多。

告警身份

kind=session 定位 (node_id, 会话名)kind=prefix 定位 (前缀, 归因对端 AS),按跨节点归组后的 max 分数判定,与 fleet 榜同口径。

node_id 不设外键——告警是历史事实,不随节点删除而级联蒸发。已消警历史按条数截断保留最近 500 条。

evaluate_once 返回 {opened, resolved, active, tracked_prefixes, flapping_prefixes, flapping_sessions} 供日志与测试;单轮评估失败只记日志,不拖垮控制面。

与主面共库的只读副本必须关掉这个循环([flap] alert_loop_enabled = false):同一份分数两个评估者做边沿判定会竞态开出重复告警。


速率与时序

衰减分回答「现在抖多凶」,告警回答「什么时候抖过」,但都画不出「变化速率随时间」的曲线——打分表是整表替换的当下快照,历史桶必须单独存。这一层完全构建在累计计数差分上,零 agent 变更。

  • 速率存档prefix_flap_rate_rollup,每 (节点, 60 s 桶) 一行,保留 60 天)。收报时对上一份报告差分两条线:喂送级 total_changes(该节点看到的全部计分事件,含未上榜前缀)与榜内同键条目的 changes 之和(只算两份报告都在榜的键——新上榜键无基准不计)。
  • 榜行速率与时长。同一次差分把 recent_changesrate_per_s 落在条目行上;「抖了多久」不另存——fleet_view 就地 join 同键在开告警started_at(上穿时刻即抖动起点),得 flapping_sinceflap_duration_s。告警线(100)高于榜单线(20),故中间带的行 flapping=true 但 duration 为 null——duration 只对告警级强度有值。
  • 活跃度时间线flap_activity_rollup,fleet 级每 60 s 桶一行,保留 60 天)。告警评估循环每轮把两层分数全算一遍,同时数出在跟踪与过阈的前缀数、过阈会话数、在开告警数落一个点。
  • 瞬时速率仪表。读时对窗口(默认 900 s)内每个 60 s 桶求全节点变化数之和(缺桶按 0,固定分母),升序去掉最高 10% 后取平均、折算次每秒——90 分位截尾均值,对突发尖刺鲁棒。

差分的保真规则

与计分一脉相承,仍是宁可漏检:

  • 计数回卷(agent 重启或条目剪枝后重建)取新值当下界——回卷必发生在报告间隔内,新值即区间下界。
  • 报告间隔超限(>600 s,掉线恢复)整份不计速率、只重建基准,否则积压变化会灌成一个假尖刺。
  • 空桶留 null,「无上报」与「确实没变化」在读侧可区分。

分辨率是 60 s,受 agent 上报节奏限制。「路由表规模随时间」不在这一层——路由采集侧的 node_routing_events 已有该时序(/ui/routing/fleet-overviewtrend)。

一个诚实的度量限制:按前缀求和的 Δchanges 非单调(路径 prune 与恢复会让和值下降),只有 per-(node, prefix, peer) 粒度的计数单调。聚合量只作方向信号,不作里程表。