perf(排名): 一个班同时打开排名、班级对抗、按人数排的题目列表时只查一次库

这几样谁来看都一样,原来每次请求现算:全服全部时间的排名本机 16 核就要 250ms,
服务器两核还要判题。services/cache.ts 进程内缓存 10 秒,同时到达的相同请求等同一个结果:
- 排名榜面的原始数据(「我」排第几、前后一名照旧每次现算):250ms → 6ms
- 班级对抗 / 详情 / PK 共用的这学期数据:40ms → 1ms
- 题目列表按做对、做过人数排序的全表人数:45ms → 20ms
40 个人同时打开全服排名(缓存是冷的)一共 342ms。代价是做对之后最多晚 10 秒上榜。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-16 14:47:24 +08:00
1 parent 307b0725d0
commit fb11e86151
5 files changed
+66 -4

No files matched your search

+2 -1
View File
@@ -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(
+2 -1
View File
@@ -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 })
+34
View File
@@ -0,0 +1,34 @@
/**
* 进程内的短缓存,同时到达的相同请求只查一次库。
*
* 给「谁来看都一样」的聚合用:排名的那群人做对了哪些题、全服班级对抗、全部题目的
* 做对人数。这些在本机 16 核上就要 40–250ms,服务器只有两核、还要判题,而上课时一个班
* 几十个人会在同一分钟打开同一张榜。缓存的是原始数据,「我」排第几、前后一名这些
* 因人而异的部分照旧每次现算。
*
* 放进程内不放 Redis:值是几百 KB 的对象,序列化来回比重算省不了多少;api 只有一个
* 进程,两个站点各算各的也没关系。不做失效,等它过期 —— 调用方选的 TTL 都很短。
*
* 缓存的是 Promise 本身:第一个请求还在查的时候,后到的直接等同一个结果。
* 查失败了马上丢掉,下一个请求重新查,不把错误缓存住。
*/
const entries = new Map<string, { value: Promise<unknown>; expiresAt: number }>()
/** 键的种类有限(几个班 × 几个时间段),超过这么多才顺手扫一遍过期的 */
const SWEEP_ABOVE = 200
export function cached<T>(key: string, ttlMs: number, load: () => Promise<T>): Promise<T> {
const now = Date.now()
const hit = entries.get(key)
if (hit && hit.expiresAt > now) return hit.value as Promise<T>
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
}
@@ -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)。
+19 -2
View File
@@ -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[]) {