翻页用的是 LIMIT n OFFSET m,而 Postgres 对 OFFSET 没有捷径:前 m 行必须真的 产出再丢掉,丢弃又发生在 join 之后,每一行都白回了一次表。生产快照(10.4 万条 公开提交)实测最后一页 1258ms、碰了 95347 个 buffer。最早那几页平时没人翻, 数据页从来不在缓存里,全是冷读,所以感受上比新的几页慢得多。 改成两步:先只 select create_time / id 数到第 m 行拿游标——这两列正好是部分索引 的全部内容,跳过 m 行走 Index Only Scan,Heap Fetches 为 0,纯在索引页里数数; 再拿这一行做 keyset 回查,只回表取 limit 行。同一页降到约 9ms、885 个 buffer, 端到端 HTTP 10.7ms。代价变成 O(m) 个索引条目而不是堆页,按快照密度外推, 涨到 100 万条时最深一页仍在几十毫秒量级。 接口签名和前端都没动,页码跳转照旧。offset 为 0、以及按题号筛选时(条件在 problem 表上,第一步得跟着 join,index-only 就没了)退回普通 offset。 部分索引从 (create_time) 换成 (create_time, id):create_time 由 new Date().toISOString() 生成,只有毫秒精度,不是全序,游标用 <= 回查时同毫秒的 上一页末行会重复出现在下一页页首。加 id 之后两步走同一个顺序。索引 2.3MB → 6.9MB。 索引两列都建成默认 ASC,靠 Index Only Scan Backward 反着扫。别照着 ORDER BY 写成 (create_time DESC, id DESC):ORDER BY 的 DESC 默认 NULLS FIRST,索引的 DESC 默认 NULLS LAST,规划器认为出不了序,会退化成 external merge sort(5.2MB 落盘), 比不加索引还糟。这一条已写进 schema.ts 和迁移文件的注释。 正确性:在快照上把新旧写法返回的 id 序列逐页比对,14 个 offset × 4 个 limit 共 56 组全部一致,含末尾残页与越界。 比赛提交列表暂不改:单场比赛撑死几千条,offset 不构成问题。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
-- 把公开提交列表的部分索引从 (create_time) 换成 (create_time, id)。
|
||||
--
|
||||
-- 为什么要加 id:列表分页改用「offset → 游标」两步查询(见 routes/submission.ts 的
|
||||
-- paginateSubmissionRows)。create_time 由 `new Date().toISOString()` 生成,只有毫秒
|
||||
-- 精度,同毫秒的两条提交分不出先后,游标回查时上一页末行会重复出现在下一页页首。
|
||||
-- 加上 id 让排序变成全序,两步走同一个顺序,翻页结果精确。
|
||||
--
|
||||
-- 两列都是 ASC:查询 `ORDER BY create_time DESC, id DESC` 靠 Index Only Scan Backward
|
||||
-- 反着扫这条索引。写成 (create_time DESC, id DESC) 反而用不上——ORDER BY 的 DESC 默认
|
||||
-- NULLS FIRST,索引的 DESC 默认 NULLS LAST,规划器认为出不了序,会退化成全量排序。
|
||||
--
|
||||
-- 锁窗口:这里是普通 CREATE INDEX(不是 CONCURRENTLY),建索引期间**阻塞写入**。
|
||||
-- 生产快照 12.3 万行 / 171MB 上实测不到 1 秒,且部署本来就在停机窗口里做,够用。
|
||||
-- 真要热更再拆成两条带 `oj2:no-transaction` 的迁移。
|
||||
--
|
||||
-- 先 DROP 再 CREATE 是安全的:两条语句在同一个事务里(migrate.ts 一条迁移一个事务),
|
||||
-- 中途失败会整体回滚,不会留下「老的没了、新的没建成」的中间态。
|
||||
|
||||
DROP INDEX "submission_public_create_time_idx";--> statement-breakpoint
|
||||
CREATE INDEX "submission_public_create_time_id_idx" ON "submission" USING btree ("create_time","id") WHERE "submission"."contest_id" is null;
|
||||
3818
apps/api/src/db/meta/0003_snapshot.json
Normal file
3818
apps/api/src/db/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
||||
"when": 1787740469403,
|
||||
"tag": "0002_drop_django_leftovers",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1787850608174,
|
||||
"tag": "0003_submission_public_create_time_id_idx",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -471,17 +471,28 @@ export const submission = pgTable("submission", {
|
||||
// 同上,不写 .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, id DESC)专用。
|
||||
// 上面的 contest_create_time_idx 看着能覆盖,但 Postgres 不把 `contest_id IS NULL`
|
||||
// 当成能吃掉首列、从而继承第二列有序性的等值条件——把 seqscan/bitmapscan 全关掉逼它
|
||||
// 也不肯用,只会走单列 contest_id 索引再全量排序。结果是每翻一页都 Parallel Seq Scan
|
||||
// 扫完整张表 + top-N 排序。改用部分索引后谓词由索引本身保证,排序序就是索引序。
|
||||
// 生产快照(12.3 万条提交)实测:61.8ms / 18936 blocks → 0.22ms / 34 blocks。
|
||||
// 这个索引不在 Django 的 migration 里,是 OJ2 单独加的,见 src/db/0001_naive_agent_zero.sql。
|
||||
// 不写 .desc():这条带 .op(),而 .op() 会吞掉方向(见 CLAUDE.md)——写了只会让快照
|
||||
// (记 asc:false)和实际建出来的索引(ASC)对不上。单列索引本来也无所谓方向,Postgres 用
|
||||
// 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`),
|
||||
// 这个索引不在 Django 的 migration 里,是 OJ2 单独加的,见 src/db/0001。
|
||||
//
|
||||
// 带上 id 是为了让排序成为**全序**,深翻页的游标转换(routes/submission.ts 的
|
||||
// paginateSubmissionRows)才精确。create_time 由 `new Date().toISOString()` 生成,
|
||||
// 只有毫秒精度,同毫秒的两条提交靠 create_time 分不出先后:游标用 `<=` 回查时,
|
||||
// 上一页的末行会重新出现在下一页页首。加上 id 之后两步用的是同一个全序,不会错位。
|
||||
// 索引从 2.3MB 涨到 6.9MB,快照实测第一步 5.7ms → 8.9ms,换精确值得。
|
||||
//
|
||||
// 两列都建成默认的 ASC NULLS LAST,靠 Index Only Scan **Backward** 服务
|
||||
// `ORDER BY create_time DESC, id DESC`。别照着 ORDER BY 写成 .desc():Postgres 里
|
||||
// `ORDER BY x DESC` 默认是 NULLS FIRST,而 `CREATE INDEX ... (x DESC)` 默认是
|
||||
// NULLS LAST,两边 nulls 位置对不上,规划器就当这条索引出不了序——实测建成
|
||||
// DESC NULLS LAST 之后深翻页退化成 external merge sort(5.2MB 落盘),比不建还糟。
|
||||
// 两列同为 ASC 时整条索引反着扫就是精确的反序,所以反而是能用的那一种。
|
||||
// 这两列都 NOT NULL,nulls 位置在语义上无所谓,纯粹是规划器的匹配规则。
|
||||
index("submission_public_create_time_id_idx").using("btree", table.createTime.asc().nullsLast(), table.id.asc().nullsLast()).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("submission_contest_id_775716d5").using("btree", table.contestId.asc().nullsLast().op("int4_ops")),
|
||||
index("submission_problem_id_76847b55").using("btree", table.problemId.asc().nullsLast().op("int4_ops")),
|
||||
|
||||
Reference in New Issue
Block a user