翻页用的是 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")),
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
submissionListSchema,
|
||||
submissionStatisticsSchema,
|
||||
} from "@oj2/contract"
|
||||
import { and, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
|
||||
import { and, count, desc, eq, ilike, inArray, isNull, sql, type SQL } from "drizzle-orm"
|
||||
import { Hono } from "hono"
|
||||
|
||||
import {
|
||||
@@ -447,6 +447,59 @@ async function submissionDetail(id: string, user: AuthUser) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交列表取数据。深翻页不走 `LIMIT n OFFSET m`——Postgres 对 OFFSET 没有捷径,前 m 行
|
||||
* 必须真的产出再丢掉,而丢弃发生在 join 之后,每一行都白回了一次表。生产快照(10.4 万条
|
||||
* 公开提交)上最后一页实测 1258ms、碰了 95347 个 buffer。而且越早的页越慢:平时没人翻,
|
||||
* 那些数据页从来不在 shared_buffers 里,全是冷读。
|
||||
*
|
||||
* 拆成两步就便宜得多:
|
||||
* 1. 只 select create_time / id —— 正好是 submission_public_create_time_id_idx 的两列,
|
||||
* 跳过 m 行走 Index Only Scan,Heap Fetches 为 0,纯在索引页里数数;
|
||||
* 2. 拿这一行当游标做 keyset 回查,只回表取 limit 行。
|
||||
* 同一页实测降到约 9ms、885 个 buffer。代价变成 O(m) 个**索引条目**而不是堆页,按快照里
|
||||
* 的索引密度外推,涨到 100 万条时最深一页仍在几十毫秒量级。
|
||||
*
|
||||
* 排序必须带 id:create_time 只有毫秒精度(`new Date().toISOString()`),光靠它不是全序,
|
||||
* 游标用 `<=` 回查时同毫秒的上一页末行会重复出现在下一页页首。索引已按 (create_time DESC,
|
||||
* id DESC) 建好,带上 id 不会多出 Sort 节点。
|
||||
*
|
||||
* 两种情况退回普通 offset:offset 为 0 时没有可跳过的行,白搭一次往返;按题号筛选时条件
|
||||
* 在 problem 表上,第一步得跟着 join、index-only 就没了——而那时结果集只剩几百条,
|
||||
* offset 本来也不慢。
|
||||
*/
|
||||
async function paginateSubmissionRows(
|
||||
where: SQL | undefined,
|
||||
limit: number,
|
||||
offset: number,
|
||||
filtersNeedProblem: boolean,
|
||||
) {
|
||||
const order = [desc(schema.submission.createTime), desc(schema.submission.id)] as const
|
||||
const page = (cursor?: SQL) =>
|
||||
db
|
||||
.select(submissionListColumns)
|
||||
.from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||
.where(cursor ? and(where, cursor) : where)
|
||||
.orderBy(...order)
|
||||
|
||||
if (offset === 0 || filtersNeedProblem) return page().limit(limit).offset(offset)
|
||||
|
||||
const [boundary] = await db
|
||||
.select({ createTime: schema.submission.createTime, id: schema.submission.id })
|
||||
.from(schema.submission)
|
||||
.where(where)
|
||||
.orderBy(...order)
|
||||
.limit(1)
|
||||
.offset(offset)
|
||||
// offset 越过了结果集尾巴,这一页本来就该是空的
|
||||
if (!boundary) return []
|
||||
|
||||
return page(
|
||||
sql`(${schema.submission.createTime}, ${schema.submission.id}) <= (${boundary.createTime}::timestamptz, ${boundary.id}::text)`,
|
||||
).limit(limit)
|
||||
}
|
||||
|
||||
submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||
@@ -476,9 +529,7 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
||||
: db.select({ value: count() }).from(schema.submission).where(where)
|
||||
const [totalRows, rows] = await Promise.all([
|
||||
totalQuery,
|
||||
db.select(submissionListColumns).from(schema.submission)
|
||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
|
||||
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset),
|
||||
paginateSubmissionRows(where, limit, offset, Boolean(displayId)),
|
||||
])
|
||||
return success(c, submissionListSchema.parse({
|
||||
results: rows.map(({ submission, problem }) => submissionListItemSchema.parse({
|
||||
|
||||
Reference in New Issue
Block a user