perf(提交列表): 越早的页越慢,最后一页要等 1.2 秒
Some checks failed
Deploy / deploy (push) Has been cancelled

翻页用的是 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:
2026-08-27 11:13:38 -06:00
parent 8861393529
commit 6c2381ad2c
5 changed files with 3917 additions and 10 deletions

View File

@@ -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;

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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 sort5.2MB 落盘),比不建还糟。
// 两列同为 ASC 时整条索引反着扫就是精确的反序,所以反而是能用的那一种。
// 这两列都 NOT NULLnulls 位置在语义上无所谓,纯粹是规划器的匹配规则。
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")),