_BACKFILL 自动 ALTER 应考虑降级为默认关闭:库在下游生产表上发不受控 DDL #13

Closed
opened 2026-08-18 10:59:30 +08:00 by iomgaa · 1 comment
Owner

来源

issue #11 实施期间的调研发现(设计文档 2026-08-17-issue11-caller-dimensions-design.md §8 记录为「另开议题」)。与 #11 同源——都源于「库自管下游 schema」这件事——但属独立的架构变更,按反 gold-plating 未纳入 #11。

现状

两个遥测后端在构造期会对下游数据库发 DDL:

  • 表不存在时 CREATE TABLE(先探测后建,issue #3/#9 的教训已收口)
  • 表存在但缺列时,_BACKFILL 机制逐列 ALTER TABLE ... ADD COLUMN

补列没有开关,库升级后首次调用即自动执行。#11 又刚给这张表加了两列,这条路径的实际使用频率在上升。

调研到的同类先例

考察了 Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow:

做法 谁这么做
自动建表,但永不 ALTER Celery(官方文档明写 create_all() 不改既有表,schema 变更自己负责)、APScheduler 3.x
版本不认识就拒绝启动 APScheduler 4.x(读 schema_version> 1 直接 RuntimeError
完全不自动,要求用户手工执行 Quartz.NET(官方明说不自动建表也不自动迁移)、Django contrib(只分发迁移文件)
自动迁移,但重量级迁移默认关闭 Hangfire——唯一敢自动 ALTER 的,且专门加了 EnableHeavyMigrations(默认 false),原文理由是防止「不受控的升级在高负载环境造成长时间停机或死锁」
把「schema 变了怎么办」做成显式配置 dbt on_schema_change,默认 ignore(最保守)

没有找到任何先例支持「库在下游库里自动 ALTER 出列」是默认行为。 Hangfire 的自我设限与本项目的处境精确同构:ALTER TABLE ADD COLUMNACCESS EXCLUSIVE 锁,PG 11+ 加列本身很快,但锁会排在长事务后面并阻塞该表其后所有查询,形成锁队列雪崩。而遥测是业务路径上的内联 await

反面教材是 Hibernate 的 hbm2ddl.auto=update:为零配置而生,被复制进生产后孤儿列静默堆积、升级失败即应用下线。SQLAlchemy 官方也自我限定 create_all 仅适合测试与小应用,长期 schema 管理应交给 Alembic。

问题

  1. 库在下游生产表上发不受控 DDL,与最小权限原则冲突(要 DDL 就要 DDL 权限)。
  2. 多进程/多版本共存时,谁先跑补列是竞态。Prefect 官方就建议多实例部署关闭自动迁移、升级前单独跑一次。
  3. 下游 DBA 无从审计:DDL 不在任何迁移记录里,事后看不出表是什么时候被谁改的。

可能的方向(未定,需要设计)

调研建议的组合是 B + D + C

  • B 版本探测:探测真实列集合information_schema.columns / PRAGMA table_info)而非版本号——多版本共存下版本号没有单一真相,且探测只需 SELECT 权限,不需要任何 DDL 权限
  • D 打印 SQL 让用户执行:Alembic offline / --sql 模式、Django sqlmigrate、Hangfire 把 Install.sql 放进包里,都是主流工具的标准能力,不是奇技淫巧
  • C 开关 + 默认关闭:轻量自动、重量需显式开启

即默认行为改为:探测到缺列 → 记 warning + 打印需要执行的 SQL,并按缺失列降级写入,而非自己 ALTER。

需要一并确认的红线

无论最终怎么改,多版本共存的兼容纪律应当写进明文(Expand/Contract 成文规范):新列只增不删、必可空或有默认、INSERT 显式列名、SELECT 禁止 *。目前库已经满足前三条,但没有文档化为承诺。

注意

这是破坏性行为变更(下游从「升级即自动补列」变成「升级后需手工执行一条 SQL」),触及库对下游的公共承诺,按 CLAUDE.md §3 属 brainstorming 强制档 + 人类门。

## 来源 issue #11 实施期间的调研发现(设计文档 `2026-08-17-issue11-caller-dimensions-design.md` §8 记录为「另开议题」)。与 #11 同源——都源于「库自管下游 schema」这件事——但属独立的架构变更,按反 gold-plating 未纳入 #11。 ## 现状 两个遥测后端在构造期会对下游数据库发 DDL: - 表不存在时 `CREATE TABLE`(先探测后建,issue #3/#9 的教训已收口) - 表存在但缺列时,`_BACKFILL` 机制逐列 `ALTER TABLE ... ADD COLUMN` 补列**没有开关**,库升级后首次调用即自动执行。#11 又刚给这张表加了两列,这条路径的实际使用频率在上升。 ## 调研到的同类先例 考察了 Celery / APScheduler / Alembic / Django contrib / Hangfire / Quartz.NET / dbt / Airbyte / Fivetran / Prefect / Airflow: | 做法 | 谁这么做 | |---|---| | 自动建表,但**永不 ALTER** | Celery(官方文档明写 `create_all()` 不改既有表,schema 变更自己负责)、APScheduler 3.x | | 版本不认识就**拒绝启动** | APScheduler 4.x(读 `schema_version`,`> 1` 直接 `RuntimeError`) | | 完全不自动,要求用户手工执行 | Quartz.NET(官方明说不自动建表也不自动迁移)、Django contrib(只分发迁移文件) | | 自动迁移,但**重量级迁移默认关闭** | **Hangfire**——唯一敢自动 ALTER 的,且专门加了 `EnableHeavyMigrations`(默认 false),原文理由是防止「不受控的升级在高负载环境造成长时间停机或死锁」 | | 把「schema 变了怎么办」做成显式配置 | dbt `on_schema_change`,默认 `ignore`(最保守) | **没有找到任何先例支持「库在下游库里自动 ALTER 出列」是默认行为。** Hangfire 的自我设限与本项目的处境精确同构:`ALTER TABLE ADD COLUMN` 取 **ACCESS EXCLUSIVE 锁**,PG 11+ 加列本身很快,但锁会排在长事务后面并阻塞该表其后所有查询,形成锁队列雪崩。而遥测是业务路径上的内联 `await`。 反面教材是 Hibernate 的 `hbm2ddl.auto=update`:为零配置而生,被复制进生产后孤儿列静默堆积、升级失败即应用下线。SQLAlchemy 官方也自我限定 `create_all` 仅适合测试与小应用,长期 schema 管理应交给 Alembic。 ## 问题 1. 库在下游**生产**表上发不受控 DDL,与最小权限原则冲突(要 DDL 就要 DDL 权限)。 2. 多进程/多版本共存时,谁先跑补列是竞态。Prefect 官方就建议多实例部署关闭自动迁移、升级前单独跑一次。 3. 下游 DBA 无从审计:DDL 不在任何迁移记录里,事后看不出表是什么时候被谁改的。 ## 可能的方向(未定,需要设计) 调研建议的组合是 **B + D + C**: - **B 版本探测**:探测**真实列集合**(`information_schema.columns` / `PRAGMA table_info`)而非版本号——多版本共存下版本号没有单一真相,且探测只需 SELECT 权限,不需要任何 DDL 权限 - **D 打印 SQL 让用户执行**:Alembic offline / `--sql` 模式、Django `sqlmigrate`、Hangfire 把 `Install.sql` 放进包里,都是主流工具的标准能力,不是奇技淫巧 - **C 开关 + 默认关闭**:轻量自动、重量需显式开启 即默认行为改为:探测到缺列 → **记 warning + 打印需要执行的 SQL**,并按缺失列降级写入,而非自己 ALTER。 ## 需要一并确认的红线 无论最终怎么改,多版本共存的兼容纪律应当写进明文(Expand/Contract 成文规范):新列只增不删、必可空或有默认、INSERT 显式列名、SELECT 禁止 `*`。目前库已经满足前三条,但没有文档化为承诺。 ## 注意 这是**破坏性行为变更**(下游从「升级即自动补列」变成「升级后需手工执行一条 SQL」),触及库对下游的公共承诺,按 CLAUDE.md §3 属 brainstorming 强制档 + 人类门。
Author
Owner

已随 1.2.3 发布(https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.2.3)

采纳的是"按后端不对称默认"这一档:新增 PGW_TELEMETRY_SCHEMA_MODE=auto|manual,三态——未设时按后端派生(SQLite→auto、Postgres→manual),显式设置则两侧都可覆盖。manual 档探测真实列集合后不发任何 DDL,改为逐列点名缺失列 + 打印可执行 SQL + 按现有列裁剪 INSERT 继续写入。

不对称的理由:issue 引用的先例(Hangfire 锁队列、Prefect 多实例竞态、Alembic 审计链)语境都是共享的生产 PG,而 SQLite 侧是下游自己的本地文件(VT / CHSAnalyzer / dissect 都是 runs/*.db 形态),无 DBA、无迁移工具,强加手工 SQL 步骤是净损失。

实现中确认的两点,值得记下:① 关掉 ALTER 必须配套裁剪写入,否则旧表缺列时 INSERT 会整行失败、逐行 warning,遥测彻底丢失——比自动 ALTER 更严重地违反"遥测必录";② 库执行的补列语句与打印给下游的脚本是两份:库内先探测后 ALTER(避开 ACCESS EXCLUSIVE 锁),打印出去的那份必须带 IF NOT EXISTS 才可重复执行。

另外 telemetry_schema_sql(backend) 进了顶层导出,下游可主动索取库要求的最小 schema 写进自己的迁移文件;PG 写入去掉了 ON CONFLICT 的冲突目标(分区表要求唯一约束包含分区键,带目标的语句在分区部署下会全线写不进去)。Expand/Contract 五条纪律已成文进 README 与 ARCHITECTURE D15。

已随 **1.2.3** 发布(https://gitea.iomgaa.online/iomgaa/PolyGateway/releases/tag/v1.2.3)。 采纳的是"按后端不对称默认"这一档:新增 `PGW_TELEMETRY_SCHEMA_MODE=auto|manual`,三态——未设时按后端派生(SQLite→auto、Postgres→manual),显式设置则两侧都可覆盖。manual 档探测真实列集合后**不发任何 DDL**,改为逐列点名缺失列 + 打印可执行 SQL + 按现有列裁剪 INSERT 继续写入。 不对称的理由:issue 引用的先例(Hangfire 锁队列、Prefect 多实例竞态、Alembic 审计链)语境都是共享的生产 PG,而 SQLite 侧是下游自己的本地文件(VT / CHSAnalyzer / dissect 都是 `runs/*.db` 形态),无 DBA、无迁移工具,强加手工 SQL 步骤是净损失。 实现中确认的两点,值得记下:① **关掉 ALTER 必须配套裁剪写入**,否则旧表缺列时 INSERT 会整行失败、逐行 warning,遥测彻底丢失——比自动 ALTER 更严重地违反"遥测必录";② 库执行的补列语句与打印给下游的脚本**是两份**:库内先探测后 ALTER(避开 ACCESS EXCLUSIVE 锁),打印出去的那份必须带 `IF NOT EXISTS` 才可重复执行。 另外 `telemetry_schema_sql(backend)` 进了顶层导出,下游可主动索取库要求的最小 schema 写进自己的迁移文件;PG 写入去掉了 `ON CONFLICT` 的冲突目标(分区表要求唯一约束包含分区键,带目标的语句在分区部署下会全线写不进去)。Expand/Contract 五条纪律已成文进 README 与 ARCHITECTURE D15。
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: iomgaa/PolyGateway#13