Skip to content

性能与容量

数据层的实测画像:库有多大、往哪个方向增长、写热点分布、索引是否有冗余、何时需要动手。

端点级的 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_entries178 303144 MB节点数 × 前缀数 × 路径数节点翻倍即增加 144 MB
node_iface_traffic_rollup777 120127 MB节点数 × 接口数 × 时间对等数翻倍即增加 127 MB
prefix_flap_rate_rollup462 60461 MB节点数 × 时间(60 s 桶)节点翻倍即增加 61 MB
node_traffic_rollup119 30615 MB节点数 × 时间
node_metrics_rollup110 19615 MB节点数 × 时间
autopeer_route_rollup54 4189768 kB节点数 × 自动对等接口数 × 时间

三张表占据全库 83%,且均已达到保留期上限、进入稳态——时序表在 60 天窗口填满后不再增长,除非节点数或接口数发生变化。

增长维度只有两个:节点数线性影响全部表;每节点接口数仅影响 node_iface_traffic_rollupautopeer_route_rollup,但这两张增长最快。自动对等每落成一条会话即新增一个接口,因此接口维度的增速由对等业务量决定,而非由运维节奏决定——这是最需要持续关注的一条曲线。

node_route_entries 的形态需单独说明:每节点仅有 2 496 个唯一前缀,明细却达 1.78 万行,即每个前缀平均 7 条路径(来自不同对端的多路径)。其规模由 DN42 全网前缀数决定,与本网规模无关。


二、写热点与写放大

实测写速率

pg_stat_user_tablesn_tup_upd 取两次采样(间隔 69 s)得到真实速率。累计计数自集群建库起累积,不能直接当作速率读取:

行/分钟来源
flap_alerts约 11060 s 告警评估循环刷新在开告警的 last_score
node_traffic_rollup2030 s 流量采样 × 10 节点
node_traffic_last_sample20同上(差分基准)
node_prefix_flap_meta1060 s 前缀级上报 × 10 节点
node_status / node_routing / node_route_prefix_hashes / node_metrics_last / node_iface_traffic_last各约 25 min 快照 × 10 节点
bgp_flap_state0打分热态已迁 KV,详见下文
合计约 170(约 2.8 行/秒)

每秒不足三行。 任何关于写压力的判断都应先对照该数值——PostgreSQL 在此量级上不构成瓶颈。

INSERT 侧量级不同:node_route_entries 的差分写实测约 2 860 行/分钟,并伴随近似等量的 DELETE,是全库最大的单一写来源。

差分写的实际效果

路由明细的两级门控效果可直接量化:

方式每节点每轮写入
整表重写(改造前)约 17 800 行
差分写(当前)约 1 500 行

降幅约 92%,但并非降至「几十行」量级,原因需要说明:同期 node_routing_events 记录的 announcedwithdrawn 恒为 0,表明前缀集合并未变化,变化的是同一前缀下的多路径明细(路径属性、来源对端)。而差分粒度是前缀——一条路径变化即需重写该前缀的全部 7 行。若要再降一个量级,只能将差分粒度细化至路径级,代价是基准表需存储 7 倍数量的哈希,在当前量级下收益不足以覆盖复杂度。

已完成的一次迁移

bgp_flap_state 的打分热态已迁入 KV,依据是架构一致性而非性能:前缀级热态早已在 KV,会话级却仍在 PostgreSQL,同一概念存在两种存法,易被后续改动误用。效果可验证:

  • n_tup_upd 由持续增长转为恒定 428 185,在 21 分钟观测窗口内未增长;
  • seq_scan 仍在增长——读侧每轮仍查询该表以完成两路合并,此为预期行为。

剩下的写热点该不该迁移

按数据性质分三类:

类别可否迁移判据
当下值(派生,将被下一次上报覆盖)flap_alerts.last_scorenode_traffic_last_samplenode_iface_traffic_last可以丢失后可从下一份上报重建,或仅损失一个采样间隔
时序存档(不可重建)node_traffic_rollupprefix_flap_rate_rollup不应既往采样无法追溯,置于可丢失介质违反分层原则
事实与事件flap_alertsstarted_at / resolved_at / peak_scorenode_statusbgp_flap_transitions不应需审计追溯

flap_alerts 是唯一仍值得评估的一项。 其每分钟 110 行 UPDATE 中绝大部分用于刷新 last_score(可重算的当下值),而 peak_scoreresolved_at 仅在真实事件发生时变更。若改为仅在事实变化时 UPDATE、将 last_score 移入 KV 并在读时合并,可将写量降低一至两个数量级。实施时需避免将审计字段一并迁出——这正是会话级迁移刻意保留 bgp_flap_transitions 于 SQL 的原因。

不建议将 rollup 迁入 KV。 若要降低其写放大(一个 5 min 桶被 30 s 采样 upsert 约 10 次),正确做法是在 KV 中累加、桶结束时一次性落库,而非迁走存档本身。该方案的代价是崩溃时丢失当前桶(不超过 5 分钟)。

结论:以上各项当前均不实施,触发条件见第六节


三、索引与扫描行为

全表扫描在小表上是正确选择

seq_scan[1] 最高的几张表:

全表扫描索引扫描活行
nodes33 468 19910 15110
agent_tokens706 531013
node_traffic_last_sample642 0847610
bgp_flap_state496 16125136
dns_groups415 254405 0581

这些均无需「优化为索引扫描」。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_rollup58 MB281 164必需(读取按 节点+接口+桶)
pk_node_route_entries42 MB15自增主键,读侧几乎不使用
pk_prefix_flap_rate_rollup18 MB160 228必需
ix_node_route_entries_node_v68992 kB155近乎不使用
ix_node_route_entries_node_local8440 kB18近乎不使用
ix_prefix_flap_rate_rollup_bucket5624 kB321 102必需,使用频次高于主键(跨节点按时间聚合无法走主键最左前缀)
ix_node_route_entries_node_prefix5200 kB3 121 002主力索引,前缀检索依赖它
ix_node_status_events_created_at712 kB0从未被使用

node_route_entries 的四个索引合计 64 MB,其中仅 5 MB 的那个承担实际检索。另外三个(42 MB 主键与两个布尔列复合索引)在每轮差分写的 1 500 行 insert / delete 中均需维护,属于纯写负担。

不建议直接删除。 自增主键还承担行标识与 DELETE ... WHERE id NOT IN 一类语句;node_v6node_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 鉴权)2agent_tokens + nodes + dns_groups0
select(Generation).where(node_id=…)(每次拉 desired-state)2generations + nodes + dns_groups0
select(WgInterface)(每次 materialize)7+ peerings 及其两端 node 再各带 dns_groups1
select(BgpSession)(每次 materialize)7同上0
select(Peering)4peerings + 两端 nodes + 两次 dns_groups0
session.get(Node)1nodes + dns_groups0

处置:将无访问点的多对一反向关系统一改为 lazy="raise_on_sql"Node.dns_groupAgentToken.nodeGeneration.nodeWgInterface.nodeBgpSession.node / .peeringPeering.local_node / .remote_node、DNS 两处反向关系)。保留 WgInterface.peeringjoined,因为 materializer 与 fleet_migration 确实读取它——这也是修复后 select(WgInterface) 仍有 1 个 JOIN 的原因。

raise_on_sql 而非 select 的理由:在 AsyncSession 下隐式惰性加载本就会抛 MissingGreenlet,与其得到一个含义模糊的报错,不如让 SQLAlchemy 直接指出「此处需要显式 joinedload」。确需整行关联对象时,在调用处显式声明 options(joinedload(...))

上线后的验证口径dns_groups 是最干净的观测量——它自身几乎无业务流量,改造前却被每个带出 Node 的查询连带扫描。nodes 不适合做观测量:它的扫描计数被外键校验淹没(见上文)。

生产实测:改造前 dns_groupsseq_scan 以约 14 次/分钟增长(24 小时 +20 462);上线后连续六分钟的多次采样计数完全不变,确认该来源已归零。

读路径已基本不访问数据库

测量方法为对同一端点连续两次请求,取第二次前后 pg_stat_user_tables 扫描次数的差值:

端点每请求查询 nodesagent_tokenspeerings
/ui/dashboard000
/ui/routing/fleet-overview000
/ui/fleet/prefix-flaps000
/ui/nodes100

不存在 N+1[2]。缓存命中时读路径完全不落库,/ui/nodes 的那一次是一条 10 行的全表查询。缓存键与 TTL 见 KV 与缓存键索引


四、死元组与 vacuum

死元组来源
node_route_entries9 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 MBshared_buffers=128 MB,无法容纳全库,但足以容纳热数据
work_mem4 MB见下方临时文件说明
临时文件累计 1 156 次 / 7 757 MB,当前零增长曾有查询溢出磁盘,现已消除
死锁0无锁竞争病理
max_connections1003 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 改为 0ALTER SYSTEMSELECT 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_alertslast_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,并更新本页参考基线的条件表。


  1. seq_scan / idx_scan:PostgreSQL 统计视图中的顺序扫描与索引扫描次数。前者逐页读取整表,后者经索引定位。行数极少的表上顺序扫描通常更快,因为整表仅占一个数据页,而走索引需额外读取索引页。 ↩︎

  2. N+1 查询:先用一条查询取出 N 条主记录,再对每条各发一次查询取关联数据,导致总查询数为 N+1。典型症状是响应时间随记录数线性上升。 ↩︎