perf(后端): 干掉 14 处 N+1 查询
Some checks failed
Deploy / deploy (push) Has been cancelled

列表接口按行发查询是从阶段 3 一路带过来的写法:`Promise.all(rows.map(...))`
看着是并发的,但每行都往库里打一次,行数一多就是几百上千次往返。同样的模式
也散在几个后台批处理和写入路径里。全部改成「先收集 id,一条 inArray/group by
查回来建 Map」——`routes/problem.ts` 的 getProblemTags 早就是这么写的,这次
只是把剩下的地方对齐。

用户可见的列表:

- `GET /problem-sets` 每行 5 条(题目数/我的进度/奖章/已获奖章/创建者),
  limit 上限 250 就是 1250 次往返。改成固定 5 条,与行数无关。
- `GET /contests` 每场比赛一条 creator 查询。后台的比赛列表本来就是 join
  出来的,只有这条公开列表漏了。
- `GET /admin/problems`、`GET /admin/contests/:id/problems` 每题一条标签查询。
- `GET /admin/problem-sets` 每行 3 条;`.../badges` 每个奖章一条 count。

后台批处理:

- `refreshContestJoinedForAll` 原来是每个用户 1 条 count 加一个独立事务里的
  insert/select for update/update。改成一条 group by 出全部用户的场次,再分批
  upsert,`metrics || excluded.metrics` 是 jsonb 浅合并,只覆盖 contest_joined
  一个键,其余指标原样保留 —— 合并在一条语句里完成,for update 那把锁不再需要。
- `rescanAchievement` 补发循环、`unlockAchievements`:命中的一次插完,
  onConflictDoNothing 的 returning 就是真新解锁的那批,unlockCount 改成一次 +N。
- `resyncProgress` 逐行 UPDATE 改成一条,completed 用 least() 夹住。

写入路径:

- 标签解析抽出 normalizeTagNames + findTagsByName(一条 lower(name) IN),
  新建题、改题、批量打标签三条路共用。
- 克隆比赛:题面一条 INSERT、标签一条 SELECT 加一条 INSERT。新旧题的对应
  关系靠 _id 认,不依赖 returning 的行序。
- `POST /admin/website` 8 个键一条多行 upsert。
- 题单奖章判定一次插完。

`/ai/duration`:原来每个时间桶两条查询、桶之间还串行,一年 12 个桶 24 次往返。
改成先算桶、再一条查询把整段区间拉回来在内存里分桶。时间戳用
`extract(epoch) * 1000` 取毫秒回来比,别指望 Date.parse 认 pg 那个
`2026-08-12 00:00:00+00` 格式。**相邻桶首尾相接、两端闭区间**(落在边界上的
提交两个桶都算)这条旧语义是照搬的,不要顺手改成半开区间。

验证:本机起 dev 栈,造了覆盖各分支的种子数据(创建者重复的题单、零题目/零
奖章的题单、completed > total 的脏进度、除不尽的百分比、大小写混写的已有标签、
带/不带标签的比赛题、正好落在分桶边界上的提交),旧代码跑一遍、新代码跑一遍:

- 51 个接口响应逐字节一致
- 12 张表的快照逐行一致(唯一差别是 progress_detail 里的 submit_time 墙钟值)
- 打开 log_statement=all 数过条数,例如 `GET /admin/problems` 20 道题
  24 → 5,`GET /problem-sets` 28 → 8,`/ai/duration` years:1 28 → 5,
  202 个用户的成就补发 1828 → 614

一处可观察的行为变化:补发现在整批共用一个 unlockTime,原来是每人一个
new Date()。rescanAchievement 上方的注释本来就写着补发会给几百人盖同一个
时间戳、前端据此只显示「已获得」不显示日期,所以这个方向是对的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 23:43:57 -06:00
parent f00c941ede
commit a75c70c82d
9 changed files with 392 additions and 247 deletions

View File

@@ -42,28 +42,43 @@ async function loadOwned(c: { req: { param(name: string): string } }, user: Auth
}
async function serialize(row: typeof schema.problemset.$inferSelect) {
const [[problems], [participants], [creator]] = await Promise.all([
db.select({ value: count() }).from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, row.id)),
db.select({ value: count() }).from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, row.id)),
return (await serializeMany([row]))[0]!
}
/** 批量版:列表接口走这个,固定 3 条查询,与行数无关(按行 serialize 就是 N+1 */
async function serializeMany(rows: (typeof schema.problemset.$inferSelect)[]) {
if (rows.length === 0) return []
const ids = rows.map((row) => row.id)
const [problems, participants, creators] = await Promise.all([
db.select({ problemsetId: schema.problemsetProblem.problemsetId, value: count() })
.from(schema.problemsetProblem).where(inArray(schema.problemsetProblem.problemsetId, ids))
.groupBy(schema.problemsetProblem.problemsetId),
db.select({ problemsetId: schema.problemsetProgress.problemsetId, value: count() })
.from(schema.problemsetProgress).where(inArray(schema.problemsetProgress.problemsetId, ids))
.groupBy(schema.problemsetProgress.problemsetId),
db.select({ id: schema.user.id, username: schema.user.username, realName: schema.userProfile.realName })
.from(schema.user).leftJoin(schema.userProfile, eq(schema.userProfile.userId, schema.user.id))
.where(eq(schema.user.id, row.createdById)).limit(1),
.where(inArray(schema.user.id, [...new Set(rows.map((row) => row.createdById))])),
])
return adminProblemSetSchema.parse({
id: row.id,
title: row.title,
description: row.description,
difficulty: row.difficulty,
status: row.status,
endTime: row.endTime,
visible: row.visible,
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime,
problemsCount: problems?.value ?? 0,
participantCount: participants?.value ?? 0,
const problemsBySet = new Map(problems.map((item) => [item.problemsetId, item.value]))
const participantsBySet = new Map(participants.map((item) => [item.problemsetId, item.value]))
const creatorById = new Map(creators.map((item) => [item.id, item]))
return rows.map((row) => {
const creator = creatorById.get(row.createdById)
return adminProblemSetSchema.parse({
id: row.id,
title: row.title,
description: row.description,
difficulty: row.difficulty,
status: row.status,
endTime: row.endTime,
visible: row.visible,
createdBy: sampleUser(creator ?? { id: row.createdById, username: "" }, creator?.realName),
createTime: row.createTime,
lastUpdateTime: row.lastUpdateTime,
problemsCount: problemsBySet.get(row.id) ?? 0,
participantCount: participantsBySet.get(row.id) ?? 0,
})
})
}
@@ -97,7 +112,7 @@ adminProblemSetRoutes.get("/problem-sets", requireTeacher, async (c) => {
.orderBy(desc(schema.problemset.createTime)).limit(limit).offset(offset),
])
return success(c, adminProblemSetListSchema.parse({
results: await Promise.all(rows.map(serialize)),
results: await serializeMany(rows),
total: totalRows[0]?.value ?? 0,
}))
})
@@ -268,9 +283,17 @@ adminProblemSetRoutes.delete("/problem-sets/:id/problems/:itemId", requireTeache
// ---------------------------------------------------------------- 奖章
async function badgeWithCount(badge: BadgeRow) {
const [earned] = await db.select({ value: count() }).from(schema.userBadge)
.where(eq(schema.userBadge.badgeId, badge.id))
return adminProblemSetBadgeSchema.parse({
return (await badgesWithCount([badge]))[0]!
}
/** 批量版:一条 group by 数完整批奖章的获得人数 */
async function badgesWithCount(badges: BadgeRow[]) {
if (badges.length === 0) return []
const earned = await db.select({ badgeId: schema.userBadge.badgeId, value: count() })
.from(schema.userBadge).where(inArray(schema.userBadge.badgeId, badges.map((badge) => badge.id)))
.groupBy(schema.userBadge.badgeId)
const countByBadge = new Map(earned.map((item) => [item.badgeId, item.value]))
return badges.map((badge) => adminProblemSetBadgeSchema.parse({
id: badge.id,
problemsetId: badge.problemsetId,
name: badge.name,
@@ -278,8 +301,8 @@ async function badgeWithCount(badge: BadgeRow) {
icon: badge.icon,
conditionType: badge.conditionType,
conditionValue: badge.conditionValue,
earnedCount: earned?.value ?? 0,
})
earnedCount: countByBadge.get(badge.id) ?? 0,
}))
}
/** 纯逻辑判定,对齐旧 `ProblemSetBadge._is_eligible` */
@@ -327,7 +350,7 @@ adminProblemSetRoutes.get("/problem-sets/:id/badges", requireTeacher, async (c)
if (!row) return failure(c, 404, "problem-set-not-found", "题单不存在")
const badges = await db.select().from(schema.problemsetBadge)
.where(eq(schema.problemsetBadge.problemsetId, row.id)).orderBy(asc(schema.problemsetBadge.id))
return success(c, await Promise.all(badges.map(badgeWithCount)))
return success(c, await badgesWithCount(badges))
})
adminProblemSetRoutes.post("/problem-sets/:id/badges", requireTeacher, async (c) => {
@@ -393,21 +416,21 @@ adminProblemSetRoutes.delete("/problem-sets/:id/badges/:badgeId", requireTeacher
* 只重算分母与百分比,不碰 completeTime —— 已经完成过的事实不因加题而撤销。
*/
async function resyncProgress(problemsetId: number) {
const [[totalRow], progresses] = await Promise.all([
db.select({ value: count() }).from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, problemsetId)),
db.select().from(schema.problemsetProgress)
.where(eq(schema.problemsetProgress.problemsetId, problemsetId)),
])
const [totalRow] = await db.select({ value: count() }).from(schema.problemsetProblem)
.where(eq(schema.problemsetProblem.problemsetId, problemsetId))
const total = totalRow?.value ?? 0
for (const progress of progresses) {
const completed = Math.min(progress.completedProblemsCount, total)
await db.update(schema.problemsetProgress).set({
totalProblemsCount: total,
completedProblemsCount: completed,
progressPercentage: total > 0 ? Math.round((completed / total) * 10000) / 100 : 0,
}).where(eq(schema.problemsetProgress.id, progress.id))
}
// 一条 UPDATE 把整个题单的参与者刷完。以前是先把 progress 全查出来再逐行 update
// 一个班的题单就是几十次往返,而算出来的值只跟 total 和这一行自己的 completed 有关。
// completed 用 least(...) 夹住,百分比按 JS 那边同样的「乘 10000 四舍五入再除 100」
// 保留两位小数 —— 数都是非负的numeric 的 round 和 Math.round 在这个区间一致。
const completed = sql`least(${schema.problemsetProgress.completedProblemsCount}, ${total})`
await db.update(schema.problemsetProgress).set({
totalProblemsCount: total,
completedProblemsCount: completed,
progressPercentage: total > 0
? sql`round((${completed}::numeric / ${total}) * 10000) / 100`
: sql`0`,
}).where(eq(schema.problemsetProgress.problemsetId, problemsetId))
}
adminProblemSetRoutes.get("/problem-sets/:id/progress", requireTeacher, async (c) => {