翻页用的是 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:
@@ -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