perf(提交列表): count 去掉无谓 join、只取序列化用得到的列

用户反馈「HEADER 点提交后页面空白一段时间才有数据」。拿生产快照
(12.3 万条提交 / submission 表 169MB)在本机实测,问题分两头。

**后端**(本次改的):

- count 无条件 `innerJoin(problem)`,但 problem 只有按题号筛选时才出现在
  where 里。带 join 的 count 走 seq scan 78ms,去掉 join 走索引 7.5ms。
- 行查询 `select submission.* + problem.*` 把两张表所有列都拉回来,包括
  submission.code(学生源码)、info、ip,以及 problem 的 description /
  hint / samples / answers / flowchart_data / sql_display —— 这些字段 map
  的时候一个都没用上。改成只 select 需要的列。
  canViewSubmission 的参数类型随之从整行 $inferSelect 收窄成实际用到的
  字段,完整行结构上仍然满足,详情接口调用不受影响。

公开列表和比赛列表两处是同一份代码,一起改了。

**前端**:

- list.vue 静态 import 了四个只在默认关闭的 n-modal 里用的组件,其中两个
  统计面板还只有老师看得见。光 chart.js 就 197KB,进页面前必须先下完。
  改成 defineAsyncComponent 后本路由增量下载 675KB / 59 个文件 →
  375KB / 43 个文件。
- n-data-table 没传 :loading,等接口这段时间表格就是一片空白,连转圈都
  没有 —— 这是「页面空白」最直接的观感来源。用 try/finally 包,接口抛错
  不会把转圈卡死。
- isAuthed 变化时重复拉了一次今日提交数。它不看登录态,onMounted 那次
  就够了。列表本身仍然重拉(要更新提交编号列的可点击状态),那次不是
  浪费;本想用 userStore.isFinished 把首次请求延后,但 getProfile() 一旦
  reject,isFinished 会永远停在 false,匿名用户就再也看不到列表了。

还有一个更大头的原因是索引用不上,导致每次翻页全表扫 169MB,那部分
需要加索引,走下一个提交。

验证:起真实 API 打生产快照,匿名 / 已登录 / myself / 题号筛选 /
语言+状态 / today / offset=5000 / 比赛列表全部 200,响应体大小前后一致
(2990 bytes),字段没丢。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 07:55:47 -06:00
parent a9332df6bd
commit 2ee61756b8
2 changed files with 94 additions and 38 deletions

View File

@@ -371,10 +371,12 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => {
}
})
// 参数按「实际用到的字段」声明,而不是整行 $inferSelect列表接口只 select 需要的列,
// 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。
function canViewSubmission(
user: AuthUser | null,
row: typeof schema.submission.$inferSelect,
problem: typeof schema.problem.$inferSelect,
row: { userId: number; shared: boolean },
problem: { createdById: number; shareSubmission: boolean },
contest: typeof schema.contest.$inferSelect | null,
allowShared = true,
) {
@@ -385,6 +387,31 @@ function canViewSubmission(
return problem.shareSubmission || row.shared
}
/**
* 提交列表只取序列化用得到的列。取 `submission.*` / `problem.*` 会把
* submission.code学生源码、info、ip 和 problem 的 description / input_description /
* output_description / hint / samples / answers / flowchart_data / sql_display 一并拉回来,
* 这些字段列表一个都不用,纯属白传。
*/
const submissionListColumns = {
submission: {
id: schema.submission.id,
createTime: schema.submission.createTime,
userId: schema.submission.userId,
username: schema.submission.username,
result: schema.submission.result,
language: schema.submission.language,
shared: schema.submission.shared,
statisticInfo: schema.submission.statisticInfo,
},
problem: {
displayId: schema.problem.displayId,
title: schema.problem.title,
shareSubmission: schema.problem.shareSubmission,
createdById: schema.problem.createdById,
},
} as const
async function submissionDetail(id: string, user: AuthUser) {
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest })
.from(schema.submission)
@@ -441,9 +468,15 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
if (language) filters.push(eq(schema.submission.language, 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)
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.submission).innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where),
db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
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),
])
@@ -480,9 +513,15 @@ submissionRoutes.get("/contests/:contestId/submissions", optionalAuth, requireCo
if (result !== undefined && result !== "" && Number.isInteger(Number(result))) filters.push(eq(schema.submission.result, 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)
const [totalRows, rows] = await Promise.all([
db.select({ value: count() }).from(schema.submission).innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where),
db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
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),
])