From fb11e86151b746fb7ef3835818c0c6c1814676a1 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Fri, 16 Oct 2026 14:47:24 +0800 Subject: [PATCH] =?UTF-8?q?perf(=E6=8E=92=E5=90=8D):=20=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E7=8F=AD=E5=90=8C=E6=97=B6=E6=89=93=E5=BC=80=E6=8E=92=E5=90=8D?= =?UTF-8?q?=E3=80=81=E7=8F=AD=E7=BA=A7=E5=AF=B9=E6=8A=97=E3=80=81=E6=8C=89?= =?UTF-8?q?=E4=BA=BA=E6=95=B0=E6=8E=92=E7=9A=84=E9=A2=98=E7=9B=AE=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=97=B6=E5=8F=AA=E6=9F=A5=E4=B8=80=E6=AC=A1=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 这几样谁来看都一样,原来每次请求现算:全服全部时间的排名本机 16 核就要 250ms, 服务器两核还要判题。services/cache.ts 进程内缓存 10 秒,同时到达的相同请求等同一个结果: - 排名榜面的原始数据(「我」排第几、前后一名照旧每次现算):250ms → 6ms - 班级对抗 / 详情 / PK 共用的这学期数据:40ms → 1ms - 题目列表按做对、做过人数排序的全表人数:45ms → 20ms 40 个人同时打开全服排名(缓存是冷的)一共 342ms。代价是做对之后最多晚 10 秒上榜。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/problem.ts | 3 +- apps/api/src/routes/ranking.ts | 3 +- apps/api/src/services/cache.ts | 34 +++++++++++++++++++++++ apps/api/src/services/problem-progress.ts | 9 ++++++ apps/api/src/services/ranking.ts | 21 ++++++++++++-- 5 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/services/cache.ts diff --git a/apps/api/src/routes/problem.ts b/apps/api/src/routes/problem.ts index 822eedd..dae2a42 100644 --- a/apps/api/src/routes/problem.ts +++ b/apps/api/src/routes/problem.ts @@ -36,6 +36,7 @@ import { JudgeStatus } from "../judge/status" import { buildProblemProgress, problemStates, + allProblemUserCounts, problemUserCounts, } from "../services/problem-progress" import { @@ -180,7 +181,7 @@ problemRoutes.get("/problems", optionalAuth, async (c) => { // 旧的「画流程图 / 语法检查」排序已经拆成类型筛选,传进来落到默认(最新创建) const sort = problemListSortSchema.safeParse(c.req.query("sort") ?? "").data ?? "" const byPeople = sort.endsWith("submission_number") || sort.endsWith("accepted_number") - const allCounts = byPeople ? await problemUserCounts() : null + const allCounts = byPeople ? await allProblemUserCounts() : null const people = allCounts ? sql`case ${schema.problem.id} ${sql.join( [...allCounts].map( diff --git a/apps/api/src/routes/ranking.ts b/apps/api/src/routes/ranking.ts index 89eecb0..0430772 100644 --- a/apps/api/src/routes/ranking.ts +++ b/apps/api/src/routes/ranking.ts @@ -28,6 +28,7 @@ import { loadEntrants, PK_MAX_CLASSES, pkPartner, + rankEntrants, standings, WEEK_MS, type Standing, @@ -107,7 +108,7 @@ rankingRoutes.get("/rankings/board", optionalAuth, async (c) => { const isTeacher = isTeacherOrAbove(user) const [entrants, mine, hiddenUsers] = await Promise.all([ - loadEntrants(audienceWhere(scope.data, group), start), + rankEntrants(scope.data, group, start), user && !isTeacher ? db .select({ rankHiddenAt: schema.user.rankHiddenAt }) diff --git a/apps/api/src/services/cache.ts b/apps/api/src/services/cache.ts new file mode 100644 index 0000000..5234ed1 --- /dev/null +++ b/apps/api/src/services/cache.ts @@ -0,0 +1,34 @@ +/** + * 进程内的短缓存,同时到达的相同请求只查一次库。 + * + * 给「谁来看都一样」的聚合用:排名的那群人做对了哪些题、全服班级对抗、全部题目的 + * 做对人数。这些在本机 16 核上就要 40–250ms,服务器只有两核、还要判题,而上课时一个班 + * 几十个人会在同一分钟打开同一张榜。缓存的是原始数据,「我」排第几、前后一名这些 + * 因人而异的部分照旧每次现算。 + * + * 放进程内不放 Redis:值是几百 KB 的对象,序列化来回比重算省不了多少;api 只有一个 + * 进程,两个站点各算各的也没关系。不做失效,等它过期 —— 调用方选的 TTL 都很短。 + * + * 缓存的是 Promise 本身:第一个请求还在查的时候,后到的直接等同一个结果。 + * 查失败了马上丢掉,下一个请求重新查,不把错误缓存住。 + */ +const entries = new Map; expiresAt: number }>() + +/** 键的种类有限(几个班 × 几个时间段),超过这么多才顺手扫一遍过期的 */ +const SWEEP_ABOVE = 200 + +export function cached(key: string, ttlMs: number, load: () => Promise): Promise { + const now = Date.now() + const hit = entries.get(key) + if (hit && hit.expiresAt > now) return hit.value as Promise + + if (entries.size > SWEEP_ABOVE) { + for (const [k, entry] of entries) if (entry.expiresAt <= now) entries.delete(k) + } + const value = load() + entries.set(key, { value, expiresAt: now + ttlMs }) + value.catch(() => { + if (entries.get(key)?.value === value) entries.delete(key) + }) + return value +} diff --git a/apps/api/src/services/problem-progress.ts b/apps/api/src/services/problem-progress.ts index af89c1f..9dfd335 100644 --- a/apps/api/src/services/problem-progress.ts +++ b/apps/api/src/services/problem-progress.ts @@ -6,6 +6,7 @@ import { isAccepted, UNJUDGED_RESULTS } from "../judge/status" import { asRecord } from "../routes/helpers" import { calendarDay, dayNumber, dayText, weekStart } from "../time" import { accepted } from "./learning-stats" +import { cached } from "./cache" /** * 一个人在公开题库里的做题状态:做对了哪些、交过哪些。 @@ -49,6 +50,14 @@ export async function problemStates(userId: number) { return { solved, tried } } +/** + * 全部题的做对 / 交过人数,题目列表「按人数排」用。全表聚合,谁来排都一样,缓存 10 秒 + * (见 services/cache.ts),列表上的人数最多晚 10 秒。 + */ +export function allProblemUserCounts() { + return cached("problem-user-counts", 10_000, () => problemUserCounts()) +} + /** * 每道题做对 / 交过的人数,和题目页「统计」页签(`problem-stats.ts`)同一个口径: * 代码提交,不算还在判的,AST_CHECK_FAILED 也是做对。不传 ids 就是全部题(排序用,全表约 85ms)。 diff --git a/apps/api/src/services/ranking.ts b/apps/api/src/services/ranking.ts index 5de37b0..0b272a7 100644 --- a/apps/api/src/services/ranking.ts +++ b/apps/api/src/services/ranking.ts @@ -29,6 +29,7 @@ import { db, schema } from "../db" import { JudgeStatus } from "../judge/status" import { sampleUser, rounded } from "../routes/helpers" import { termStart, weekStart } from "../time" +import { cached } from "./cache" /** * 排名页和班级详情、班级 AI 分析共用的口径(routes/ranking.ts、routes/ai.ts): @@ -173,7 +174,20 @@ const PLATEAU_MIN = 5 /** 班级详情的每周人均列最近几周 */ const DETAIL_WEEKS = 6 -/** 有班级的学生,这学期的做对时刻 —— 班级对抗和班级详情都从这一份算 */ +/** + * 排名、班级对抗这些「谁来看都一样」的数据缓存多久(services/cache.ts)。10 秒: + * 一个班同时打开时只查一次;学生做对一道题马上去看排名,最多晚 10 秒看到。 + */ +const SHARED_TTL = 10_000 + +/** 排名榜面那群人的做对时刻:同一个范围、同一个时间段谁来看都一样,「我」的部分由调用方现算 */ +export function rankEntrants(scope: RankScope, group: string | null, since: string | null) { + return cached(`rank-entrants:${scope}:${group}:${since}`, SHARED_TTL, () => + loadEntrants(audienceWhere(scope, group), since), + ) +} + +/** 有班级的学生,这学期的做对时刻 —— 班级对抗、班级详情、班级 PK 都从这一份算 */ async function classedEntrants() { const term = termStart() const where = and( @@ -181,7 +195,10 @@ async function classedEntrants() { isNotNull(schema.user.className), ne(schema.user.className, ""), )! - return { term, entrants: await loadEntrants(where, term) } + return { + term, + entrants: await cached(`classed-entrants:${term}`, SHARED_TTL, () => loadEntrants(where, term)), + } } function battleFrom(entrants: Entrant[]) {