From 5d60bb15bb3e8f2a46d1f4102d74638ce4992909 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 7 Sep 2026 18:44:59 -0600 Subject: [PATCH] =?UTF-8?q?perf(redis):=20=E7=83=AD=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=BE=80=E8=BF=94=EF=BC=8C=E9=99=90=E6=B5=81?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E6=94=B9=E8=B5=B0=20EVALSHA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查 Redis 用法时找到的四处小账,都是确定性改动,语义不变: - `getUserByToken` 里两条串行 EXPIRE 合成一次 pipeline。这是全后端最热的 Redis 路径 —— 每个带鉴权的 HTTP 请求都要续一次会话和反向索引。上次给 `touchSession` 修的正是同一个形状,这处漏了,两边现在一致。 - `createSession` 的 SET / SADD / EXPIRE 三趟并成一趟。登录是突发的, 一个班同时登录时差别全压在这一下。 - 限流的 Lua 从 `redis.eval` 改成 `defineCommand`,稳态走 EVALSHA 只发 40 字节 sha1,不再每次带上 937 字节的脚本全文;NOSCRIPT 由 ioredis 自动回退成 EVAL 重新灌,Redis 重启和 SCRIPT FLUSH 都不用管。 - 删掉 websocket 订阅连接上重复的 error 监听 —— `withErrorLogging` 已经 打过一遍且带连接名,留着只会把同一条错误打两份。 实跑验证(dev 栈,API 跑在 3999):登录后 `session:*` 多一条、 `user-sessions:1` 的 scard 和 ttl(604800) 都对;把两个键的 TTL 压到 100 再打一次带鉴权的请求,两个都回到 604800。限流侧 `info commandstats` 显示 evalsha calls=3/failed=1 + eval calls=2 —— 失败那次正是 SCRIPT FLUSH 之后 的 NOSCRIPT 回退;令牌桶数值与旧实现逐位一致(10 个初始额度扣 3 剩 7, 再要 20 个被拒并返回 wait=433.33 = (20-7)/0.03)。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB --- apps/api/src/auth/session.ts | 26 +++++++++++++++----------- apps/api/src/services/throttling.ts | 26 ++++++++++++++++++++++---- apps/api/src/websocket.ts | 5 ++--- 3 files changed, 39 insertions(+), 18 deletions(-) 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,