perf(redis): 热路径合并往返,限流脚本改走 EVALSHA
Deploy / deploy (push) Has been cancelled

审查 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
This commit is contained in:
2026-09-07 18:44:59 -06:00
co-authored by Claude Opus 5
parent 01d7924faa
commit 5d60bb15bb
3 changed files with 39 additions and 18 deletions
+15 -11
View File
@@ -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<SessionResult>
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: {