fix(数据库): 索引方向被 .op() 吞掉,schema.ts 和真实库对不上

根因不是 .desc(),是 opclass。drizzle-kit 的 CreatePgIndexConvertor 里那个
三元一旦走进 opclass 分支就回不到方向分支,而 pull 给每一列都挂了 .op(...),
于是"写了 .desc() 却生成不出 DESC"每次都会重演。实测确认:不写 .op() 时
多列混合方向能正常生成,所以原先"多列混合方向的索引别指望 generate"这条
自我限制不成立。

announcement_list_idx 和 contest_create_time_idx 两条真实索引踩在这上面:
生产库里它们带 DESC(schema.sql:1636、:1685),schema.ts 却会生成成全 ASC。
contest_create_time_idx 的 opclass 还串了位(contest_id 标 timestamptz_ops、
create_time 标 int4_ops),那条 SQL 真拿去执行 Postgres 会直接拒绝。

改快照而不是生成迁移:generate 想 drop + recreate,但重建出来的和生产库里
那两条完全等价(DESC 默认就是 NULLS FIRST),执行它只是在 12.3 万行的表上
白挨一次锁。手法和当初处理 problem_tag_name_ci_unique 的 opclass 一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 09:53:05 -06:00
parent 1f9f33aa3b
commit eabb61189c
3 changed files with 33 additions and 18 deletions

View File

@@ -176,11 +176,27 @@ schema下面三处已经修过了别让它们回潮
- **表达式索引的 opclass**`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle - **表达式索引的 opclass**`problem_tag_name_ci_unique` 在快照里带 `opclass`,但 drizzle
自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。 自己序列化不出来,导致每次都 drop + recreate。已从快照里去掉。
**还有两个改不掉的、写代码时要绕开的** **还有两个写代码时要绕开的**
- **索引方向会被丢**`.desc()` 在生成 SQL 时消失,但快照里记成 `asc: false`,两边对不上, - **`.op()` 会吞掉索引方向**:真正的根因不是 `.desc()`,是 opclass。drizzle-kit 的
下次 pull 就是假 diff。单列索引不写方向就行Postgres 用 Index Scan Backward 服务 `CreatePgIndexConvertor` 里那个三元一旦走进 opclass 分支就回不到方向分支:
`ORDER BY ... DESC`,代价一样)。**多列混合方向的索引别指望 generate**,得手写。 `${it.opclass ? ` ${it.opclass}` : it.asc ? "" : " DESC"}`。而 `drizzle-kit pull`
给**每一列**都挂了 `.op(...)`,所以本仓库里"写了 `.desc()` 却生成不出 DESC"每次都会重演。
**要方向就别写 `.op()`。** 不写没有任何代价——`int4_ops` / `timestamptz_ops` 本来就是
这些类型的默认 opclass写了等于没写。实测drizzle-kit 0.31.10,探针索引跑过 generate
| schema.ts | 生成的 SQL |
|---|---|
| `.desc().nullsFirst().op("timestamptz_ops")` | `"create_time" timestamptz_ops` ← 方向丢了 |
| `.desc().nullsFirst()` | `"create_time" DESC NULLS FIRST` ✅ |
| `.desc()` | `"create_time" DESC NULLS LAST` ✅ |
所以**多列混合方向的索引可以正常 generate**,不必手写。
假 diff 的机制也要理解对:带 `.op()` 时快照记的是 `asc: false`SQL 建出来却是 ASC
**分歧在快照和真实库之间**,不在快照和 schema.ts 之间——所以再跑 generate 是干净的,
要等到下次 pull 才炸出来。这是当初难定位的原因。
- **`CREATE INDEX CONCURRENTLY` 跑不了**migrator 把所有语句包在一个事务里。大表加索引 - **`CREATE INDEX CONCURRENTLY` 跑不了**migrator 把所有语句包在一个事务里。大表加索引
要是不能接受锁写窗口,只能绕开 migration 手工执行。参考量级12.3 万行的部分索引, 要是不能接受锁写窗口,只能绕开 migration 手工执行。参考量级12.3 万行的部分索引,
普通 `CREATE INDEX` 只锁 74ms一般不用纠结。 普通 `CREATE INDEX` 只锁 74ms一般不用纠结。

View File

@@ -489,22 +489,19 @@
"expression": "visible", "expression": "visible",
"isExpression": false, "isExpression": false,
"asc": true, "asc": true,
"nulls": "last", "nulls": "last"
"opclass": "bool_ops"
}, },
{ {
"expression": "top", "expression": "top",
"isExpression": false, "isExpression": false,
"asc": false, "asc": false,
"nulls": "first", "nulls": "first"
"opclass": "bool_ops"
}, },
{ {
"expression": "create_time", "expression": "create_time",
"isExpression": false, "isExpression": false,
"asc": false, "asc": false,
"nulls": "first", "nulls": "first"
"opclass": "bool_ops"
} }
], ],
"isUnique": false, "isUnique": false,
@@ -2915,15 +2912,13 @@
"expression": "contest_id", "expression": "contest_id",
"isExpression": false, "isExpression": false,
"asc": true, "asc": true,
"nulls": "last", "nulls": "last"
"opclass": "timestamptz_ops"
}, },
{ {
"expression": "create_time", "expression": "create_time",
"isExpression": false, "isExpression": false,
"asc": false, "asc": false,
"nulls": "first", "nulls": "first"
"opclass": "int4_ops"
} }
], ],
"isUnique": false, "isUnique": false,

View File

@@ -52,7 +52,9 @@ export const announcement = pgTable("announcement", {
top: boolean().notNull(), top: boolean().notNull(),
}, (table) => [ }, (table) => [
index("announcement_created_by_id_359ccf50").using("btree", table.createdById.asc().nullsLast().op("int4_ops")), index("announcement_created_by_id_359ccf50").using("btree", table.createdById.asc().nullsLast().op("int4_ops")),
index("announcement_list_idx").using("btree", table.visible.asc().nullsLast().op("bool_ops"), table.top.desc().nullsFirst().op("bool_ops"), table.createTime.desc().nullsFirst().op("bool_ops")), // 不写 .op()opclass 会吞掉方向(见 CLAUDE.md。生产库是 (visible, top DESC, create_time DESC)
// 写了 .op() 的话 generate 出来的是全 ASCschema.ts 就和真实库对不上了。
index("announcement_list_idx").using("btree", table.visible.asc().nullsLast(), table.top.desc().nullsFirst(), table.createTime.desc().nullsFirst()),
foreignKey({ foreignKey({
columns: [table.createdById], columns: [table.createdById],
foreignColumns: [user.id], foreignColumns: [user.id],
@@ -466,7 +468,9 @@ export const submission = pgTable("submission", {
username: text().notNull(), username: text().notNull(),
ip: text(), ip: text(),
}, (table) => [ }, (table) => [
index("contest_create_time_idx").using("btree", table.contestId.asc().nullsLast().op("timestamptz_ops"), table.createTime.desc().nullsFirst().op("int4_ops")), // 同上,不写 .op()。原先 pull 出来的 opclass 还串了位contest_id 标成 timestamptz_ops、
// create_time 标成 int4_ops那条 SQL 真拿去执行 Postgres 会直接拒绝。
index("contest_create_time_idx").using("btree", table.contestId.asc().nullsLast(), table.createTime.desc().nullsFirst()),
// 提交列表默认视图WHERE contest_id IS NULL ORDER BY create_time DESC专用。 // 提交列表默认视图WHERE contest_id IS NULL ORDER BY create_time DESC专用。
// 上面的 contest_create_time_idx 看着能覆盖,但 Postgres 不把 `contest_id IS NULL` // 上面的 contest_create_time_idx 看着能覆盖,但 Postgres 不把 `contest_id IS NULL`
// 当成能吃掉首列、从而继承第二列有序性的等值条件——把 seqscan/bitmapscan 全关掉逼它 // 当成能吃掉首列、从而继承第二列有序性的等值条件——把 seqscan/bitmapscan 全关掉逼它
@@ -474,8 +478,8 @@ export const submission = pgTable("submission", {
// 扫完整张表 + top-N 排序。改用部分索引后谓词由索引本身保证,排序序就是索引序。 // 扫完整张表 + top-N 排序。改用部分索引后谓词由索引本身保证,排序序就是索引序。
// 生产快照12.3 万条提交实测61.8ms / 18936 blocks → 0.22ms / 34 blocks。 // 生产快照12.3 万条提交实测61.8ms / 18936 blocks → 0.22ms / 34 blocks。
// 这个索引不在 Django 的 migration 里,是 OJ2 单独加的,见 src/db/0001_naive_agent_zero.sql。 // 这个索引不在 Django 的 migration 里,是 OJ2 单独加的,见 src/db/0001_naive_agent_zero.sql。
// 不写 .desc()drizzle-kit 生成 SQL 时会把方向丢掉,写了会让快照(记 asc:false和实际 // 不写 .desc()这条带 .op(),而 .op() 会吞掉方向(见 CLAUDE.md——写了会让快照
// 建出来的索引ASC对不上,下次 pull 就产生假 diff。单列索引无所谓方向Postgres 用 // (记 asc:false和实际建出来的索引ASC对不上。单列索引本来也无所谓方向Postgres 用
// Index Scan Backward 服务 ORDER BY ... DESC实测同样是 0.08ms。 // Index Scan Backward 服务 ORDER BY ... DESC实测同样是 0.08ms。
index("submission_public_create_time_idx").using("btree", table.createTime.op("timestamptz_ops")).where(sql`${table.contestId} is null`), index("submission_public_create_time_idx").using("btree", table.createTime.op("timestamptz_ops")).where(sql`${table.contestId} is null`),
index("problem_user_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")), index("problem_user_idx").using("btree", table.problemId.asc().nullsLast().op("int4_ops"), table.userId.asc().nullsLast().op("int4_ops")),