外观
打分算法
三部分:所有粒度共用的衰减打分核、判定「什么算一次抖动」的计分规则、以及把加分与消警的边沿固化成持久告警与时序存档。
衰减打分的数学
所有粒度共用同一个衰减核。设某主体在时刻
代码里就是这一行(各粒度各有一份,参数不同):
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、把锚点前移到当下:
稳态分与阈值定标
若某主体以固定间隔 +1 冲激、其后按半衰期衰减,稳态是等比级数求和:
这个闭式是阈值定标的依据——想让「每 15 s 抖一次」刚好压过报警线,就反解出半衰期与阈值。判定「抖动中」就是拿当前分和阈值比:
两组参数:
- 会话级:半衰期 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:
- 离开 Established——
state实变且原 state 是 Established:会话断了。 - 隐性重连——前后两份快照的 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 的口径):
- withdraw——某条路径被撤回(
_paths里有记录,pop 出来,按它记下的归因对端计分)。 - 路径实变——同一
(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.py 与 FlapAlert 模型。一个背景循环(60 s 一轮)对会话级与前缀级各算一遍当前衰减分(复用同一套半衰期常量,避免口径分叉),做边沿判定:
- 上穿告警线且当前无在开告警 → 开一条,记
started_at、peak_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_changes与rate_per_s落在条目行上;「抖了多久」不另存——fleet_view就地 join 同键在开告警的started_at(上穿时刻即抖动起点),得flapping_since与flap_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-overview 的 trend)。
一个诚实的度量限制:按前缀求和的
Δchanges非单调(路径 prune 与恢复会让和值下降),只有 per-(node, prefix, peer)粒度的计数单调。聚合量只作方向信号,不作里程表。