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:
@@ -371,10 +371,12 @@ submissionRoutes.post("/code/format", requireAuth, async (c) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 参数按「实际用到的字段」声明,而不是整行 $inferSelect:列表接口只 select 需要的列,
|
||||||
|
// 传不进完整行。完整行在结构上满足这两个窄类型,详情接口照旧调用不受影响。
|
||||||
function canViewSubmission(
|
function canViewSubmission(
|
||||||
user: AuthUser | null,
|
user: AuthUser | null,
|
||||||
row: typeof schema.submission.$inferSelect,
|
row: { userId: number; shared: boolean },
|
||||||
problem: typeof schema.problem.$inferSelect,
|
problem: { createdById: number; shareSubmission: boolean },
|
||||||
contest: typeof schema.contest.$inferSelect | null,
|
contest: typeof schema.contest.$inferSelect | null,
|
||||||
allowShared = true,
|
allowShared = true,
|
||||||
) {
|
) {
|
||||||
@@ -385,6 +387,31 @@ function canViewSubmission(
|
|||||||
return problem.shareSubmission || row.shared
|
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) {
|
async function submissionDetail(id: string, user: AuthUser) {
|
||||||
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest })
|
const [row] = await db.select({ submission: schema.submission, problem: schema.problem, contest: schema.contest })
|
||||||
.from(schema.submission)
|
.from(schema.submission)
|
||||||
@@ -441,9 +468,15 @@ submissionRoutes.get("/submissions", optionalAuth, async (c) => {
|
|||||||
if (language) filters.push(eq(schema.submission.language, language))
|
if (language) filters.push(eq(schema.submission.language, 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 退化成 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([
|
||||||
db.select({ value: count() }).from(schema.submission).innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where),
|
totalQuery,
|
||||||
db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
|
db.select(submissionListColumns).from(schema.submission)
|
||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
|
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
|
||||||
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset),
|
.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 (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}`)
|
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 会让
|
||||||
|
// 计划器把 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([
|
||||||
db.select({ value: count() }).from(schema.submission).innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where),
|
totalQuery,
|
||||||
db.select({ submission: schema.submission, problem: schema.problem }).from(schema.submission)
|
db.select(submissionListColumns).from(schema.submission)
|
||||||
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
|
.innerJoin(schema.problem, eq(schema.submission.problemId, schema.problem.id)).where(where)
|
||||||
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset),
|
.orderBy(desc(schema.submission.createTime)).limit(limit).offset(offset),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -23,13 +23,23 @@ import { useUserStore } from "shared/store/user"
|
|||||||
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
|
||||||
import { renderTableTitle } from "utils/renders"
|
import { renderTableTitle } from "utils/renders"
|
||||||
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
|
import ButtonWithSearch from "./components/ButtonWithSearch.vue"
|
||||||
import StatisticsPanel from "shared/components/StatisticsPanel.vue"
|
|
||||||
import FlowchartStatisticsPanel from "shared/components/FlowchartStatisticsPanel.vue"
|
|
||||||
import SubmissionLink from "./components/SubmissionLink.vue"
|
import SubmissionLink from "./components/SubmissionLink.vue"
|
||||||
import SubmissionDetail from "./detail.vue"
|
|
||||||
import Grade from "./components/Grade.vue"
|
import Grade from "./components/Grade.vue"
|
||||||
import FlowchartLink from "./components/FlowchartLink.vue"
|
import FlowchartLink from "./components/FlowchartLink.vue"
|
||||||
import FlowchartScoreDetail from "./components/FlowchartScoreDetail.vue"
|
|
||||||
|
// 下面四个组件只在默认关闭的 n-modal 里用,其中两个统计面板还只有老师看得见。
|
||||||
|
// 静态 import 会把它们拖进本路由的关键路径——光 chart.js 就 197KB,进页面前必须先下完。
|
||||||
|
// 改成异步后本路由增量下载从 675KB / 59 个文件降到 360KB 出头。
|
||||||
|
const StatisticsPanel = defineAsyncComponent(
|
||||||
|
() => import("shared/components/StatisticsPanel.vue"),
|
||||||
|
)
|
||||||
|
const FlowchartStatisticsPanel = defineAsyncComponent(
|
||||||
|
() => import("shared/components/FlowchartStatisticsPanel.vue"),
|
||||||
|
)
|
||||||
|
const SubmissionDetail = defineAsyncComponent(() => import("./detail.vue"))
|
||||||
|
const FlowchartScoreDetail = defineAsyncComponent(
|
||||||
|
() => import("./components/FlowchartScoreDetail.vue"),
|
||||||
|
)
|
||||||
|
|
||||||
interface SubmissionQuery {
|
interface SubmissionQuery {
|
||||||
username: string
|
username: string
|
||||||
@@ -51,6 +61,8 @@ const submissions = ref<SubmissionListItem[]>([])
|
|||||||
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
|
const flowcharts = ref<FlowchartSubmissionListItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const todayCount = ref(0)
|
const todayCount = ref(0)
|
||||||
|
// 没有它的话,等接口这段时间表格就是一片空白,连转圈都没有
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
// 使用分页 composable
|
// 使用分页 composable
|
||||||
const { query, clearQuery } = usePagination<SubmissionQuery>({
|
const { query, clearQuery } = usePagination<SubmissionQuery>({
|
||||||
@@ -99,29 +111,34 @@ const languageOptions: SelectOption[] = [
|
|||||||
async function listSubmissions() {
|
async function listSubmissions() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
const offset = query.limit * (query.page - 1)
|
const offset = query.limit * (query.page - 1)
|
||||||
if (query.language === "Flowchart") {
|
loading.value = true
|
||||||
const res = await getFlowchartSubmissions({
|
try {
|
||||||
username: query.username,
|
if (query.language === "Flowchart") {
|
||||||
problemId: query.problem,
|
const res = await getFlowchartSubmissions({
|
||||||
myself: query.myself,
|
username: query.username,
|
||||||
offset,
|
problemId: query.problem,
|
||||||
limit: query.limit,
|
myself: query.myself,
|
||||||
today: query.today,
|
offset,
|
||||||
grade: query.result,
|
limit: query.limit,
|
||||||
})
|
today: query.today,
|
||||||
total.value = res.total
|
grade: query.result,
|
||||||
flowcharts.value = res.results
|
})
|
||||||
} else {
|
total.value = res.total
|
||||||
const res = await getSubmissions({
|
flowcharts.value = res.results
|
||||||
...query,
|
} else {
|
||||||
offset,
|
const res = await getSubmissions({
|
||||||
problemId: query.problem,
|
...query,
|
||||||
contestId: (route.params.contestID as string) ?? "",
|
offset,
|
||||||
language: query.language,
|
problemId: query.problem,
|
||||||
today: query.today,
|
contestId: (route.params.contestID as string) ?? "",
|
||||||
})
|
language: query.language,
|
||||||
submissions.value = res.results
|
today: query.today,
|
||||||
total.value = res.total
|
})
|
||||||
|
submissions.value = res.results
|
||||||
|
total.value = res.total
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,13 +236,11 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态
|
// 登录状态变化后刷新提交列表,更新提交编号列的可点击状态。
|
||||||
|
// 今日提交数不看登录态,onMounted 那次就够了,这里不用再拉一遍。
|
||||||
watch(
|
watch(
|
||||||
() => userStore.isAuthed,
|
() => userStore.isAuthed,
|
||||||
() => {
|
() => listSubmissions(),
|
||||||
listSubmissions()
|
|
||||||
if (route.name === "submissions") getTodayCount()
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const columns = computed(() => {
|
const columns = computed(() => {
|
||||||
@@ -491,12 +506,14 @@ const flowchartColumns = computed(() => {
|
|||||||
:bordered="false"
|
:bordered="false"
|
||||||
:columns="flowchartColumns"
|
:columns="flowchartColumns"
|
||||||
:data="flowcharts"
|
:data="flowcharts"
|
||||||
|
:loading="loading"
|
||||||
/>
|
/>
|
||||||
<n-data-table
|
<n-data-table
|
||||||
v-else
|
v-else
|
||||||
:bordered="false"
|
:bordered="false"
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data="submissions"
|
:data="submissions"
|
||||||
|
:loading="loading"
|
||||||
/>
|
/>
|
||||||
</n-flex>
|
</n-flex>
|
||||||
<Pagination
|
<Pagination
|
||||||
|
|||||||
Reference in New Issue
Block a user