外观
全局约定
全部表共享的命名、引擎、类型与结构纪律。修改 ORM 前应先阅读本页。
命名
所有表共享同一声明性基类 Base(app/db/base.py)。Base.metadata 注入了统一的命名约定,保证索引、外键、唯一键与主键名跨数据库稳定一致:
| 类型 | 模板 |
|---|---|
索引 ix | ix_%(table_name)s_%(column_0_N_name)s |
唯一键 uq | uq_%(table_name)s_%(column_0_N_name)s |
检查 ck | ck_%(table_name)s_%(constraint_name)s |
外键 fk | fk_%(table_name)s_%(column_0_N_name)s_%(referred_table_name)s |
主键 pk | pk_%(table_name)s |
约定式命名的实际价值体现在排障:在 pg_stat_user_indexes 中看到 ix_node_route_entries_node_prefix,即可判定其所属表与列组合,无需回查 DDL。
引擎与连接池
装配在 app/db/engine.py:Database 封装一个异步 AsyncEngine 与 async_sessionmaker(expire_on_commit=False、pool_pre_ping=True)。
PostgreSQL 上显式配置连接池与语句超时:
| 参数 | 值 | 理由 |
|---|---|---|
pool_size / max_overflow | 5 / 10 | 每 worker 独立持有一份池,总连接数 = worker 数 × 15 |
pool_timeout | 10 s | 取不到连接即快速失败,避免请求无限排队 |
pool_recycle | 1800 s | 长时间空闲的连接被中间设备静默断开后,下次使用会报 server closed the connection unexpectedly |
statement_timeout | 15 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_at为String,存放 agent 上报的 ISO 8601 原文。该字段是上报批次的标识与差分锚点,而非本地事件时刻,原样保留才能与 agent 侧口径逐字对齐。此项常被误判为疏漏,改为时间戳类型会破坏差分基准的比对。
关系加载策略
默认不急切加载。 除一处例外,全部多对一反向关系(AgentToken.node、Generation.node、Node.dns_group、Peering.local_node / .remote_node、WgInterface.node、BgpSession.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_keys 与 fleet_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.toml 的 extend-select = ["S608"] 拦住 f-string / % / .format 拼接 DML 与 DQL。
它有两处盲区,因此不能作为唯一防线:只识别 SELECT/INSERT/UPDATE/DELETE,看不见 DDL(CREATE/DROP/ALTER);也看不见「把变量整个传进 text()」——那一行没有字符串字面量可供匹配。
第三层:AST 纪律测试
app/tests/test_sql_discipline.py 扫描运行时代码,补上述两处盲区:
| 规则 | 约束 |
|---|---|
| R1 | text() / 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 双层结构
WgInterface 与 BgpSession 采用「索引列 + spec JSON」双层结构:少量字段(name、node_id、peering_id、enabled、kind、remote_asn)承担查询与约束,完整的 Pydantic schema dump 存入 spec 列,使后端无需随 schema 演进而改表。
索引列由 apply_spec() 自校验后的 spec 单源投影,从而杜绝列与 JSON 漂移。这是该结构成立的前提:任何绕过 apply_spec 直接写列的路径都会制造两份真相。
同一思路在 nodes.base_template(DesiredState 中不来自子表的部分)与 generations.snapshot(整份已发布快照)上复用。
代价是这些列无法参与查询,只能整体取出。node_status 上的 bgp_summary 与 peer_rates 两个预提取列即为绕开「读侧解析大 JSON 列」而设,属于以一次写时计算替代 N 次读时计算的取舍。