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:
1 parent
307b0725d0
commit
fb11e86151
5 files changed
+66
-4
No files matched your search
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
Reference in new issue
Block a user