build(数据库): 改用 drizzle migration,加提交列表索引、清掉 Django 残留
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
一条线上的三件事:让 drizzle 的迁移机制真正可用 → 用它加索引 → 用它清掉 不再需要的 Django 表,最后接进部署和 CI。 ## 1. 让 drizzle-kit generate 可用 原本以为不能用:只加一个索引,generate 却吐出一堆噪音,其中 5 条 `DROP SEQUENCE auth_*/django_*` 打到生产库上会直接搞坏旧后端。 逐个查下来全是 `pull` 出的基线自己不能 round-trip,都是可修的: - **快照里的 Django 序列**:tablesFilter 只过滤表、不过滤它们的序列。 已从 0000_snapshot.json 清掉。 - **bigint 上限精度**:pull 生成的 `maxValue: 9223372036854775807` 是 JS number 字面量,round-trip 成 ...776000,每次 generate 都多出 10 条 ALTER COLUMN。改成字符串。 - **表达式索引的 opclass**:problem_tag_name_ci_unique 在快照里带 opclass,drizzle 自己序列化不出来,导致每次 drop + recreate。已去掉。 改完 `generate` 是干净的 no-op。 两个改不掉、只能绕的写进了 CLAUDE.md:索引 `.desc()` 生成 SQL 时会被丢 (单列索引不写方向即可,Postgres 用 Index Scan Backward 服务 ORDER BY DESC,实测同样 0.08ms);migrator 把所有语句包一个事务, CREATE INDEX CONCURRENTLY 跑不了。 最容易吃亏的是 drizzle 没有 --fake-initial:对已有数据的库直接 migrate 会从 0000 跑起、撞表回滚,**而且 exit 1 但一个错误都不打印**。 ## 2. 0001 提交列表索引 `WHERE contest_id IS NULL ORDER BY create_time DESC LIMIT n` 用不上现有的 contest_create_time_idx (contest_id, create_time DESC) —— Postgres 不把 `IS NULL` 当成能吃掉首列、从而继承第二列有序性的等值条件。把 enable_seqscan / enable_bitmapscan 全关掉逼它用也不肯,宁可走单列 contest_id 索引再全量排序。于是每翻一页都 Parallel Seq Scan 扫完整张表。 换成部分索引后谓词由索引自己保证,索引序就是查询要的排序序。生产快照 (12.3 万条提交)实测首页取 10 行:61.8ms / 读 18936 blocks → 0.22ms / 读 34 blocks。端到端 94ms → 6ms。 真正要命的不是单次 61ms,是每个请求都要把 169MB 的表刷一遍 shared_buffers —— 一节课几十个学生同时开提交列表,磁盘和缓存直接被打穿。 ## 3. 0002 删掉 Django 残留 确认旧 Django 后端不再使用、也不再作为回滚路径。删前核实过:没有任何 OJ2 保留的表引用这 7 张,3 条外键全在它们内部(所以不用 CASCADE,真有 漏网的会报错而不是被悄悄级联掉);5 个序列都由各自的表 owned,随 DROP TABLE 一并消失;数据全是 Django 自身元数据。tablesFilter 随之移除。 **回滚路径就此作废** —— CLAUDE.md 开头和 runbook 的「回滚保证」「七、回滚」 都改了。这条迁移已在本机 dev 库和生产快照副本上跑通,**生产库尚未执行**。 ## 4. migrate 接进部署与 CI deploy.sh 在构建之后、起栈之前跑 `oj2-api migrate`,失败就中止部署(旧 容器原样还在跑)。CI 走的也是 deploy.sh,所以不用给 GitHub 配数据库凭据, 也不用把生产库对外开放。 迁移文件**不内嵌进二进制**,随镜像装在 /usr/local/share/oj2/migrations。 这样 drizzle 的 migrate() 能原样用 —— 靠 _journal.json 自动发现,新增迁移 不用改任何代码,和 Django 扫 migrations/ 是一回事。内嵌就得为每条迁移 手写一行 import,那是迟早会漏的账。(CLAUDE.md 里「单二进制不能读文件」 那条讲的是 node_modules 和 import.meta.dir 推路径,按显式绝对路径读一个 数据目录不在此列。) 三道闸门,都是写完测出来才补上的: - **破坏性迁移拦截**:DROP TABLE / DROP COLUMN / DROP SCHEMA / ALTER COLUMN ... TYPE / TRUNCATE 命中就退出 4,需要 `OJ2_ALLOW_DESTRUCTIVE=1` 显式放行。DROP INDEX / DROP CONSTRAINT 不算, 拦了只会让人习惯性带上放行开关。扫描前先剥注释,避免误报。 - **基线缺失**:退出 3 并直接打印该敲的 SQL。注意判的是 `max(created_at) < 0` 而不是「表不存在」—— 表存在而为空(上次迁移失败 留下的)同样是没基线。 - **迁移目录读不到**:这是最可能犯的错(Dockerfile 漏拷),原本是 drizzle 的堆栈,现在直接说该检查哪一行。 另外发现 0000_crazy_gateway.sql 是 pull 的产物,**整份被 /* */ 包着, 可执行语句 0 条**,所以这个库根本不能靠迁移自举建表。原先写的「空库就从 0000 建」跑起来会炸在一个和真实原因毫不相干的 unterminated /* comment 上。 现在如实说明:结构只能来自 docs/specs/schema.sql 或生产 dump。 验证:镜像内编译(ARTIFACTS=build)出的真实镜像跑完五种场景 —— 拦截、 放行、幂等、无基线、漏拷目录,全部符合预期;dev 形态同样五种场景全过。 tsc / 路由遮蔽 / deploy.sh 语法 / generate no-op 都通过。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,9 +25,26 @@
|
||||
|
||||
### 两个原本以为要做、实测不需要做的事
|
||||
|
||||
**1. 不需要执行任何 DDL。** 把生产 dump 的表结构和新后端在用的库逐列对比:
|
||||
**1. 切换本身不需要执行任何 DDL。** 把生产 dump 的表结构和新后端在用的库逐列对比:
|
||||
两边都是 **278 列,完全一致**,没有新增、没有删除、没有新表。新后端直接跑在现有结构上。
|
||||
|
||||
> 后来追加了一个**性能索引**(提交列表默认视图从全表扫变成索引扫),并且改用
|
||||
> drizzle migration 管理,见 `apps/api/src/db/0001_add_submission_public_create_time_idx.sql`。
|
||||
> 它**不属于切换流程**,切换前后任何时候单独跑一次即可,跑早点更好,
|
||||
> **别塞进停机窗口**(12.3 万行实测建索引 74ms,但没必要占用窗口时间)。
|
||||
>
|
||||
> 对生产库第一次执行前,**必须先手插 `__drizzle_migrations` 的基线行**,否则 migrate
|
||||
> 会从 `0000` 的完整建表跑起、撞表回滚,而且**失败时不打印任何错误**。
|
||||
> 基线 SQL 和完整说明见 `CLAUDE.md` 的「改 schema 走 drizzle migration」。执行:
|
||||
>
|
||||
> ```bash
|
||||
> # 1) 先插基线行(见 CLAUDE.md)
|
||||
> # 2) 再跑
|
||||
> DATABASE_URL=<生产库> bun run db:migrate
|
||||
> ```
|
||||
>
|
||||
> 这会在生产库里新建一个 `drizzle` schema 和 `__drizzle_migrations` 表。
|
||||
|
||||
**2. 不需要重置序列。** 我在 `phase3-coverage.md` 里记过一条「切换必做:重置各表 sequence」,
|
||||
**那条是错的** —— 它来自我手工造的本地库(用显式 id 导入、没有 setval)。真实的
|
||||
pg_dumpall 备份带 30 条 `setval`,而且直接查生产快照里所有序列 vs `max(id)`,
|
||||
@@ -35,6 +52,13 @@ pg_dumpall 备份带 30 条 `setval`,而且直接查生产快照里所有序
|
||||
|
||||
### 回滚保证(已实测)
|
||||
|
||||
> ⚠️ **2026-08-26 起本节即将作废。** 旧 Django 后端确认不再使用,
|
||||
> `0002_drop_django_leftovers` 会删掉它的 7 张框架表。该迁移**尚未在生产库执行**;
|
||||
> 一旦执行,旧栈就起不来,本节和第七节描述的「停新栈起旧栈」「把 NPM 上游改回 8080」
|
||||
> 全部失效,唯一退路是从备份恢复数据库。**执行前先做一次全量备份。**
|
||||
> 下面内容保留作历史记录。
|
||||
|
||||
|
||||
新栈跑完登录、提交、判题之后,再和生产 dump 的结构比一次:**逐列一致,零差异**。
|
||||
新后端不会给旧后端留下任何它不认识的东西。
|
||||
|
||||
@@ -424,6 +448,10 @@ curl -s -o /dev/null -w '未登录进后台 %{http_code}\n' $BASE/api/admin/das
|
||||
|
||||
## 七、回滚
|
||||
|
||||
> ⚠️ **执行 `0002_drop_django_leftovers` 之后本节作废**,理由见上面「回滚保证」一节:
|
||||
> 旧 Django 后端的表被删除后旧栈起不来,退路只剩「从备份恢复数据库」,不是秒级操作。
|
||||
> 下面内容保留作历史记录。
|
||||
|
||||
**试跑之后切的**(推荐路径):NPM 里把 `xuyue.cc` 的上游从 `8090` 改回 `8080`。
|
||||
旧栈这时候还跑着,**秒级生效,什么都不用停不用起**。等确认稳定了再决定何时收掉新栈。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user