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:
93
CLAUDE.md
93
CLAUDE.md
@@ -1,17 +1,24 @@
|
||||
# CLAUDE.md
|
||||
|
||||
OJ2 是判题狗(Online Judge)的后端重写:Django 6 → Bun + TypeScript,前后端同仓。
|
||||
上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA),**两者都是回滚路径,
|
||||
完全冻结、一行都不改。**
|
||||
上一代在 `../OnlineJudge/`(Django)和 `../ojnext/`(Vue SPA),**仍然完全冻结、
|
||||
一行都不改**。
|
||||
|
||||
> **2026-08-26 起**:旧仓库零改动,没有例外——包括修 bug、包括不影响外部接口的
|
||||
> 内部小修(日志、缓存实现之类,以前允许,现已收紧)。所有后续工作,包括在旧仓库
|
||||
> 里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、是否重现了同样的问题,
|
||||
> 只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策不动旧仓库",不要顺手改掉。
|
||||
> **2026-08-26:回滚路径已废弃。** 旧 Django 后端确认不再使用,
|
||||
> `0002_drop_django_leftovers` 会删掉它的 7 张框架表(含 `django_session`、
|
||||
> `django_migrations`)。这条迁移**已在本机 dev 库和生产快照副本上跑通**,
|
||||
> **生产库尚未执行**——跑之前务必确认服务器上没有 Django 进程还活着,
|
||||
> 否则删的是它正在用的 session 表。跑完之后旧后端就起不来了,
|
||||
> 「把上游切回去」不再是可用的回滚手段,要退只能靠备份恢复。
|
||||
>
|
||||
> 所以「改 schema 要考虑回滚」这条约束**已经解除**,schema 现在归 OJ2 独占,
|
||||
> 走 drizzle migration 正常演进即可。
|
||||
|
||||
冻结的目的是「回滚那天旧站能原样起来、和新站看到同一份数据」——改 OJ2 时,接口路径
|
||||
与响应结构、数据库 schema 这些外部可观测的东西都要小心,一动回滚就不再是「把上游
|
||||
切回去」那么简单。
|
||||
> **旧仓库仍然零改动**,没有例外——包括修 bug、包括不影响外部接口的内部小修。
|
||||
> 所有后续工作,包括在旧仓库里发现的 bug,都只落在 OJ2:先确认 OJ2 是否有对应逻辑、
|
||||
> 是否重现了同样的问题,只在 OJ2 里修;旧仓库那边如实告知用户"未处理,按当前政策
|
||||
> 不动旧仓库",不要顺手改掉。冻结的理由现在只剩「留作参照、别分散精力」,
|
||||
> 不再是回滚保证。
|
||||
|
||||
设计文档:`docs/specs/2026-08-06-bun-backend-rewrite-design.md`
|
||||
切换手册:`docs/specs/phase5-cutover-runbook.md` ← 上线当天照这份走
|
||||
@@ -109,11 +116,71 @@ Drizzle schema 是从生产库 `drizzle-kit pull` 出来的,**不写迁移**
|
||||
新旧后端跑在同一套表结构上(阶段 5 演练逐列比对过,零差异),这是回滚能成立的前提 ——
|
||||
所以改 schema 前先想清楚回滚怎么办。
|
||||
|
||||
生产库的几个约定:
|
||||
### 改 schema 走 drizzle migration
|
||||
|
||||
- `raw_password` 明文列**要保留**,老师用它找学生密码。不要"顺手清理"。
|
||||
- `judge_server_heartbeat` 保留。
|
||||
- `problem.prompt` 是给未来 AI 预留的,当前没接线,不要删。
|
||||
`bun run db:generate`(造迁移文件)→ `bun run db:migrate`(按 `drizzle.__drizzle_migrations`
|
||||
增量执行),就是 Django `makemigrations` / `migrate` 的等价物。索引/结构变更走这条,
|
||||
不要再手写 SQL 往 `docs/specs/` 里塞。
|
||||
|
||||
**部署时自动执行。** `docker/deploy.sh` 在「构建镜像」之后、「起栈」之前会跑
|
||||
`oj2-api migrate`,失败就中止部署(旧容器原样还在跑)。CI 走的也是 deploy.sh,
|
||||
所以不需要给 GitHub 配数据库凭据,也不用把生产库对外开放。
|
||||
|
||||
迁移文件**不内嵌进二进制**,随镜像装在 `/usr/local/share/oj2/migrations`
|
||||
(见 `runtime.ts` 的 `migrationsDir`、Dockerfile 里那两条 COPY)。这样 drizzle 的
|
||||
`migrate()` 能原样用——它靠 `meta/_journal.json` 自动发现迁移,**新增迁移不用改任何
|
||||
代码**。内嵌就得为每条迁移手写一行 import,那是迟早会漏的账。
|
||||
|
||||
**破坏性迁移默认拦截。** 含 `DROP TABLE` / `DROP COLUMN` / `DROP SCHEMA` /
|
||||
`ALTER COLUMN ... TYPE` / `TRUNCATE` 的迁移会让部署停在迁移这步并退出 4,
|
||||
需要确认备份后显式放行:
|
||||
|
||||
```bash
|
||||
OJ2_ALLOW_DESTRUCTIVE=1 docker/deploy.sh
|
||||
```
|
||||
|
||||
`DROP INDEX` / `DROP CONSTRAINT` 不算——它们不掉数据,拦了只会让人习惯性带上放行开关。
|
||||
|
||||
**0000 跑不了,库不能靠迁移自举。** `0000_crazy_gateway.sql` 是 `drizzle-kit pull`
|
||||
的产物,整份被 `/* */` 包着,可执行语句 0 条。所以任何新库的结构都只能来自
|
||||
`docs/specs/schema.sql` 或生产 dump,然后手工做基线。`oj2-api migrate` 会检测这两种
|
||||
情况并打印具体该做什么,不会让你撞上 drizzle 那个语焉不详的报错。
|
||||
|
||||
**给一个已经存在的库做基线**:drizzle 没有 `--fake-initial`,`migrate` 见到空的
|
||||
`__drizzle_migrations` 会从 `0000` 的完整建表跑起,撞上已存在的表就整个事务回滚 ——
|
||||
**而且失败时 exit 1 但一个错误都不打印**(只有 NOTICE,实测过)。所以对已有数据的库
|
||||
第一次跑之前,先手插一行把 `0000` 标记成已执行:
|
||||
|
||||
```sql
|
||||
CREATE SCHEMA IF NOT EXISTS drizzle;
|
||||
CREATE TABLE IF NOT EXISTS drizzle.__drizzle_migrations (
|
||||
id SERIAL PRIMARY KEY, hash text NOT NULL, created_at bigint);
|
||||
INSERT INTO drizzle.__drizzle_migrations (hash, created_at)
|
||||
VALUES ('baseline-0000-faked', 1786070652521); -- = meta/_journal.json 里 0000 的 when
|
||||
```
|
||||
|
||||
migrator 只比 `created_at`,不校验 hash,所以 hash 随便填。
|
||||
|
||||
**已知的三个坑**(`meta/0000_snapshot.json` 是 `pull` 出来的,没法无损还原 Django 建的
|
||||
schema,下面三处已经修过了,别让它们回潮):
|
||||
|
||||
- ~~**快照里的 Django 序列**~~:已随 `0002_drop_django_leftovers` 删表一并解决,
|
||||
`tablesFilter` 也移除了。(历史原因:`tablesFilter` 只过滤表、不过滤它们的序列,
|
||||
于是 `generate` 会吐出 5 条 `DROP SEQUENCE`。)
|
||||
- **bigint 上限精度**:`pull` 生成的 `maxValue: 9223372036854775807` 是 JS number 字面量,
|
||||
round-trip 成 `...776000`,每次 generate 都会多出 10 条 `ALTER COLUMN ... SET MAXVALUE`。
|
||||
已改成字符串。
|
||||
- **表达式索引的 opclass**:`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle
|
||||
自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。
|
||||
|
||||
**还有两个改不掉的、写代码时要绕开的**:
|
||||
|
||||
- **索引方向会被丢**:`.desc()` 在生成 SQL 时消失,但快照里记成 `asc: false`,两边对不上,
|
||||
下次 pull 就是假 diff。单列索引不写方向就行(Postgres 用 Index Scan Backward 服务
|
||||
`ORDER BY ... DESC`,代价一样)。**多列混合方向的索引别指望 generate**,得手写。
|
||||
- **`CREATE INDEX CONCURRENTLY` 跑不了**:migrator 把所有语句包在一个事务里。大表加索引
|
||||
要是不能接受锁写窗口,只能绕开 migration 手工执行。参考量级:12.3 万行的部分索引,
|
||||
普通 `CREATE INDEX` 只锁 74ms,一般不用纠结。
|
||||
|
||||
## 部署
|
||||
|
||||
|
||||
Reference in New Issue
Block a user