perf(提交列表): 按用户名、题号筛选走索引,不再扫全表
Some checks failed
Deploy / deploy (push) Has been cancelled

用户名筛选:user_id 先查成字面列表,不再把子查询夹在 OR 里(那样整条 OR 不可索引),
加 trigram 索引接住 ilike '%x%';翻页先圈出匹配行再排序取页,避开规划器顺着时间索引
倒扫、边扫边滤的计划。快照上查一个班 70~107ms → 5~14ms;匹配 9.5 万条的年级前缀
从 34ms 变成 70~90ms,实际不这么查。

题号筛选:先解析成 problem.id,加 (problem_id, create_time, id) 部分索引。老题和
不存在的题号不再倒扫大半张表(34~67ms → 4ms),题号筛选也能走游标深翻页,
count 不再 join problem。

迁移 0015 装 pg_trgm(官方镜像自带 contrib,trusted 扩展)。39 个筛选组合的响应
与改动前逐字节一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xg91q3JsunDE7EYoi9G3i2
This commit is contained in:
2026-09-13 23:47:24 -06:00
parent fe4fc46243
commit 6e63866cc9
5 changed files with 3850 additions and 39 deletions

View File

@@ -0,0 +1,9 @@
-- 提交列表「题号」「用户名」两个筛选的索引,用法和实测数据见 schema.ts 里两条索引的注释。
--
-- pg_trgm 是 contrib 模块要先装扩展drizzle-kit generate 不会替你写这一句。
-- 官方 postgres:16-alpine 镜像自带 contrib且 pg_trgm 是 trusted 扩展PG 13 起),
-- 库 owner 就能装。换成不带 contrib 的 Postgres 时这里会失败、部署停在迁移这步。
-- CREATE EXTENSION 可以在事务里执行,不需要 no-transaction 标记。
CREATE EXTENSION IF NOT EXISTS pg_trgm;--> statement-breakpoint
CREATE INDEX "submission_public_problem_time_idx" ON "submission" USING btree ("problem_id","create_time","id") WHERE "submission"."contest_id" is null;--> statement-breakpoint
CREATE INDEX "submission_public_username_trgm_idx" ON "submission" USING gin ("username" gin_trgm_ops) WHERE "submission"."contest_id" is null;

File diff suppressed because it is too large Load Diff

View File

@@ -106,6 +106,13 @@
"when": 1789034426259,
"tag": "0014_drop_django_migrations",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1789364546358,
"tag": "0015_submission_filter_indexes",
"breakpoints": true
}
]
}

View File

@@ -545,6 +545,23 @@ export const submission = pgTable("submission", {
*/
index("submission_language_time_idx").using("btree", table.language.asc().nullsLast(), table.createTime.asc().nullsLast()).where(sql`${table.contestId} is null`),
index("submission_result_time_idx").using("btree", table.result.asc().nullsLast(), table.createTime.asc().nullsLast()).where(sql`${table.contestId} is null`),
/**
* 提交列表的「题号」筛选。路由先把题号解析成 problem.id见 routes/submission.ts 的
* problemFilter这条索引才用得上等值定位到一道题剩下两列正好是翻页的全序
* 深翻页的游标也照走。列方向同上面几条,全 ASC 靠 Backward 扫。
*
* problem_user_idx 以 problem_id 打头,但不带时间:一道题最近几个月没人交的话,
* 规划器照样选分页索引倒扫、边扫边滤。快照实测 10476 月之后没人交17ms 倒扫
* 2 万行、3017最后一次在 2024 年59ms 倒扫 8.2 万行、不存在的题号扫完全表。
*/
index("submission_public_problem_time_idx").using("btree", table.problemId.asc().nullsLast(), table.createTime.asc().nullsLast(), table.id.asc().nullsLast()).where(sql`${table.contestId} is null`),
/**
* 提交列表的「用户名」筛选是 `ilike '%x%'`btree 帮不上,只有 trigram 能索引中缀匹配。
* 扩展在迁移 0015 里装(官方 postgres 镜像自带 contribpg_trgm 是 trusted 扩展)。
* 3MB。模式不足 3 个字符时 trigram 抽不出东西,照旧全表扫——那种前缀匹配大半张表,
* 扫表本来就是对的计划。
*/
index("submission_public_username_trgm_idx").using("gin", table.username.op("gin_trgm_ops")).where(sql`${table.contestId} is null`),
/**
* 覆盖索引,专门给「在全部公开提交上做聚合」那几个接口用:教师统计不填班级、
* 活跃榜、题目 AC 趋势。它们慢的**不是聚合本身,是为了读这四个小列把 145MB 的堆

View File

@@ -360,13 +360,36 @@ async function matchedUsers(username: string) {
*
* 统计接口那边只按 user_id 筛(口径是「花名册上这个班谁做完了」,已删号的人本来
* 就不在花名册里);这两条是公开列表,不该因为改名或删号少给记录,所以取并集。
*
* 账号那一支**先查出 id 再拼成字面列表**,不写成 `user_id in (子查询)`:子查询夹在 OR
* 里会被做成 hashed SubPlan整条 OR 就不可索引,加了 trigram 索引照样全表扫。拆开之后
* 两支各走各的索引submission_public_metrics_idx + submission_public_username_trgm_idx
* 快照实测 count 65ms → 0.6ms。`ks2` 这种匹配上千个账号的宽前缀退回扫表30~50ms
* 和原来持平。
*/
function usernameFilter(username: string) {
async function usernameFilter(username: string) {
const like = `%${username}%`
return or(
sql`${schema.submission.userId} in (select ${schema.user.id} from ${schema.user} where ${ilike(schema.user.username, like)})`,
ilike(schema.submission.username, like),
)!
const users = await db.select({ id: schema.user.id }).from(schema.user)
.where(ilike(schema.user.username, like))
const frozen = ilike(schema.submission.username, like)
return users.length ? or(inArray(schema.submission.userId, users.map((row) => row.id)), frozen)! : frozen
}
/**
* 两条提交列表的题号筛选:先把题号解析成 problem.id再按 `submission.problem_id` 筛。
* 原来是 join problem 之后比 `lower(problem._id)`,条件落在 problem 表上,规划器只能
* 顺着时间索引倒扫、逐行回表比对,走不上 submission_public_problem_time_idx。
*
* 公开列表只认公开题、比赛列表只认本场的题:题号只在这个范围内唯一(比赛题的 `_id`
* 和公开题撞号是常态而公开提交从不指向比赛题快照核过0 条)。
* 查无此题时留恒假条件,少推一个 filter 就成了「不筛」。
*/
async function problemFilter(displayId: string, contestId: number | null) {
const problems = await db.select({ id: schema.problem.id }).from(schema.problem).where(and(
sql`lower(${schema.problem.displayId}) = lower(${displayId})`,
contestId === null ? isNull(schema.problem.contestId) : eq(schema.problem.contestId, contestId),
))
return problems.length ? inArray(schema.submission.problemId, problems.map((row) => row.id)) : sql`false`
}
/**
@@ -879,18 +902,24 @@ async function submissionDetail(id: string, user: AuthUser) {
* 游标用 `<=` 回查时同毫秒的上一页末行会重复出现在下一页页首。索引已按 (create_time DESC,
* id DESC) 建好,带上 id 不会多出 Sort 节点。
*
* 两种情况退回普通 offsetoffset 为 0 时没有可跳过的行,白搭一次往返;按题号筛选时条件
* 在 problem 表上,第一步得跟着 join、index-only 就没了——而那时结果集只剩几百条,
* offset 本来也不慢。
* offset 为 0 时没有可跳过的行,直接取,省一次往返。
*
* **按用户名筛选另走一条路**:先把匹配的行整个圈出来(`materialized` 挡住规划器),
* 在圈里排序取页,再按主键回表。不圈的话规划器一见 `ORDER BY ... LIMIT` 就选时间索引
* 倒扫、边扫边滤——它按平均密度估一个班的提交散布在全表,实际上一个班的提交扎堆在它
* 上课的那一两年早就毕业的班要倒扫大半张表。快照实测第一页ks248 66ms → 0.75ms、
* ks225 142ms → 2.7ms、ks212 翻到 1000 条 79ms → 3.6ms。圈的代价和匹配行数成正比,
* 最宽的 `ks2`9.5 万行)要 65ms和同一请求里 count 扫表的量级一样,不另外拖慢响应。
* 游标那条路帮不了它:第一步游标定位本身就是同一个倒扫。
*/
async function paginateSubmissionRows(
where: SQL | undefined,
limit: number,
offset: number,
filtersNeedProblem: boolean,
byUsername: boolean,
) {
const order = [desc(schema.submission.createTime), desc(schema.submission.id)] as const
const page = (cursor?: SQL) =>
const page = (condition: SQL | undefined) =>
db
.select(submissionListColumns)
.from(schema.submission)
@@ -898,10 +927,21 @@ async function paginateSubmissionRows(
// 取当前用户名用。left join 不是 inner —— 已删号的学生这边没有行,
// inner join 会把他们的提交整条从列表里抹掉
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
.where(cursor ? and(where, cursor) : where)
.where(condition)
.orderBy(...order)
.limit(limit)
if (offset === 0 || filtersNeedProblem) return page().limit(limit).offset(offset)
if (byUsername) {
const matched = db
.select({ id: schema.submission.id, createTime: schema.submission.createTime })
.from(schema.submission)
.where(where)
return page(sql`${schema.submission.id} in (
with matched as materialized ${matched}
select id from matched order by create_time desc, id desc limit ${limit} offset ${offset}
)`)
}
if (offset === 0) return page(where)
const [boundary] = await db
.select({ createTime: schema.submission.createTime, id: schema.submission.id })
@@ -913,9 +953,10 @@ async function paginateSubmissionRows(
// offset 越过了结果集尾巴,这一页本来就该是空的
if (!boundary) return []
return page(
return page(and(
where,
sql`(${schema.submission.createTime}, ${schema.submission.id}) <= (${boundary.createTime}::timestamptz, ${boundary.id}::text)`,
).limit(limit)
))
}
/**
@@ -940,27 +981,27 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
return success(c, { results: [], total: 0 } satisfies SubmissionList)
}
const filters = [isNull(schema.submission.contestId)]
const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim()
const myself = c.req.query("myself") === "1" ? user : null
// 「只看自己」盖过用户名
const username = myself ? undefined : c.req.query("username")?.trim()
const result = c.req.query("result")
const language = c.req.query("language")?.trim()
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
else if (username) filters.push(usernameFilter(username))
const filters: Array<SQL | undefined> = [isNull(schema.submission.contestId)]
filters.push(...await Promise.all([
displayId ? problemFilter(displayId, null) : undefined,
username ? usernameFilter(username) : undefined,
]))
if (myself) filters.push(eq(schema.submission.userId, myself.id))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (language) filters.push(eq(schema.submission.language, asFilterValue(language)))
if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
// 计划器把 count 退化成 seq scan(生产快照实测 7.5ms → 78ms
const totalQuery = displayId
? db.select({ value: count() }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
: db.select({ value: count() }).from(schema.submission).where(where)
// count 不 join problem无条件 join 会让计划器把 count 退化成 seq scan
// (生产快照实测 7.5ms → 78ms。题号已经解析成 problem_id也用不着 join
const [totalRows, rows] = await Promise.all([
totalQuery,
paginateSubmissionRows(where, limit, offset, Boolean(displayId)),
db.select({ value: count() }).from(schema.submission).where(where),
paginateSubmissionRows(where, limit, offset, Boolean(username)),
])
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
const [joinTimes, problemsetTitles] = await Promise.all([
@@ -997,25 +1038,24 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
const contest = c.get("contest")!
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
const filters = [eq(schema.submission.contestId, contest.id)]
const user = c.get("user")
const displayId = c.req.query("problemId")?.trim()
const username = c.req.query("username")?.trim()
const myself = c.req.query("myself") === "1" ? user : null
const username = myself ? undefined : c.req.query("username")?.trim()
const result = c.req.query("result")
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
else if (username) filters.push(usernameFilter(username))
const filters: Array<SQL | undefined> = [eq(schema.submission.contestId, contest.id)]
filters.push(...await Promise.all([
displayId ? problemFilter(displayId, contest.id) : undefined,
username ? usernameFilter(username) : undefined,
]))
if (myself) filters.push(eq(schema.submission.userId, myself.id))
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, asFilterValue(Number(result))))
if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`)
const where = and(...filters)
// count 不 join problemproblem 只有按题号筛选时才出现在 where 里,无条件 join 会让
// 计划器把 count 退化成 seq scan生产快照实测 7.5ms → 78ms
const totalQuery = displayId
? db.select({ value: count() }).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
: db.select({ value: count() }).from(schema.submission).where(where)
// 一场比赛最多一两千条提交,按 contest_create_time_idx 定位之后怎么滤都不贵,
// 所以不像公开列表那样分游标 / 圈选两条路
const [totalRows, rows] = await Promise.all([
totalQuery,
db.select({ value: count() }).from(schema.submission).where(where),
db.select(submissionListColumns).from(schema.submission)
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)).where(where)