外观
Flap 检测
会话级(P0)与前缀级(P1)两级检测、告警事件(P2)与速率存档的数据层。模型在 app/db/models/flap.py 与 prefix_flap.py,检测机制全貌见 flap 检测。
该子系统横跨四种存储角色,本页覆盖其全部落点:
| 落点 | 层 | 存储内容 | 丢失后果 |
|---|---|---|---|
Valkey pflap:<node> / bgpflap:<node> | L4 | 两级打分热态 | 从下一份上报重新起算 |
flap_alerts · bgp_flap_transitions | 事实 | 告警的开启与消退、状态转移 | 不可重建 |
node_prefix_flap_meta | L2 | 喂送健康度 | 下一份上报即重建 |
prefix_flap_rate_rollup · flap_activity_rollup | L3 | 60 天速率与活跃度曲线 | 历史断档 |
bgp_flap_state · node_prefix_flaps · node_prefix_flap_paths | 降级路径 | KV 不可用时接管热态 | — |
贯穿全册的分界:当下视图与事实
Flap 数据分两类,存储方式截然不同,这是本册最重要的结构:
| 当下视图 | 事实 | |
|---|---|---|
| 定义 | 指数衰减[1]打分,每份上报整份重算、读时按半衰期续衰 | 转移事件、告警的开启与消退 |
| 丢失后果 | 从下一份上报重新起算(半衰期内影响自然消散) | 历史中断,且不可重建 |
| 存储位置 | L4 Valkey 单键 blob | PostgreSQL |
打分不落 SQL 的原因:前缀级热态每 60 s 整份替换,落 SQL 每月将产生上亿行写入;会话级量级虽小得多,迁入 KV 是为消除「同一概念两种存法」的不对称,该不对称最易导致后续改动出错。
热态置于 L4 而非 L5 的原因:该 blob 是唯一副本。它是对上一份已经过去的上报所做的差分基准,无法计算也无法重建(下一轮上报只能重新起算,中间一格永久缺失)。置于会 LRU 淘汰且不落盘的 Redis 上,容器重建即全部丢失。键定义与 TTL 见 KV 与缓存键索引。
注意:写侧(PrefixFlapStore / FlapStore)与告警评估器(FlapAlertStore)必须注入同一个 KV 实例。 三者经 load_current_flap_state / load_session_flap_state 读取同一批键;若一方改变数据源而另一方未同步,评估器将读到空热态,把全部在开告警判定为已消退并批量写入 resolved_at。此项由 test_storage_discipline.py 静态约束。
flap_alerts
flap 告警事件的持久层(P2)。衰减分属于当下视图,消退后历史即不可见;本表由控制面背景评估循环(60 s)以边沿触发方式维护:分数上穿阈值时开启一条,下穿消警线(阈值的一半,以迟滞避免反复开关)时关闭该条。
kind=session 定位 (node_id, name=会话名);kind=prefix 定位 (name=前缀, peer_asn=归因对端),nodes 记录波及节点集合。
node_id 有意不设外键:告警属于历史事实,不应随节点删除而级联消失。
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键 |
kind | String(16) | NOT NULL | session / prefix |
node_id | String(64) | 可空,index(无 FK) | 会话告警的所属节点;前缀告警为 NULL |
name | String(128) | NOT NULL | 会话名或前缀 |
peer_asn | BigInteger | 可空 | 前缀告警的归因对端 AS |
nodes | JSON | 可空 | 波及节点集合(session 为单元素;prefix 为看到该抖动的节点,持续期间取并集) |
peak_score / last_score | Float | NOT NULL | 持续期间峰值分 / 最近评估分 |
started_at | DateTime(tz) | NOT NULL,server_default now | 开单时刻 |
resolved_at | DateTime(tz) | 可空,index | 消警时刻;NULL 表示在开 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻 |
已消警的历史按条数截断保留 500 条(_RESOLVED_KEEP)。
注意:这是全库 UPDATE 最频繁的表(约 110 行/分钟),其中绝大部分用于每轮评估刷新 last_score——一个可重算的当下值,与审计字段混存于同一行。改造方向与触发条件见写热点。
bgp_flap_transitions
append-only 的会话状态转移历史,供界面下钻查询单条会话的抖动时刻。按节点保留最近 200 条(_TRANSITIONS_KEEP)。
仅记录涉及 Established 的转移(断连、隐性重连、恢复);非 Established 状态之间的切换只计数不落行——长期 down 的会话每份快照切换一次状态,一天约 288 条,不足一天即会将保留窗口内的真实 flap 历史挤占殆尽。
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
id | Integer | PK,autoincrement | 主键 |
node_id | String(64) | FK→nodes.node_id(CASCADE),NOT NULL,index | 所属节点 |
name | String(128) | NOT NULL,index | BIRD protocol 名 |
session | String(128) | 可空 | schema 会话名 |
kind | String(16) | NOT NULL | state(state 实变,如 Established→Active)/ restart(隐性重连) |
from_state / to_state | String(32) | NOT NULL | 转移前后状态 |
captured_at | String(64) | NOT NULL | 观测到转移的快照时刻。不是转移实际发生时刻——分辨率即快照节奏 |
created_at | DateTime(tz) | server_default now | 写入时刻 |
node_prefix_flap_meta
每节点最近一次前缀级上报的元信息(喂送健康度),用于区分「无抖动」与「喂送中断导致不可见」。写入频率低(每份上报一次,约 60 s),始终走 SQL。
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK,FK→nodes.node_id(CASCADE) | 主键即外键 |
captured_at | String(64) | NOT NULL | 最近上报生成时刻 |
feed_state | String(32) | NOT NULL,默认 unknown | flapfeed BGP 会话状态(established / down / unknown) |
total_changes | BigInteger | NOT NULL,默认 0 | 采集会话启动以来的累计路径变化总数 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻 |
total_changes 的差分正是下面速率存档的数据来源。
时序存档
两张表互补:速率回答「变化速度」,活跃度回答「多少主体处于抖动状态」。通用桶形态与保留期见时序建模通则。
prefix_flap_rate_rollup
每 (节点, 60 s 桶) 的路径变化速率存档,收报时差分累加。fleet 级「路由变化速率」的时序与仪表都从这张表读时聚合。
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点 |
bucket_start | BigInteger | PK(复合) | 桶起点(epoch 秒,对齐 60 s) |
changes | BigInteger | NOT NULL,默认 0 | 喂送级差分——该节点看到的全部计分事件,含未上榜前缀(来自 total_changes 的差分) |
listed_changes | BigInteger | NOT NULL,默认 0 | 榜内 (prefix, peer) 条目 changes 的差分之和。只算两份报告都在榜的键,新上榜键无基准不计 |
samples | Integer | NOT NULL,默认 0 | 落桶报告数(>0 即「该桶有上报」,读侧据此区分 0 与无数据) |
额外索引 ix_prefix_flap_rate_rollup_bucket (bucket_start)——跨节点按时间聚合时不带 node_id,走不了主键最左前缀。实测这个索引比主键还常被用(32 万次 vs 16 万次)。
flap_activity_rollup
fleet 级 flap 活跃度时间线(每 60 s 一行,无节点维度),随告警评估循环落点。ORM 类名 FlapActivitySample。
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
bucket_start | BigInteger | PK | 桶起点(epoch 秒,对齐 60 s) |
tracked_prefixes | Integer | NOT NULL,默认 0 | 在跟踪的前缀数(有任何存活分数) |
flapping_prefixes | Integer | NOT NULL,默认 0 | 过阈前缀数 |
flapping_sessions | Integer | NOT NULL,默认 0 | 过阈会话数 |
open_alerts | Integer | NOT NULL,默认 0 | 在开告警数 |
降级路径的三张表
以下三张表在 KV 可用时不再被写入,仅在 Valkey 未配置或发生故障时接管。回退期间行为与改造前完全一致,区别仅在于恢复了 SQL 整表替换的写放大(测试与降级共用同一份代码,语义逐条相同)。
注意:读侧会合并两路。 load_session_flap_state / load_current_flap_state 先扫描 KV,某节点 blob 缺失时回落该节点的 SQL 行。这一设计使热路径切换过程中评估器始终能读到数据,不会因「空热态」而误判批量消退;但同时也意味着,热路径运行一段时间后,此处保留的是上一次降级时写入的陈旧行,经衰减后接近零分,表现为「该节点无抖动」而非报错。
实测佐证:bgp_flap_state 的 n_tup_upd 在 21 分钟观测窗口内稳定于 428 185 未增长,而 seq_scan 仍在增长——写侧已完全走 KV,读侧每轮仍查询一次以完成合并。
bgp_flap_state
每 (节点, BIRD 协议名) 一行的会话级 flap 打分状态。数据源是 runtime snapshot 里的 bgp_protocols(约 5 min 节奏):快照入库时与上一份逐会话比对,发现状态转移、或 state 同为 Established 但 since 变化的「隐性重连」,即记转移并给该会话的指数衰减分加分。该检测不需要 agent 侧任何改动,完全由控制面推导。会话从快照中消失(配置删除)时,对应行同步删除。
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点 |
name | String(128) | PK(复合) | BIRD protocol 名(快照内唯一) |
session | String(128) | 可空 | 反查到的 schema 会话名(eBGP 有;iBGP 合成会话为 NULL) |
last_state | String(32) | NOT NULL | 上次观测的 state(比对基准) |
last_since | String(64) | 可空 | 上次观测的 since(since 变而 state 不变即隐性重连) |
captured_at | String(64) | NOT NULL | 分数锚点 = 上次纳入比对的快照时刻;读取时从锚点按半衰期续衰到当前 |
score | Float | NOT NULL,默认 0.0 | 指数衰减 flap 分 |
transitions_total | Integer | NOT NULL,默认 0 | 累计状态转移数(不衰减,供展示) |
flaps_total | Integer | NOT NULL,默认 0 | 其中计分的 flap 事件数 |
last_transition_at | String(64) | 可空 | 最近一次转移时刻 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻 |
KV blob(bgpflap:<node>)中每个会话的条目与上述列逐字段同构,区别仅在于 node_id 提升为键名、name 提升为 map 的键。
node_prefix_flaps
一条 (节点, 前缀, 对端 AS) 的当前前缀级 flap 打分,随上报按节点整表替换(条目 ≤500,delete + bulk insert)。数据源是 agent 旁路 flapfeed 管线的周期上报(约 60 s),agent 侧已按 (prefix, 归因对端 AS) 算好指数衰减分并截断 Top-N。
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点 |
prefix | String(64) | PK(复合) | 前缀 |
peer_asn | BigInteger | PK(复合),默认 0 | 归因对端 AS。0 表示未归因或本节点自起源(主键列不可空,读出时归一回 None) |
origin_asn | BigInteger | 可空 | 前缀起源 AS(AS_PATH 最后一跳);本网自起源或无从归因时为 NULL |
score | Float | NOT NULL | agent 侧算好的指数衰减分(锚定 captured_at,读取时可续衰) |
changes | BigInteger | NOT NULL,默认 0 | 该 (prefix, peer) 自 flapfeed 启动以来的累计变化数(不衰减) |
recent_changes | BigInteger | 可空 | 上一报告间隔内的变化数。上一报告无该键即无基准 → NULL,宁可漏检不虚报 |
rate_per_s | Float | 可空 | 折算的每秒速率(榜单 Rate 列口径) |
last_change_at | String(64) | 可空 | 最近一次变化时刻 |
captured_at | String(64) | NOT NULL | 所属上报批次的生成时刻 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻 |
node_prefix_flap_paths
一条 (节点, 前缀) 的根因定位路径证据,随上报按节点整表替换。只对上榜抖动前缀存(agent 侧已过滤,量小;每前缀 ≤12 条 distinct AS_PATH,整批 ≤500 条)。控制面根因定位时跨节点读出、按 AS_PATH 求并,喂给 services/flap_localize.localize。
| 列 | 类型 | 约束 / 默认 | 说明 |
|---|---|---|---|
node_id | String(64) | PK(复合),FK→nodes.node_id(CASCADE) | 所属节点 |
prefix | String(64) | PK(复合) | 前缀 |
paths | JSON | NOT NULL,默认 [] | [{"as_path": [int…], "churn": float, "present": bool}]——外部 AS_PATH 证据(已剥本网前缀) |
changes | Float | NOT NULL,默认 0.0 | 前缀级衰减计数:总变化事件(源头信号的分母) |
withdrawn | Float | NOT NULL,默认 0.0 | 彻底消失次数(分子) |
captured_at | String(64) | NOT NULL | 上报批次生成时刻 |
updated_at | DateTime(tz) | server_default now,带 onupdate | 更新时刻 |
迁移至 KV 的代价(留档)
会话级热态迁入 KV 时,存量分数未做搬迁,从 0 重新累积。实测影响可忽略:切换时 120 个会话的残余分数最高仅 0.11(榜单阈值 0.05,告警阈值 3.0),且当时无任何 session 类告警处于开启状态,半衰期 30 分钟意味着这些分数在一小时内本也会衰减殆尽。
该结果与前缀级迁移时出现「跳过搬迁导致 34 条假消退」的差异,根源在于读侧合并两路:切换过程中评估器始终能读到数据。后续同类迁移应将这条合并逻辑视为必备前提,而非可选优化。
指数衰减打分:每次事件为该主体加 1 分,分数随时间按半衰期连续衰减。相比固定窗口计数,它兼具记忆性(短时间内多次事件可累加)与自然消退(停止抖动后分数自行降至阈值以下),且只需一个可调参数。 ↩︎