外观
写入路径
数据进入库中的两条路径。二者的纪律截然相反:
| 管理写路径 | 上报摄入路径 | |
|---|---|---|
| 触发方 | 人(管理面 / 门户 / 自动对等) | agent 周期上报 |
| 写入对象 | L1 配置事实源 | L2 观测、L3 存档、L4 热态 |
| 一致性 | 强:单事务、行锁、schema 校验,失败整体回滚 | 弱:单轮失败仅记日志,下一轮自然补齐 |
| 时延纪律 | 可以慢,正确性优先 | 必须快:墙钟超过 agent 读超时会导致假失败 |
| 出口 | generations.snapshot,即 agent 拉取的 DesiredState | 观测视图与时序曲线 |
理解这条分界即掌握大半:配置错误必须拒绝,观测缺失必须容忍。
一、管理写路径:materialize
控制面采用「规范化表 → DesiredState 快照」的物化模型,入口有两个:
- Provision:
provision_node_from_state(app/db/provision.py)将整份DesiredState落库。_split_base_template切出Node.base_template——剥离interfaces、bgp_sessions、generation,将dns整段置None(DNS 走共享组模型),并剥离 node 块中的身份字段(node_id、asn、router_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):
锁节点行——
session.get(Node, node_id, with_for_update={"of": Node}),使并发管理写在此处串行化,保证 generation 严格单调、不与UNIQUE(node_id, generation)冲突。限定只锁
nodes主表是必需的:Node.dns_group为lazy="joined"的可空关系,get()会带外连接,而 PostgreSQL 不允许FOR UPDATE锁定外连接的可空侧。(SQLite 忽略该子句,依靠单写者与唯一约束兜底。)递增世代:
new_generation = (current_generation or 0) + 1。注入节点密钥(
_inject_node_keys):对每个 WG 接口无条件将private_key_ref覆盖为node_wireguard_keys中的字面私钥。spec 中的原有取值一律被覆盖——这是密钥单一事实源的落地方式。组装快照(
_assemble_snapshot):以base_template为底,叠加子表内容。_node_payload:数据库中的节点身份字段优先覆盖,base_template.node作为兜底(提供region等不在数据库中的字段)。- 接口与会话按
sort_order, id取出。 - enabled 语义不对称:
InterfaceSpec无enabled字段,disabled 接口直接不进入 snapshot;BgpSessionSpec有enabled,disabled 会话仍在 snapshot 中但取值为False。 - 退役处理:
lifecycle == "decommissioned"时将interfaces与bgp_sessions置空、dns置None——agent 收敛后即拆除所有隧道、撤销所有 BGP、停止宣告路由;核心 runtime 服务(router-netns/wg-gateway/bird-router)按 schema 要求保留为惰性空转。
两类单一真相源的派生注入(
_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 中存储副本。
- 节点 LLA:对外部 eBGP WG 接口(
DNS 派生(
_load_dns_group):见 共享 DNS 的组装规则。校验:
DesiredState.model_validate(snapshot)再次执行 schema 校验,拒绝任何漂移。校验不通过即抛出,由上层回滚事务,从而保证写入generations.snapshot的内容始终合法。管理路由将其转换为422。写入 generation 并将
Node.current_generation指向新值。世代保留裁剪:
keep_generations(默认 100)大于 0 时,在同一事务内删除generation <= new_generation - keep的旧世代,防止表无界增长。当前世代必然落在保留窗口内。
提示:materialize 不触发事件广播。 由调用方在事务提交后决定是否发送门铃——否则「事件先发、事务后回滚」会导致 agent 拉取到旧数据。
派生注入是「将副本改为派生」的两个落地范例,概念模型见 地址模型。
二、上报摄入路径
agent 的每类上报对应一个端点(清单见 Agent 面 API)。摄入策略按「该数据是否为对账的权威依据」分两档:
| 端点 | 落库方式 | 理由 |
|---|---|---|
POST /runtime-snapshot | node_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_summary | payload.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/materializer | generations、nodes.current_generation |
db/provision 与管理端点 | 全部 L1 表 |
services/node_keys | node_wireguard_keys、nodes.wireguard_public_key(只读投影) |
PortAllocator | port_pools(以读为主),端口号写入 wg_interfaces.spec |
services/node_status | node_status、node_status_events |
services/routing | node_routing、node_route_entries、node_route_prefix_hashes、node_routing_events |
services/traffic | node_traffic_rollup、node_traffic_last_sample、node_iface_traffic_rollup、node_iface_traffic_last、Redis 热窗口 |
services/metrics | node_metrics_rollup、node_metrics_last |
services/autopeering_metrics | autopeer_route_rollup |
services/flaps | KV bgpflap:*、bgp_flap_transitions(降级时写 bgp_flap_state) |
services/prefix_flaps | KV pflap:*、node_prefix_flap_meta、prefix_flap_rate_rollup(降级时写另两张) |
services/flap_alerts | flap_alerts、flap_activity_rollup |
services/portal_sessions | portal_sessions、KV portal:login:*(降级时写 portal_login_transactions) |
| 审计中间件 | admin_audit_log |
KV 与 Redis 侧的写入方逐键列于 KV 与缓存键索引。