perf(后端): 干掉 14 处 N+1 查询
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:
xuyueandClaude Opus 5 committed 2026-08-26 23:43:57 -06:00
1 parent f00c941ede
commit a75c70c82d
9 files changed
+392 -247

No files matched your search

+28 -23
View File
@@ -6,7 +6,7 @@ import {
updateAcmHelperRequestSchema,
updateContestRequestSchema,
} from "@oj2/contract"
import { and, count, desc, eq, ilike } from "drizzle-orm"
import { and, count, desc, eq, ilike, inArray } from "drizzle-orm"
import { Hono } from "hono"
import { requireTeacher, type AppEnv } from "../../auth/middleware"
@@ -206,28 +206,33 @@ adminContestRoutes.post("/contests/:id/clone", requireTeacher, async (c) => {
const problems = await tx.select().from(schema.problem)
.where(eq(schema.problem.contestId, id))
for (const problem of problems) {
const { id: _oldId, ...rest } = problem
const [copy] = await tx.insert(schema.problem).values({
...rest,
contestId: contest!.id,
// 计数器归零:克隆的是题面,不是历史战绩
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createdById: me,
createTime: now,
lastUpdateTime: now,
}).returning({ id: schema.problem.id })
// 标签是多对多中间表,Django 的 problem.tags.set(tags) 对应这里手工复制关系行
const tags = await tx.select({ tagId: schema.problemTags.problemtagId })
.from(schema.problemTags).where(eq(schema.problemTags.problemId, problem.id))
if (tags.length) {
await tx.insert(schema.problemTags).values(tags.map((tag) => ({
problemId: copy!.id,
problemtagId: tag.tagId,
})))
}
if (problems.length === 0) return contest!.id
// 题面、标签各一条语句,不再按题循环。新旧题的对应关系靠 _id 认:
// 克隆出来的题原样保留 _id,而它们全在同一场新比赛里,彼此不会重名。
const copies = await tx.insert(schema.problem).values(problems.map(({ id: _oldId, ...rest }) => ({
...rest,
contestId: contest!.id,
// 计数器归零:克隆的是题面,不是历史战绩
submissionNumber: 0,
acceptedNumber: 0,
statisticInfo: {},
createdById: me,
createTime: now,
lastUpdateTime: now,
}))).returning({ id: schema.problem.id, displayId: schema.problem.displayId })
const newIdByDisplayId = new Map(copies.map((copy) => [copy.displayId, copy.id]))
// 标签是多对多中间表,Django 的 problem.tags.set(tags) 对应这里手工复制关系行
const tags = await tx.select({ problemId: schema.problemTags.problemId, tagId: schema.problemTags.problemtagId })
.from(schema.problemTags).where(inArray(schema.problemTags.problemId, problems.map((problem) => problem.id)))
if (tags.length) {
const displayIdByOldId = new Map(problems.map((problem) => [problem.id, problem.displayId]))
const links = tags.flatMap((tag) => {
const newId = newIdByDisplayId.get(displayIdByOldId.get(tag.problemId) ?? "")
return newId === undefined ? [] : [{ problemId: newId, problemtagId: tag.tagId }]
})
if (links.length) await tx.insert(schema.problemTags).values(links)
}
return contest!.id
})