diff --git a/apps/api/src/auth/session.ts b/apps/api/src/auth/session.ts index 5612c7d..574ca2f 100644 --- a/apps/api/src/auth/session.ts +++ b/apps/api/src/auth/session.ts @@ -72,14 +72,14 @@ export async function createSession( previousLogin, contestPasswords: {}, } - await redis.set( - sessionKey(token), - JSON.stringify(value), - "EX", - config.sessionTtlSeconds, - ) - await redis.sadd(userSessionsKey(userId), token) - await redis.expire(userSessionsKey(userId), config.sessionTtlSeconds) + // 三条写进一个 pipeline:一个班四十号人同时登录时,三趟往返和一趟的差别 + // 全压在登录这一下上 + await redis + .pipeline() + .set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds) + .sadd(userSessionsKey(userId), token) + .expire(userSessionsKey(userId), config.sessionTtlSeconds) + .exec() setCookie(c, config.sessionCookie, token, { httpOnly: true, sameSite: "Lax", @@ -193,10 +193,14 @@ async function getUserByToken(token: string | undefined): Promise return { user: null, reason: "disabled" } } - await redis.expire(sessionKey(token), config.sessionTtlSeconds) // 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期, - // 之后再吊销就找不到这张会话了 - await redis.expire(userSessionsKey(session.userId), config.sessionTtlSeconds) + // 之后再吊销就找不到这张会话了。两条走一次 pipeline —— 这是全后端最热的 Redis + // 路径,每个带鉴权的请求都要走一趟,形状和 touchSession 里那对保持一致 + await redis + .pipeline() + .expire(sessionKey(token), config.sessionTtlSeconds) + .expire(userSessionsKey(session.userId), config.sessionTtlSeconds) + .exec() // 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。 return { user: { diff --git a/apps/api/src/services/throttling.ts b/apps/api/src/services/throttling.ts index b442da5..3dd373b 100644 --- a/apps/api/src/services/throttling.ts +++ b/apps/api/src/services/throttling.ts @@ -61,6 +61,26 @@ redis.call('EXPIRE', key, ttl) return { allowed, tostring(wait) } ` +/** + * 注册成自定义命令而不是每次 `redis.eval`:eval 会把上面 900 多字节的脚本全文 + * 一起发过去,而限流点在提交判题、AI 分析、流程图评分上,判题高峰期每条提交都要发 + * 一遍。ioredis 的 defineCommand 走 EVALSHA,只发 40 字节的 sha1,遇到 NOSCRIPT + * 自动回退成一次 EVAL 把脚本重新灌进去 —— Redis 重启或 SCRIPT FLUSH 之后不用管。 + */ +redis.defineCommand("throttleConsume", { numberOfKeys: 1, lua: CONSUME_SCRIPT }) + +type ThrottleRedis = typeof redis & { + throttleConsume( + key: string, + capacity: string, + fillRate: string, + defaultCapacity: string, + now: string, + num: string, + ttl: string, + ): Promise<[number, string]> +} + function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig { if (!value || typeof value !== "object" || Array.isArray(value)) return fallback const raw = value as Record @@ -121,9 +141,7 @@ export async function consumeToken( // 每次调用都会刷新 TTL,因此只有长时间无提交才会过期,届时桶早已回满, // 重新按 default_capacity 初始化只会更严,不会放水。 const ttl = Math.ceil(bucket.capacity / bucket.fill_rate) + 60 - const result = (await redis.eval( - CONSUME_SCRIPT, - 1, + const result = await (redis as ThrottleRedis).throttleConsume( `throttling:${scope}:${identity}`, String(bucket.capacity), String(bucket.fill_rate), @@ -131,7 +149,7 @@ export async function consumeToken( String(Date.now() / 1000), String(num), String(ttl), - )) as [number, string] + ) if (Number(result[0]) === 1) return { allowed: true } return { allowed: false, wait: Number(result[1]) || 0 } } diff --git a/apps/api/src/websocket.ts b/apps/api/src/websocket.ts index 6015a60..f0136b9 100644 --- a/apps/api/src/websocket.ts +++ b/apps/api/src/websocket.ts @@ -438,9 +438,8 @@ export async function bridgeSubmissionEvents( console.error("Failed to bridge submission event", error) }) }) - subscriber.on("error", (error) => { - console.error("Submission event subscriber error", error) - }) + // 连接层的 error 已经由 createSubscriberRedis 里的 withErrorLogging 打了 + // (带连接名),这里再挂一个只会把同一条错误打两遍 await subscriber.subscribe( submissionUpdateChannel, userEventChannel,