用户名筛选: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:
9
apps/api/src/db/0015_submission_filter_indexes.sql
Normal file
9
apps/api/src/db/0015_submission_filter_indexes.sql
Normal 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;
|
||||||
3738
apps/api/src/db/meta/0015_snapshot.json
Normal file
3738
apps/api/src/db/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
|||||||
"when": 1789034426259,
|
"when": 1789034426259,
|
||||||
"tag": "0014_drop_django_migrations",
|
"tag": "0014_drop_django_migrations",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789364546358,
|
||||||
|
"tag": "0015_submission_filter_indexes",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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_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`),
|
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 打头,但不带时间:一道题最近几个月没人交的话,
|
||||||
|
* 规划器照样选分页索引倒扫、边扫边滤。快照实测 1047(6 月之后没人交)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 镜像自带 contrib,pg_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 的堆
|
* 活跃榜、题目 AC 趋势。它们慢的**不是聚合本身,是为了读这四个小列把 145MB 的堆
|
||||||
|
|||||||
@@ -360,13 +360,36 @@ async function matchedUsers(username: string) {
|
|||||||
*
|
*
|
||||||
* 统计接口那边只按 user_id 筛(口径是「花名册上这个班谁做完了」,已删号的人本来
|
* 统计接口那边只按 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}%`
|
const like = `%${username}%`
|
||||||
return or(
|
const users = await db.select({ id: schema.user.id }).from(schema.user)
|
||||||
sql`${schema.submission.userId} in (select ${schema.user.id} from ${schema.user} where ${ilike(schema.user.username, like)})`,
|
.where(ilike(schema.user.username, like))
|
||||||
ilike(schema.submission.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,
|
* 游标用 `<=` 回查时同毫秒的上一页末行会重复出现在下一页页首。索引已按 (create_time DESC,
|
||||||
* id DESC) 建好,带上 id 不会多出 Sort 节点。
|
* id DESC) 建好,带上 id 不会多出 Sort 节点。
|
||||||
*
|
*
|
||||||
* 两种情况退回普通 offset:offset 为 0 时没有可跳过的行,白搭一次往返;按题号筛选时条件
|
* offset 为 0 时没有可跳过的行,直接取,省一次往返。
|
||||||
* 在 problem 表上,第一步得跟着 join、index-only 就没了——而那时结果集只剩几百条,
|
*
|
||||||
* offset 本来也不慢。
|
* **按用户名筛选另走一条路**:先把匹配的行整个圈出来(`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(
|
async function paginateSubmissionRows(
|
||||||
where: SQL | undefined,
|
where: SQL | undefined,
|
||||||
limit: number,
|
limit: number,
|
||||||
offset: number,
|
offset: number,
|
||||||
filtersNeedProblem: boolean,
|
byUsername: boolean,
|
||||||
) {
|
) {
|
||||||
const order = [desc(schema.submission.createTime), desc(schema.submission.id)] as const
|
const order = [desc(schema.submission.createTime), desc(schema.submission.id)] as const
|
||||||
const page = (cursor?: SQL) =>
|
const page = (condition: SQL | undefined) =>
|
||||||
db
|
db
|
||||||
.select(submissionListColumns)
|
.select(submissionListColumns)
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
@@ -898,10 +927,21 @@ async function paginateSubmissionRows(
|
|||||||
// 取当前用户名用。left join 不是 inner —— 已删号的学生这边没有行,
|
// 取当前用户名用。left join 不是 inner —— 已删号的学生这边没有行,
|
||||||
// inner join 会把他们的提交整条从列表里抹掉
|
// inner join 会把他们的提交整条从列表里抹掉
|
||||||
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId))
|
||||||
.where(cursor ? and(where, cursor) : where)
|
.where(condition)
|
||||||
.orderBy(...order)
|
.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
|
const [boundary] = await db
|
||||||
.select({ createTime: schema.submission.createTime, id: schema.submission.id })
|
.select({ createTime: schema.submission.createTime, id: schema.submission.id })
|
||||||
@@ -913,9 +953,10 @@ async function paginateSubmissionRows(
|
|||||||
// offset 越过了结果集尾巴,这一页本来就该是空的
|
// offset 越过了结果集尾巴,这一页本来就该是空的
|
||||||
if (!boundary) return []
|
if (!boundary) return []
|
||||||
|
|
||||||
return page(
|
return page(and(
|
||||||
|
where,
|
||||||
sql`(${schema.submission.createTime}, ${schema.submission.id}) <= (${boundary.createTime}::timestamptz, ${boundary.id}::text)`,
|
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)) {
|
if (!(await getBooleanOption("submission_list_show_all", true)) && !isAdminRole(user)) {
|
||||||
return success(c, { results: [], total: 0 } satisfies SubmissionList)
|
return success(c, { results: [], total: 0 } satisfies SubmissionList)
|
||||||
}
|
}
|
||||||
const filters = [isNull(schema.submission.contestId)]
|
|
||||||
const displayId = c.req.query("problemId")?.trim()
|
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 result = c.req.query("result")
|
||||||
const language = c.req.query("language")?.trim()
|
const language = c.req.query("language")?.trim()
|
||||||
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
|
const filters: Array<SQL | undefined> = [isNull(schema.submission.contestId)]
|
||||||
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
|
filters.push(...await Promise.all([
|
||||||
else if (username) filters.push(usernameFilter(username))
|
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 (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 (language) filters.push(eq(schema.submission.language, asFilterValue(language)))
|
||||||
if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
|
if (c.req.query("today") === "1") filters.push(sql`${schema.submission.createTime} >= ${todayStart()}`)
|
||||||
const where = and(...filters)
|
const where = and(...filters)
|
||||||
// count 不 join problem:problem 只有按题号筛选时才出现在 where 里,无条件 join 会让
|
// count 不 join problem:无条件 join 会让计划器把 count 退化成 seq scan
|
||||||
// 计划器把 count 退化成 seq scan(生产快照实测 7.5ms → 78ms)。
|
// (生产快照实测 7.5ms → 78ms)。题号已经解析成 problem_id,也用不着 join。
|
||||||
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)
|
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
totalQuery,
|
db.select({ value: count() }).from(schema.submission).where(where),
|
||||||
paginateSubmissionRows(where, limit, offset, Boolean(displayId)),
|
paginateSubmissionRows(where, limit, offset, Boolean(username)),
|
||||||
])
|
])
|
||||||
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
// 闸门只对学生自己的提交生效,所以只拿这一页里属于他自己的题目去查,一页一次查询
|
||||||
const [joinTimes, problemsetTitles] = await Promise.all([
|
const [joinTimes, problemsetTitles] = await Promise.all([
|
||||||
@@ -997,25 +1038,24 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
|
|||||||
const contest = c.get("contest")!
|
const contest = c.get("contest")!
|
||||||
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
const limit = queryInteger(c.req.query("limit"), 10, { min: 1, max: 250 })
|
||||||
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
const offset = queryInteger(c.req.query("offset"), 0, { min: 0 })
|
||||||
const filters = [eq(schema.submission.contestId, contest.id)]
|
|
||||||
const user = c.get("user")
|
const user = c.get("user")
|
||||||
const displayId = c.req.query("problemId")?.trim()
|
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 result = c.req.query("result")
|
||||||
if (displayId) filters.push(sql`lower(${schema.problem.displayId}) = lower(${displayId})`)
|
const filters: Array<SQL | undefined> = [eq(schema.submission.contestId, contest.id)]
|
||||||
if (c.req.query("myself") === "1" && user) filters.push(eq(schema.submission.userId, user.id))
|
filters.push(...await Promise.all([
|
||||||
else if (username) filters.push(usernameFilter(username))
|
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 (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}`)
|
if (contestStatus(contest) !== "1") filters.push(sql`${schema.submission.createTime} >= ${contest.startTime}`)
|
||||||
const where = and(...filters)
|
const where = and(...filters)
|
||||||
// count 不 join problem:problem 只有按题号筛选时才出现在 where 里,无条件 join 会让
|
// 一场比赛最多一两千条提交,按 contest_create_time_idx 定位之后怎么滤都不贵,
|
||||||
// 计划器把 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)
|
|
||||||
const [totalRows, rows] = await Promise.all([
|
const [totalRows, rows] = await Promise.all([
|
||||||
totalQuery,
|
db.select({ value: count() }).from(schema.submission).where(where),
|
||||||
db.select(submissionListColumns).from(schema.submission)
|
db.select(submissionListColumns).from(schema.submission)
|
||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id))
|
||||||
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)).where(where)
|
.leftJoin(schema.user, eq(schema.user.id, schema.submission.userId)).where(where)
|
||||||
|
|||||||
Reference in New Issue
Block a user