Skip to content

性能

控制面的实测性能:热点在哪、为什么、怎么量。全部数字来自生产实测(2026-08-13),不是估算。

原始基准数据与复测方法在 scripts/bench/BASELINES.md,压测脚本是同目录的 readonly_endpoints.py(标准库实现,无外部依赖)。

分工:本页管端点与进程(QPS、p50、worker、并发模型);存储侧的规模、写热点、索引与容量阈值归数据层性能与容量,存储角色划分见数据层参考


结论先行

状态依据
应用 CPU曾是唯一瓶颈,已解单进程封顶 101.20% → 3 worker 后 218.13%
读路径 DB基本不碰库dashboard / fleet-overview / prefix-flaps 每请求查 nodes 0 次
PostgreSQL远未饱和压测时 CPU 32.81%,缓冲命中率 98%,控制库约 400 MB
KV / 缓存余量充足命中率 99.8%,Valkey 水位 1.1%,evicted_keys=0
写路径不构成瓶颈全库 UPDATE 约 170 行/分钟(实测

一句话:这套系统的瓶颈曾经是"一个 Python 进程只能用一个核",不是数据库。 把该缓存的缓存掉、把进程内状态搬进 KV 之后,四核机器上开三个 worker,读路径的吞吐提升了 3–16 倍(视端点而定)。


一、基准测试

怎么跑

bash
ssh -i ~/.ssh/<> <>@<控制面主>          # 目标见运维台账,不在仓库内
export ADMIN_TOKEN=$(sudo grep -oP 'admin_token\s*=\s*"\K[^"]+' \
  /opt/dn42-control/deploy/docker/control-server.toml)
python3 /opt/dn42-control/scripts/bench/readonly_endpoints.py 4 10 <>

直连 127.0.0.1:8000——绕开 nginx 与 Cloudflare,量的是应用本身。每端点之间留 2 秒空隙,让 agent 的周期上报挤进去。

注意:跨规格只比较 p50 的相对关系与「哪个端点最慢」的排序,不比较 QPS 绝对值。 冷缓存与热缓存的数据同样不可对比——主机重启后的一次首测即因此作废重跑,当时 ui/nodes 反而「变慢」了 35%。

四条基线的演进

同参数(并发 4 / 每端点 10 秒)下 QPS 的变化:

端点1 核1 核 + 缓存修复4 核4 核 + 3 worker累计
/healthz181.2179.7371.3534.73.0×
/ui/dashboard60.9113.9168.8302.75.0×
/ui/nodes45.341.871.1104.82.3×
/ui/fleet/prefix-flaps11.011.215.433.43.0×
/ui/routing/fleet-overview1.9115.3204.6361.3190×

三段收益的来源各不相同,值得分开看:

  1. 缓存修复(第 1→2 列):只影响 fleet-overview,但那一项就是 60 倍。与 CPU 无关
  2. 换 4 核(第 2→3 列):普涨 1.4–2.1×。收益不是"控制面变快了"——它仍然只能用一个核——而是它不再和 PG / Valkey / nginx / agent 上报抢同一个核
  3. 开 3 worker(第 3→4 列):再涨 1.4–2.2×,这次才是真正的并行。

二、应用层热点

曾经的头号问题:单进程只能用一个核

改造前实测:压测打满时容器 CPU 101.20% 就封顶——uvicorn 不带 --workers 就是一个 Python 进程,GIL 决定它只能吃满一个核。四核机器上另外三个核完全闲置,同期 PostgreSQL 只用 20.93%。

开 3 worker 后同样的压测打到 218.13%。没有打满 400% 是因为压测客户端自己也在这台机器上抢核,而且部分时间在等 PG / KV 的 I/O。

为什么以前不能开 worker:四份状态都在进程内存里,多 worker 会各持一份互不可见。它们依次迁到 KV 之后才解锁——细节见数据层参考KV 键空间。其中最容易被忽略的是拨测:它天然横跨"POST 建作业 / agent WS 回传 / GET SSE 订阅"三个请求,单进程时必然同进程,多 worker 下不跨进程共享就断链。

当前最慢的端点:/ui/fleet/prefix-flaps

p50 93 ms(3 worker 下),是其余端点的 5–10 倍。根因不在 DB——它查 nodes 零次——而在:

  1. 从 KV 读 10 个热态 blob,合计约 1.4 MB JSON;
  2. 反序列化后对每个条目按半衰期做衰减计算(纯 CPU,单请求内串行);
  3. 跨节点按 (prefix, peer_asn) 归组。

增长维度是节点数 × 榜内条目数(每节点上限 500 条)。当前 10 节点可接受;节点数翻倍前应考虑把衰减计算下推、或直接缓存榜单结果。

已修:/ui/dashboard 的缓存 TTL 短于轮询间隔

该端点把 5 个聚合拼成一份响应,实测冷路径 0.5–1.8 s、热路径 8 ms,因此 TTL 直接决定用户感知的首屏时延。它原本设 3 秒,而前端是固定 35 秒轮询(webui 的单一全局 ticker)——每次轮询必然过期,等于每次刷新都付 1.5 s 冷成本,那份缓存只对 3 秒内的重复请求有效。

更关键的是,3 秒 TTL 也买不到新鲜度:它缓存的复合结果里,各组件本身就已经是 10 秒(health)到 30 秒(routing)陈旧的。

处置是把 TTL 提到 45 秒(大于轮询间隔才可能命中)。命中项龄期 ≤45 s,叠加组件自身陈旧度后最坏约 75 s,仍短于 flap 上报的 60 秒节奏与 30 秒流量分辨率。

单纯调大 TTL 有明确的天花板:单个浏览器按固定间隔轮询时,命中不会续期、条目仍在原时刻过期,因此无论 TTL 取多大都只能消掉约一半冷调用(t=0 miss / t=35 hit / t=70 miss…),再大就要以成倍的陈旧度换命中率。

因此最终改为 stale-while-revalidate:命中即返回(不论新旧),龄期超过软过期时顺带派一个后台刷新。三个参数各管一件事:

参数职责
软过期20 s超过即触发后台重算,但仍立刻返回旧值。小于轮询间隔,保证每次轮询都顺带刷新一轮
硬过期600 sRedis 键寿命,也是「后台刷新持续失败」时陈旧度的兜底上限;无人访问 10 分钟即自然淘汰
单飞锁90 sSET NX:3 worker × 多标签页可能同时判定过期,同一键只允许一个重算

龄期由响应体里已有的 generated_at 判定,无需额外存储。刷新只在有人访问时发生,不做空转预热。

生产实测:软过期之后的请求耗时 10.6 ms、返回数据龄期 20.7 s——读者不再为重算等待,代价是数据最多旧一个轮询周期。

教训与 fleet-overview 那次同源:缓存参数要对着真实调用节奏设定。脱离调用方谈 TTL,很容易设出一个「看起来很新鲜、实际从不命中」的值。

已修:/ui/routing/fleet-overview 的 2 秒

压测发现它 p50 2084 ms,是其余端点的 30–100 倍,而这是概览页打开就调的接口。

根因不是本次改造引入:get_fleet_overview 内层的 get_fleet() 早有 30 秒缓存,但外层的 trend 聚合与 origins 榜单每次都要扫 node_route_entries(当时 177 480 行 / 139 MB)做 GROUP BY

处置是给整份结果加 30 秒缓存(键含全部影响结果的参数)。p50 2084 ms → 22.7 ms,快 92 倍/ui/dashboard 的 QPS 顺带从 60.9 涨到 113.9——它内部也走这条路径。

这件事的教训不是"该加缓存",而是没有压测就看不见它。这个端点在功能测试里一切正常,只有在量它的 p50 时才暴露。


三、数据库:压测视角

存储层的完整画像——表规模、写热点与速率、索引使用、死元组、扩展阈值——在数据层性能与容量那一页是数据层的唯一事实源,这里只记压测期间从应用视角看到的三件事。

读路径基本不碰库。 打两次同一端点、量第二次前后 pg_stat_user_tables 扫描次数差值:/ui/dashboard/ui/routing/fleet-overview/ui/fleet/prefix-flaps 每请求查 nodes 0 次/ui/nodes 1 次(一条 10 行的全表查询)。没有 N+1——缓存命中时读路径完全不落库。

PG 在压测中远未饱和。 应用打满两个核时,PostgreSQL 才用 32.81% 的 CPU,缓冲命中率 98%,连接 23 / 100。压测暴露的两次真问题(fleet-overview 的 2 秒、prefix-flaps 的 93 ms)根因都在应用侧的重复计算,没有一次是数据库慢

写路径同样不是瓶颈。 全库 UPDATE 实测约 170 行/分钟。判断某张表是否值得迁入 KV 的判据与触发条件见扩展阈值注意:pg_stat_user_tables 的计数是累计值而非速率,直接读取会得出与事实相反的结论。


四、KV 与缓存

指标实测读法
Redis 命中率99.8%(+11 670 hit / +22 miss)读路径几乎不回源
evicted_keys0从未触顶淘汰
Valkey 内存2.78 MB / 256 MB(1.1%)离 70% 告警线很远
最大单键pflap:<node> 115–164 KB10 节点合计约 1.4 MB

pflap:* 是全栈最大的单键,也是 prefix-flaps 端点慢的直接原因(读 + 反序列化 1.4 MB)。它同时是唯一副本,所以只能放 Valkey 不能放 Redis——这条取舍见数据层参考


五、并发模型

nginx (4 worker)
   └── control-server 容器
         ├── uvicorn 主进程
         ├── worker 1 ─┐
         ├── worker 2  ├─ 共享 :8000(SO_REUSEPORT,内核分发)
         └── worker 3 ─┘
               每 worker: 独立 asyncio 事件循环 + 独立 PG 连接池 (5+10)
                          + 独立 KV/Redis 连接 + 各自的 pub/sub 订阅循环

worker 数怎么定:4 核开 3 个,留 1 核给 PG / Valkey / nginx / agent 上报。再加要先扩核。

连接池怎么定:总连接 = worker 数 × (pool_size + max_overflow) = 3 × 15 = 45。PG max_connections=100(3 个保留给 superuser),还要给 auth-server 与 registry-server 留份额。改 worker 数必须同步复核这个乘法。

跨 worker 共享的东西(都在 KV 里,见 KV 键空间):事件总线 pub/sub、WS 订阅注册表、agent 心跳、登录限速、flap 热态、拨测中转、瞬态凭据。

注意:启用 --workers > 1 必须配置 DN42_CONTROL_KV_URL 未配置时上述状态全部回落进程内存,症状为拨测与日志流随机无输出、agent 在线数跳动、限速失效,且均不产生错误日志。


六、下一步

按性价比排序。前两项已经做完,列在这里是为了说明判断依据。

状态收益依据
fleet-overview 结果缓存✅ 已做p50 快 92×压测发现,改动 10 行
开 3 worker✅ 已做吞吐 1.4–2.2×CPU 从 101% 封顶到 218%
prefix-flaps 榜单缓存✅ 已做QPS 6.4×,p50 93 → 16.1 ms压测发现,20s TTL
/ui/nodes 结果缓存✅ 已做QPS 8.3×,p50 61.5 → 7.8 msper-node 循环的轻度 N+1
bgp_flap_state 搬 KV✅ 已做写热点归零 + 消除架构不对称与前缀级热态同构;转移历史仍留 SQL
扩核 + 加 worker不急线性当前 CPU 未持续打满
存储侧各项(分区化、索引清理、flap_alerts 迁 KV)扩展阈值

判断原则:先量再改。fleet-overview 那 2 秒在功能测试里完全正常,是压测把它照出来的;反过来,nodes 表 3300 万次全表扫看着吓人,量过之后确认是小表上的正确行为。