Skip to content

全局约定

全部表共享的命名、引擎、类型与结构纪律。修改 ORM 前应先阅读本页。


命名

所有表共享同一声明性基类 Baseapp/db/base.py)。Base.metadata 注入了统一的命名约定,保证索引、外键、唯一键与主键名跨数据库稳定一致:

类型模板
索引 ixix_%(table_name)s_%(column_0_N_name)s
唯一键 uquq_%(table_name)s_%(column_0_N_name)s
检查 ckck_%(table_name)s_%(constraint_name)s
外键 fkfk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s
主键 pkpk_%(table_name)s

约定式命名的实际价值体现在排障:在 pg_stat_user_indexes 中看到 ix_node_route_entries_node_prefix,即可判定其所属表与列组合,无需回查 DDL。


引擎与连接池

装配在 app/db/engine.pyDatabase 封装一个异步 AsyncEngineasync_sessionmakerexpire_on_commit=Falsepool_pre_ping=True)。

PostgreSQL 上显式配置连接池与语句超时:

参数理由
pool_size / max_overflow5 / 10每 worker 独立持有一份池,总连接数 = worker 数 × 15
pool_timeout10 s取不到连接即快速失败,避免请求无限排队
pool_recycle1800 s长时间空闲的连接被中间设备静默断开后,下次使用会报 server closed the connection unexpectedly
statement_timeout15 s单条卡死的重查询会长期占用连接直至耗尽池;正常查询中最重的 fleet 聚合在百毫秒量级

注意:修改 worker 数必须同步复核该乘法。 生产为 3 worker × 15 = 峰值 45,PostgreSQL max_connections=100(其中 3 个保留给 superuser),还需为 auth-server 与 registry-server 预留份额。池参数是配合多 worker 由 10/20 下调而来——单进程时代的 10/20 在 3 worker 下峰值为 90,已逼近上限。

对 SQLite(dev / CI)有两处特殊处理:

  • check_same_thread=False——异步多任务下 same-thread 限制无实际意义;
  • 每个新连接执行 PRAGMA foreign_keys=ON——SQLite 默认关闭外键,不开启则所有 ondelete 子句失效,关系树中的级联全部不生效。

类型

  • 时间戳列统一为 DateTime(timezone=True),Python 侧默认 datetime.now(timezone.utc),数据库侧 server_default=func.now()updated_at 列额外带 onupdate
  • ASN 列一律 BigInteger:DN42 的 4242420000 以上取值超出 int32 范围。
  • 时序表的桶起点用 BigInteger 存 epoch 秒,不使用时间戳类型,理由见时序建模通则
  • flap 与流量表中的 captured_atString,存放 agent 上报的 ISO 8601 原文。该字段是上报批次的标识与差分锚点,而非本地事件时刻,原样保留才能与 agent 侧口径逐字对齐。此项常被误判为疏漏,改为时间戳类型会破坏差分基准的比对。

关系加载策略

默认不急切加载。 除一处例外,全部多对一反向关系(AgentToken.nodeGeneration.nodeNode.dns_groupPeering.local_node / .remote_nodeWgInterface.nodeBgpSession.node / .peering、DNS 两处)均声明为 lazy="raise_on_sql"

原因是急切加载会沿关系链传递Node.dns_group 一处 lazy="joined",就会让每个带出 Node 的查询额外 JOIN 一次 dns_groups,而带出 Node 的查询遍布鉴权、世代拉取与 materialize,链式叠加后 select(WgInterface) 实际执行 7 个 JOIN。量化与修复见性能与容量

场景写法
只需外键值(绝大多数情况)直接用 xxx_id 列,不碰关系属性
确需关联对象在调用处 select(X).options(joinedload(X.rel)),作用域限于该查询
父到子的集合lazy="select"(按需)或 lazy="selectin"(批量取,避免 N+1)

唯一的例外WgInterface.peering,保持 lazy="joined":materializer 的 _load_peer_public_keysfleet_migration 都直接读 row.peering.remote_node_id,且这两处均为批量遍历,显式 joinedload 反而分散。

改动关系加载策略时,务必先确认该关系有无访问点(grep -rnE "\.(peering|node|dns_group)\."),再跑全量测试——raise_on_sql 的违规只在运行时暴露,类型检查看不见。

raise_on_sql 而非 select 的理由:AsyncSession 下隐式惰性加载会抛 MissingGreenlet,报错含义模糊;raise_on_sql 直接指出「此处需要显式加载」,同时允许已在 identity map 中的对象无 SQL 解析。

SQL 构造与防注入

三层约束,从强到弱依次兜底。前两层是设计使然,第三层是把纪律固化成会失败的检查。

第一层:结构

读写一律走 ORM / Core 表达式,值由驱动绑定。这一层不需要额外防护——参数化之下值永远不会被解析为 SQL。三个服务的应用代码中,text() 仅用于 SELECT 1 健康探针、server_default 与部分索引谓词等常量场景。

第二层:ruff S608

pyproject.tomlextend-select = ["S608"] 拦住 f-string / % / .format 拼接 DML 与 DQL

它有两处盲区,因此不能作为唯一防线:只识别 SELECT/INSERT/UPDATE/DELETE,看不见 DDL(CREATE/DROP/ALTER);也看不见「把变量整个传进 text()」——那一行没有字符串字面量可供匹配。

第三层:AST 纪律测试

app/tests/test_sql_discipline.py 扫描运行时代码,补上述两处盲区:

规则约束
R1text() / exec_driver_sql() 的首个实参必须是字符串字面量
R2禁止 getattr(Model, 变量) 式动态标识符——参数化对标识符位置无效

R1 的豁免走 # sql-ok: <理由> 标记(可写在调用行或紧邻上方的注释块)。它同时是可 grep 的审计清单grep -rn "sql-ok:" app/ 一次列出全部动态 SQL 及其豁免依据。测试还对豁免数量设了上限,新增一处会失败,须显式上调并说明理由。

当前全仓库仅两处豁免,都在 services/partitions.py:分区 DDL 无法参数化(标识符与边界不能是绑定参数),插值项分别来自模块常量与整数运算,DROP 那处的分区名还额外经过常量前缀过滤与 int() 解析——含 SQL 元字符的名字会在解析处被拒。

LIKE 通配符必须转义

把用户输入直接包成 %…% 不构成注入(值仍是绑定参数),但输入中的 %_ 会被当作通配符:搜 a_b 命中 axb,搜 % 命中全表。前者是语义错误,后者把一次检索放大为全表扫描。

统一走 app/db/sqlutil.py,两者成对使用,缺一不可:

python
from ..db.sqlutil import LIKE_ESCAPE, like_contains

pattern = like_contains(user_input)
column.like(pattern, escape=LIKE_ESCAPE)

ESCAPE 子句在 SQLite 与 PostgreSQL 上语义一致,无需按方言分支。参数本身是整数等无通配符可能的类型时可以不转义,但应在该处注明原因。

索引列 + spec JSON 双层结构

WgInterfaceBgpSession 采用「索引列 + spec JSON」双层结构:少量字段(namenode_idpeering_idenabledkindremote_asn)承担查询与约束,完整的 Pydantic schema dump 存入 spec 列,使后端无需随 schema 演进而改表。

索引列由 apply_spec() 自校验后的 spec 单源投影,从而杜绝列与 JSON 漂移。这是该结构成立的前提:任何绕过 apply_spec 直接写列的路径都会制造两份真相。

同一思路在 nodes.base_template(DesiredState 中不来自子表的部分)与 generations.snapshot(整份已发布快照)上复用。

代价是这些列无法参与查询,只能整体取出。node_status 上的 bgp_summarypeer_rates 两个预提取列即为绕开「读侧解析大 JSON 列」而设,属于以一次写时计算替代 N 次读时计算的取舍。