外观
性能与容量
数据层的实测画像:库有多大、往哪个方向增长、写热点分布、索引是否有冗余、何时需要动手。
端点级的 QPS 与 p50 基准、并发模型属于应用层范畴,见性能画像。
测量口径:参考基线
本页全部数值取自生产环境的一次完整采集,采集条件统称参考基线:
| 条件 | 取值 |
|---|---|
| 控制面主机 | 4 核 / 7.9 GB |
| 控制面进程 | uvicorn 3 worker |
| 在线节点 | 10 个 |
| agent 版本 | 1.0.293 |
| 数据库 | PostgreSQL 16,单实例三库 |
引用基线而非日期,是因为数值的可解释性来自采集条件而非采集时刻。 一个「178 303 行」只有在「10 节点在线」的前提下才有意义;节点数变化后,日期再新的数字也不具可比性。基线随每次规格变更重采,历次采集的原始记录与时间戳保存在 scripts/bench/BASELINES.md,本页只描述当前基线下的结论。
复现方法见文末。
结论先行
| 维度 | 状态 | 依据 |
|---|---|---|
| 容量 | 宽裕 | 控制库约 400 MB,缓冲命中率 98.1% |
| 增长 | 集中于三张表 | 前三大表占全库 83%,其余 38 张合计不足 70 MB |
| 写速率 | 极低 | 全库 UPDATE 约 170 行/分钟(约 2.8 行/秒) |
| 写放大 | 一处明显 | node_route_entries 差分写约 2 860 行/分钟 |
| 索引 | 存在冗余 | 三个索引合计 51 MB,扫描次数均在三位数以内 |
| 死元组 | 一张表偏高 | node_traffic_rollup 约 7 900,其余基本为零 |
综合判断:数据层当前不存在性能问题,需要持续关注的是容量的增长维度。 两项值得动手的改动目的均非提速——分区化用于消除死元组,索引清理用于减少写负担。
一、实测规模与增长维度
控制库 41 张表合计约 400 MB,分布高度集中:
| 表 | 活行 | 表体 | 增长维度 | 规模翻倍的代价 |
|---|---|---|---|---|
node_route_entries | 178 303 | 144 MB | 节点数 × 前缀数 × 路径数 | 节点翻倍即增加 144 MB |
node_iface_traffic_rollup | 777 120 | 127 MB | 节点数 × 接口数 × 时间 | 对等数翻倍即增加 127 MB |
prefix_flap_rate_rollup | 462 604 | 61 MB | 节点数 × 时间(60 s 桶) | 节点翻倍即增加 61 MB |
node_traffic_rollup | 119 306 | 15 MB | 节点数 × 时间 | — |
node_metrics_rollup | 110 196 | 15 MB | 节点数 × 时间 | — |
autopeer_route_rollup | 54 418 | 9768 kB | 节点数 × 自动对等接口数 × 时间 | — |
三张表占据全库 83%,且均已达到保留期上限、进入稳态——时序表在 60 天窗口填满后不再增长,除非节点数或接口数发生变化。
增长维度只有两个:节点数线性影响全部表;每节点接口数仅影响 node_iface_traffic_rollup 与 autopeer_route_rollup,但这两张增长最快。自动对等每落成一条会话即新增一个接口,因此接口维度的增速由对等业务量决定,而非由运维节奏决定——这是最需要持续关注的一条曲线。
node_route_entries 的形态需单独说明:每节点仅有 2 496 个唯一前缀,明细却达 1.78 万行,即每个前缀平均 7 条路径(来自不同对端的多路径)。其规模由 DN42 全网前缀数决定,与本网规模无关。
二、写热点与写放大
实测写速率
对 pg_stat_user_tables 的 n_tup_upd 取两次采样(间隔 69 s)得到真实速率。累计计数自集群建库起累积,不能直接当作速率读取:
| 表 | 行/分钟 | 来源 |
|---|---|---|
flap_alerts | 约 110 | 60 s 告警评估循环刷新在开告警的 last_score |
node_traffic_rollup | 20 | 30 s 流量采样 × 10 节点 |
node_traffic_last_sample | 20 | 同上(差分基准) |
node_prefix_flap_meta | 10 | 60 s 前缀级上报 × 10 节点 |
node_status / node_routing / node_route_prefix_hashes / node_metrics_last / node_iface_traffic_last | 各约 2 | 5 min 快照 × 10 节点 |
bgp_flap_state | 0 | 打分热态已迁 KV,详见下文 |
| 合计 | 约 170(约 2.8 行/秒) |
每秒不足三行。 任何关于写压力的判断都应先对照该数值——PostgreSQL 在此量级上不构成瓶颈。
INSERT 侧量级不同:node_route_entries 的差分写实测约 2 860 行/分钟,并伴随近似等量的 DELETE,是全库最大的单一写来源。
差分写的实际效果
路由明细的两级门控效果可直接量化:
| 方式 | 每节点每轮写入 |
|---|---|
| 整表重写(改造前) | 约 17 800 行 |
| 差分写(当前) | 约 1 500 行 |
降幅约 92%,但并非降至「几十行」量级,原因需要说明:同期 node_routing_events 记录的 announced 与 withdrawn 恒为 0,表明前缀集合并未变化,变化的是同一前缀下的多路径明细(路径属性、来源对端)。而差分粒度是前缀——一条路径变化即需重写该前缀的全部 7 行。若要再降一个量级,只能将差分粒度细化至路径级,代价是基准表需存储 7 倍数量的哈希,在当前量级下收益不足以覆盖复杂度。
已完成的一次迁移
bgp_flap_state 的打分热态已迁入 KV,依据是架构一致性而非性能:前缀级热态早已在 KV,会话级却仍在 PostgreSQL,同一概念存在两种存法,易被后续改动误用。效果可验证:
n_tup_upd由持续增长转为恒定 428 185,在 21 分钟观测窗口内未增长;seq_scan仍在增长——读侧每轮仍查询该表以完成两路合并,此为预期行为。
剩下的写热点该不该迁移
按数据性质分三类:
| 类别 | 表 | 可否迁移 | 判据 |
|---|---|---|---|
| 当下值(派生,将被下一次上报覆盖) | flap_alerts.last_score、node_traffic_last_sample、node_iface_traffic_last | 可以 | 丢失后可从下一份上报重建,或仅损失一个采样间隔 |
| 时序存档(不可重建) | node_traffic_rollup、prefix_flap_rate_rollup | 不应 | 既往采样无法追溯,置于可丢失介质违反分层原则 |
| 事实与事件 | flap_alerts 的 started_at / resolved_at / peak_score、node_status、bgp_flap_transitions | 不应 | 需审计追溯 |
flap_alerts 是唯一仍值得评估的一项。 其每分钟 110 行 UPDATE 中绝大部分用于刷新 last_score(可重算的当下值),而 peak_score 与 resolved_at 仅在真实事件发生时变更。若改为仅在事实变化时 UPDATE、将 last_score 移入 KV 并在读时合并,可将写量降低一至两个数量级。实施时需避免将审计字段一并迁出——这正是会话级迁移刻意保留 bgp_flap_transitions 于 SQL 的原因。
不建议将 rollup 迁入 KV。 若要降低其写放大(一个 5 min 桶被 30 s 采样 upsert 约 10 次),正确做法是在 KV 中累加、桶结束时一次性落库,而非迁走存档本身。该方案的代价是崩溃时丢失当前桶(不超过 5 分钟)。
结论:以上各项当前均不实施,触发条件见第六节。
三、索引与扫描行为
全表扫描在小表上是正确选择
seq_scan[1] 最高的几张表:
| 表 | 全表扫描 | 索引扫描 | 活行 |
|---|---|---|---|
nodes | 33 468 199 | 10 151 | 10 |
agent_tokens | 706 531 | 0 | 13 |
node_traffic_last_sample | 642 084 | 76 | 10 |
bgp_flap_state | 496 161 | 25 | 136 |
dns_groups | 415 254 | 405 058 | 1 |
这些均无需「优化为索引扫描」。10 行的表仅占用一个数据页,planner 选择顺序扫描是正确的——走索引反而多一次索引页读取。agent_tokens 上存在 unique 索引却零次索引扫描,恰是 planner 判断正确的证据。
nodes 被扫描 3 300 万次这一数值,成因是外键校验而非查询:每向 node_route_entries 插入一行,PostgreSQL 都要回查父表确认 node_id 存在,而 10 行的 nodes 只占一页,planner 选顺序扫描。
该归因已实测确认——14 秒窗口内 nodes 顺序扫描增长 240 次、node_route_entries 插入增长 210 行,接近 1:1;累计值 3 578 万次扫描对 3 461 万行插入,相差 3.5%。差分写触发整表重写时会出现瞬时尖峰(实测 18 秒内 38 288 次,恰等于该节点的明细行数)。
结论:这个数字不需要处置。 单页扫描的成本极低,且它是外键完整性的代价,唯一的消除方式是去掉外键——不值得。
索引清单中有三个未被使用
按体积排序的索引使用情况:
| 索引 | 大小 | 使用次数 | 判断 |
|---|---|---|---|
pk_node_iface_traffic_rollup | 58 MB | 281 164 | 必需(读取按 节点+接口+桶) |
pk_node_route_entries | 42 MB | 15 | 自增主键,读侧几乎不使用 |
pk_prefix_flap_rate_rollup | 18 MB | 160 228 | 必需 |
ix_node_route_entries_node_v6 | 8992 kB | 155 | 近乎不使用 |
ix_node_route_entries_node_local | 8440 kB | 18 | 近乎不使用 |
ix_prefix_flap_rate_rollup_bucket | 5624 kB | 321 102 | 必需,使用频次高于主键(跨节点按时间聚合无法走主键最左前缀) |
ix_node_route_entries_node_prefix | 5200 kB | 3 121 002 | 主力索引,前缀检索依赖它 |
ix_node_status_events_created_at | 712 kB | 0 | 从未被使用 |
node_route_entries 的四个索引合计 64 MB,其中仅 5 MB 的那个承担实际检索。另外三个(42 MB 主键与两个布尔列复合索引)在每轮差分写的 1 500 行 insert / delete 中均需维护,属于纯写负担。
不建议直接删除。 自增主键还承担行标识与 DELETE ... WHERE id NOT IN 一类语句;node_v6 与 node_local 对应按地址族和 scope 过滤的读路径,扫描次数低可能仅表示当前无人使用该筛选条件。删除前应确认对应读路径确无调用方。收益也需明确:可节省约 51 MB 空间与每轮 1 500 行的索引维护,在当前写速率下不构成瓶颈,属于扩容前再处理的事项。
ORM 关系的 JOIN 放大(已消除)
ORM 的急切关系会沿关系链传递:Node.dns_group 曾声明为 lazy="joined",于是任何带出 Node 的查询都会再 LEFT JOIN 一次 dns_groups,而带出 Node 的查询遍布鉴权、世代拉取与 materialize:
| 查询 | 修复前 JOIN | 实际触及的表 | 修复后 |
|---|---|---|---|
select(AgentToken).where(token_hash=…)(每次 agent 鉴权) | 2 | agent_tokens + nodes + dns_groups | 0 |
select(Generation).where(node_id=…)(每次拉 desired-state) | 2 | generations + nodes + dns_groups | 0 |
select(WgInterface)(每次 materialize) | 7 | + peerings 及其两端 node 再各带 dns_groups | 1 |
select(BgpSession)(每次 materialize) | 7 | 同上 | 0 |
select(Peering) | 4 | peerings + 两端 nodes + 两次 dns_groups | 0 |
session.get(Node) | 1 | nodes + dns_groups | 0 |
处置:将无访问点的多对一反向关系统一改为 lazy="raise_on_sql"(Node.dns_group、AgentToken.node、Generation.node、WgInterface.node、BgpSession.node / .peering、Peering.local_node / .remote_node、DNS 两处反向关系)。保留 WgInterface.peering 为 joined,因为 materializer 与 fleet_migration 确实读取它——这也是修复后 select(WgInterface) 仍有 1 个 JOIN 的原因。
选 raise_on_sql 而非 select 的理由:在 AsyncSession 下隐式惰性加载本就会抛 MissingGreenlet,与其得到一个含义模糊的报错,不如让 SQLAlchemy 直接指出「此处需要显式 joinedload」。确需整行关联对象时,在调用处显式声明 options(joinedload(...))。
上线后的验证口径:dns_groups 是最干净的观测量——它自身几乎无业务流量,改造前却被每个带出 Node 的查询连带扫描。nodes 不适合做观测量:它的扫描计数被外键校验淹没(见上文)。
生产实测:改造前 dns_groups 的 seq_scan 以约 14 次/分钟增长(24 小时 +20 462);上线后连续六分钟的多次采样计数完全不变,确认该来源已归零。
读路径已基本不访问数据库
测量方法为对同一端点连续两次请求,取第二次前后 pg_stat_user_tables 扫描次数的差值:
| 端点 | 每请求查询 nodes | agent_tokens | peerings |
|---|---|---|---|
/ui/dashboard | 0 | 0 | 0 |
/ui/routing/fleet-overview | 0 | 0 | 0 |
/ui/fleet/prefix-flaps | 0 | 0 | 0 |
/ui/nodes | 1 | 0 | 0 |
不存在 N+1[2]。缓存命中时读路径完全不落库,/ui/nodes 的那一次是一条 10 行的全表查询。缓存键与 TTL 见 KV 与缓存键索引。
四、死元组与 vacuum
| 表 | 死元组 | 来源 |
|---|---|---|
node_route_entries | 9 500–28 400(波动) | 差分写的 DELETE;autovacuum 回收速度充足,观测期内在回收与再生之间循环 |
node_traffic_rollup | 约 7 900 | 按行 DELETE 裁剪保留期 |
node_status_events | 约 600 | 按条数裁剪历史 |
| 其余 38 张 | 低于 200 | — |
node_traffic_rollup 是唯一稳定偏高的一张,因其同时满足两个条件:高频 UPDATE(每桶被 upsert 约 10 次)与按行 DELETE 裁剪。分区化正是针对此类表设计——转换后裁剪由 DELETE 变为 DROP TABLE <分区>,该来源的死元组归零。
需注意收益边界:分区化只消除裁剪产生的死元组,不消除 upsert 产生的。该表两个来源皆有,分区化仅解决其中一半。
五、PostgreSQL 自身余量
| 指标 | 实测 | 解读 |
|---|---|---|
| 缓冲命中率 | 98.10%(19.2 亿 hit / 3 719 万 read) | 数据基本驻留内存,磁盘 I/O 不构成瓶颈 |
| 控制库大小 | 约 400 MB | shared_buffers=128 MB,无法容纳全库,但足以容纳热数据 |
work_mem | 4 MB | 见下方临时文件说明 |
| 临时文件 | 累计 1 156 次 / 7 757 MB,当前零增长 | 曾有查询溢出磁盘,现已消除 |
| 死锁 | 0 | 无锁竞争病理 |
max_connections | 100 | 3 worker × 池 (5+10) 峰值 45,另两个服务各占少量 |
| 空闲期连接 | 10 | 池的常驻部分 |
临时文件:历史溢出与当前状态
pg_stat_database.temp_files 记录排序或哈希超出 work_mem 而落盘的次数。控制库累计 1 156 次、7 757 MB,但两次间隔 22 小时的采样计数完全相同,说明溢出全部发生在过去,当前无新增。
成因指向 fleet-overview 加结果缓存之前的那条聚合查询:它每次请求都要对 node_route_entries(十几万行、含 JSON 列)做 GROUP BY,4 MB 的 work_mem 装不下。加缓存后该路径每 30 秒最多执行一次,溢出随之停止。
需要重新观察时,将 log_temp_files 由 -1 改为 0(ALTER SYSTEM 后 SELECT pg_reload_conf(),无需重启),每次溢出的语句与大小会写入日志。
当前规模下无需调整 PostgreSQL 参数。若确需调整,首先应评估 shared_buffers(128 MB 相对 400 MB 的库偏小),但在命中率 98% 的前提下收益有限——真正的热数据是若干小表与最近的时序桶,60 天前的冷存档本就不应占用内存。
连接池的核算见全局约定:修改 worker 数必须同步复核 worker 数 × 15 这一乘法。
六、扩展阈值
按性价比排序。前三项已完成,列出用于说明判断依据。
| 项 | 状态 | 收益 | 触发条件 |
|---|---|---|---|
| 路由明细差分写 | 已完成 | 每轮写量降低 92% | — |
| flap 热态迁 KV(两级) | 已完成 | 写热点归零,消除架构不对称 | — |
| 重型摄入回执先行 | 已完成 | 消除 agent 对账假失败 | — |
| 时序表分区化 | 待定 | 消除 node_traffic_rollup 的死元组 | 节点数超过 20,或最大表超过 500 万行,或 autovacuum 回收滞后 |
flap_alerts 的 last_score 迁 KV | 待定 | 写量降低一至两个数量级 | 告警数量级上升,或该表死元组开始堆积 |
| 清理三个冗余索引 | 待定 | 节省 51 MB 及每轮索引维护 | 扩容前,且确认读路径无调用方 |
调整 shared_buffers | 不必要 | 有限 | 缓冲命中率跌破 95% |
| 路径级差分写 | 不必要 | 再降低一个量级 | 单节点明细超过 10 万行 |
判断原则:先度量后改动。 本页每条结论均可用下列命令复现。nodes 表 3 300 万次全表扫描的绝对值较大,度量后确认是小表上的正确行为;反之 pk_node_route_entries 占用 42 MB 而读侧仅使用 15 次,不度量则无从发现。
复测方法
bash
ssh -i ~/.ssh/<密钥> <用户>@<控制面主机>
PSQL="sudo docker exec docker-compose-postgres-1 psql -U dn42 -d dn42_control -P pager=off"
# 表规模、扫描与写计数(第一、三节)
$PSQL -c "SELECT relname, n_live_tup, n_dead_tup,
pg_size_pretty(pg_total_relation_size(relid)) AS total,
seq_scan, idx_scan, n_tup_ins, n_tup_upd, n_tup_del
FROM pg_stat_user_tables ORDER BY pg_total_relation_size(relid) DESC"
# 索引体积与使用次数(第三节)
$PSQL -c "SELECT relname, indexrelname, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS sz
FROM pg_stat_user_indexes ORDER BY pg_relation_size(indexrelid) DESC LIMIT 25"
# 缓冲命中率(第五节)
$PSQL -c "SELECT datname, blks_read, blks_hit FROM pg_stat_database ORDER BY blks_hit DESC LIMIT 5"注意:n_tup_* 是累计值而非速率。 计数自集群建库起累积(stats_reset 为空),跨重启不清零。得到速率必须取两次采样求差并记录间隔——本页的「行/分钟」均来自 69 秒窗口的两次采样。将累计值当作速率读取,会得出与事实相反的结论。
采集完成后,将结果与采集时刻一并追加到 scripts/bench/BASELINES.md,并更新本页参考基线的条件表。