Skip to content

写入路径

数据进入库中的两条路径。二者的纪律截然相反:

管理写路径上报摄入路径
触发方人(管理面 / 门户 / 自动对等)agent 周期上报
写入对象L1 配置事实源L2 观测、L3 存档、L4 热态
一致性强:单事务、行锁、schema 校验,失败整体回滚弱:单轮失败仅记日志,下一轮自然补齐
时延纪律可以慢,正确性优先必须快:墙钟超过 agent 读超时会导致假失败
出口generations.snapshot,即 agent 拉取的 DesiredState观测视图与时序曲线

理解这条分界即掌握大半:配置错误必须拒绝,观测缺失必须容忍。


一、管理写路径:materialize

控制面采用「规范化表 → DesiredState 快照」的物化模型,入口有两个:

  • Provisionprovision_node_from_stateapp/db/provision.py)将整份 DesiredState 落库。_split_base_template 切出 Node.base_template——剥离 interfacesbgp_sessionsgeneration,将 dns 整段置 None(DNS 走共享组模型),并剥离 node 块中的身份字段(node_idasnrouter_id、loopback、prefixes),因为这些字段的权威值在 nodes 列上,materialize 时由 _node_payload 无条件覆盖,存副本只会陈旧。子表逐行 apply_spec 写入。该函数幂等:节点已存在时覆盖 base_template、删除并重建子表,再执行 materialize。
  • 管理写:管理员对 peerings、wg_interfaces、bgp_sessions、dns 等表的增改删,提交前调用 materialize 发布新世代。

materialize 流程

materialize(session, node_id, …)app/services/materializer.py):

  1. 锁节点行——session.get(Node, node_id, with_for_update={"of": Node}),使并发管理写在此处串行化,保证 generation 严格单调、不与 UNIQUE(node_id, generation) 冲突。

    限定只锁 nodes 主表是必需的:Node.dns_grouplazy="joined" 的可空关系,get() 会带外连接,而 PostgreSQL 不允许 FOR UPDATE 锁定外连接的可空侧。(SQLite 忽略该子句,依靠单写者与唯一约束兜底。)

  2. 递增世代new_generation = (current_generation or 0) + 1

  3. 注入节点密钥_inject_node_keys):对每个 WG 接口无条件将 private_key_ref 覆盖为 node_wireguard_keys 中的字面私钥。spec 中的原有取值一律被覆盖——这是密钥单一事实源的落地方式。

  4. 组装快照_assemble_snapshot):以 base_template 为底,叠加子表内容。

    • _node_payload:数据库中的节点身份字段优先覆盖,base_template.node 作为兜底(提供 region 等不在数据库中的字段)。
    • 接口与会话按 sort_order, id 取出。
    • enabled 语义不对称InterfaceSpecenabled 字段,disabled 接口直接不进入 snapshot;BgpSessionSpecenabled,disabled 会话仍在 snapshot 中但取值为 False
    • 退役处理lifecycle == "decommissioned" 时将 interfacesbgp_sessions 置空、dnsNone——agent 收敛后即拆除所有隧道、撤销所有 BGP、停止宣告路由;核心 runtime 服务(router-netns / wg-gateway / bird-router)按 schema 要求保留为惰性空转。
  5. 两类单一真相源的派生注入_interface_payload):

    • 节点 LLA:对外部 eBGP WG 接口(peering 存在且 is_internal=False),将 NodeSpec.link_local 派生为 <link_local>/64 注入 addresses(去重)。内部互联的 WG 接口使用各自的 link-local,不复用节点级取值。
    • 内部对端 WG 公钥:对端为另一受管节点时(peering.remote_node_id 非空),从对端 Node.wireguard_public_key 实时取值填入 wireguard_peer.public_key,而非在 spec 中存储副本。
  6. DNS 派生_load_dns_group):见 共享 DNS 的组装规则

  7. 校验DesiredState.model_validate(snapshot) 再次执行 schema 校验,拒绝任何漂移。校验不通过即抛出,由上层回滚事务,从而保证写入 generations.snapshot 的内容始终合法。管理路由将其转换为 422

  8. 写入 generation 并将 Node.current_generation 指向新值。

  9. 世代保留裁剪keep_generations(默认 100)大于 0 时,在同一事务内删除 generation <= new_generation - keep 的旧世代,防止表无界增长。当前世代必然落在保留窗口内。

提示:materialize 不触发事件广播。 由调用方在事务提交后决定是否发送门铃——否则「事件先发、事务后回滚」会导致 agent 拉取到旧数据。

派生注入是「将副本改为派生」的两个落地范例,概念模型见 地址模型


二、上报摄入路径

agent 的每类上报对应一个端点(清单见 Agent 面 API)。摄入策略按「该数据是否为对账的权威依据」分两档:

端点落库方式理由
POST /runtime-snapshotnode_status 同步落库后立即应答,流量、自观测、flap、autopeer 四路存档移入 background task健康与对账需即时可见;四路纯观测不应将墙钟叠加进 agent 的读超时
POST /routing-table整份移入 background task,认证后立即应答大表节点的整表写事务墙钟可超过 agent 读超时,同步等待会导致客户端断开
POST /prefix-flaps/wireguard-traffic/reconciliation-report/apply-result同步落库单次写入量小

回执先行

两个重型端点采用同一模式:先应答,再落库。

代价是失败无法再向客户端表达——_archive_runtime_snapshot_bg_record_routing_snapshot_bg 均捕获异常并记日志。这是有意设计:观测型数据在下一轮(约 5 min)自然补齐,而因控制面写延迟导致 agent 判定对账失败,等同于将存储问题伪装为节点故障。sha2 节点曾因此出现对账假失败,该模式即为此次问题的处置结论。

由此得出一条纪律:加入这两条背景路径的任何新摄入,都不能是「必须成功」的写入。 需要强一致的数据应走同步段,或改走管理写路径。

路由明细的两级门控

node_route_entries 是全库最大的表(178 303 行 / 144 MB)。若每轮整表重写,10 节点每轮即为十几万行的 delete 加 insert。写路径因此设置两级门控(services/routing.py):

实测效果:每节点每轮由整表重写的约 17 800 行降至约 1 500 行,降幅 92%。未能降至「几十行」量级的原因是差分粒度为前缀而非路径——一条路径变化即需重写该前缀的全部约 7 行明细。量化与再优化的判据见性能与容量

预提取

node_status 上有两列并非 agent 上报,而是在摄入时从 payload 中派生:

提取自免去的开销
bgp_summarypayload.bgp_protocols 原样副本fleet 聚合读侧无需解析 last_snapshot 大列
peer_rates与上一份快照差分得出的 per-peer 速率读侧无需自行检索上一份快照并做差分

这是「写时计算一次」替代「读时计算 N 次」的标准取舍。代价是这两列与 last_snapshot 存在冗余——它们是派生值,任何时候都可以从 last_snapshot 重算,因此缺列时读侧会自动回退到解析完整快照的慢路径(新部署可能缺失,见已知偏差)。

热态写

flap 打分状态不落 SQL,每份上报整份覆盖 KV 中的单键 blob(pflap:<node> / bgpflap:<node>)。写侧失败时回落 SQL 整表路径。取舍与降级语义见 Flap 检测


三、写入方一览

排查「某个值因何变化」时的第一张表:

写入方涉及的表
services/materializergenerationsnodes.current_generation
db/provision 与管理端点全部 L1 表
services/node_keysnode_wireguard_keysnodes.wireguard_public_key(只读投影)
PortAllocatorport_pools(以读为主),端口号写入 wg_interfaces.spec
services/node_statusnode_statusnode_status_events
services/routingnode_routingnode_route_entriesnode_route_prefix_hashesnode_routing_events
services/trafficnode_traffic_rollupnode_traffic_last_samplenode_iface_traffic_rollupnode_iface_traffic_last、Redis 热窗口
services/metricsnode_metrics_rollupnode_metrics_last
services/autopeering_metricsautopeer_route_rollup
services/flapsKV bgpflap:*bgp_flap_transitions(降级时写 bgp_flap_state
services/prefix_flapsKV pflap:*node_prefix_flap_metaprefix_flap_rate_rollup(降级时写另两张)
services/flap_alertsflap_alertsflap_activity_rollup
services/portal_sessionsportal_sessions、KV portal:login:*(降级时写 portal_login_transactions
审计中间件admin_audit_log

KV 与 Redis 侧的写入方逐键列于 KV 与缓存键索引