外观
性能
控制面的实测性能:热点在哪、为什么、怎么量。全部数字来自生产实测(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 | 累计 |
|---|---|---|---|---|---|
/healthz | 181.2 | 179.7 | 371.3 | 534.7 | 3.0× |
/ui/dashboard | 60.9 | 113.9 | 168.8 | 302.7 | 5.0× |
/ui/nodes | 45.3 | 41.8 | 71.1 | 104.8 | 2.3× |
/ui/fleet/prefix-flaps | 11.0 | 11.2 | 15.4 | 33.4 | 3.0× |
/ui/routing/fleet-overview | 1.9 | 115.3 | 204.6 | 361.3 | 190× |
三段收益的来源各不相同,值得分开看:
- 缓存修复(第 1→2 列):只影响
fleet-overview,但那一项就是 60 倍。与 CPU 无关。 - 换 4 核(第 2→3 列):普涨 1.4–2.1×。收益不是"控制面变快了"——它仍然只能用一个核——而是它不再和 PG / Valkey / nginx / agent 上报抢同一个核。
- 开 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 零次——而在:
- 从 KV 读 10 个热态 blob,合计约 1.4 MB JSON;
- 反序列化后对每个条目按半衰期做衰减计算(纯 CPU,单请求内串行);
- 跨节点按
(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 s | Redis 键寿命,也是「后台刷新持续失败」时陈旧度的兜底上限;无人访问 10 分钟即自然淘汰 |
| 单飞锁 | 90 s | SET 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_keys | 0 | 从未触顶淘汰 |
| Valkey 内存 | 2.78 MB / 256 MB(1.1%) | 离 70% 告警线很远 |
| 最大单键 | pflap:<node> 115–164 KB | 10 节点合计约 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 ms | per-node 循环的轻度 N+1 |
bgp_flap_state 搬 KV | ✅ 已做 | 写热点归零 + 消除架构不对称 | 与前缀级热态同构;转移历史仍留 SQL |
| 扩核 + 加 worker | 不急 | 线性 | 当前 CPU 未持续打满 |
存储侧各项(分区化、索引清理、flap_alerts 迁 KV) | 见扩展阈值 | — | — |
判断原则:先量再改。fleet-overview 那 2 秒在功能测试里完全正常,是压测把它照出来的;反过来,nodes 表 3300 万次全表扫看着吓人,量过之后确认是小表上的正确行为。