perf(redis): 热路径合并往返,限流脚本改走 EVALSHA
Some checks failed
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
parent 01d7924faa
commit 5d60bb15bb
3 changed files with 39 additions and 18 deletions

View File

@@ -72,14 +72,14 @@ export async function createSession(
previousLogin, previousLogin,
contestPasswords: {}, contestPasswords: {},
} }
await redis.set( // 三条写进一个 pipeline一个班四十号人同时登录时三趟往返和一趟的差别
sessionKey(token), // 全压在登录这一下上
JSON.stringify(value), await redis
"EX", .pipeline()
config.sessionTtlSeconds, .set(sessionKey(token), JSON.stringify(value), "EX", config.sessionTtlSeconds)
) .sadd(userSessionsKey(userId), token)
await redis.sadd(userSessionsKey(userId), token) .expire(userSessionsKey(userId), config.sessionTtlSeconds)
await redis.expire(userSessionsKey(userId), config.sessionTtlSeconds) .exec()
setCookie(c, config.sessionCookie, token, { setCookie(c, config.sessionCookie, token, {
httpOnly: true, httpOnly: true,
sameSite: "Lax", sameSite: "Lax",
@@ -193,10 +193,14 @@ async function getUserByToken(token: string | undefined): Promise<SessionResult>
return { user: null, reason: "disabled" } return { user: null, reason: "disabled" }
} }
await redis.expire(sessionKey(token), config.sessionTtlSeconds)
// 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期, // 反向索引跟着会话一起续期,否则活跃用户的索引会先于会话到期,
// 之后再吊销就找不到这张会话了 // 之后再吊销就找不到这张会话了。两条走一次 pipeline —— 这是全后端最热的 Redis
await redis.expire(userSessionsKey(session.userId), config.sessionTtlSeconds) // 路径,每个带鉴权的请求都要走一趟,形状和 touchSession 里那对保持一致
await redis
.pipeline()
.expire(sessionKey(token), config.sessionTtlSeconds)
.expire(userSessionsKey(session.userId), config.sessionTtlSeconds)
.exec()
// 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。 // 唯一的收窄点。库里是 text 列,认不出来的值降成最低权限,见 toAdminType 的注释。
return { return {
user: { user: {

View File

@@ -61,6 +61,26 @@ redis.call('EXPIRE', key, ttl)
return { allowed, tostring(wait) } 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 { function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) return fallback if (!value || typeof value !== "object" || Array.isArray(value)) return fallback
const raw = value as Record<string, unknown> const raw = value as Record<string, unknown>
@@ -121,9 +141,7 @@ export async function consumeToken(
// 每次调用都会刷新 TTL因此只有长时间无提交才会过期届时桶早已回满 // 每次调用都会刷新 TTL因此只有长时间无提交才会过期届时桶早已回满
// 重新按 default_capacity 初始化只会更严,不会放水。 // 重新按 default_capacity 初始化只会更严,不会放水。
const ttl = Math.ceil(bucket.capacity / bucket.fill_rate) + 60 const ttl = Math.ceil(bucket.capacity / bucket.fill_rate) + 60
const result = (await redis.eval( const result = await (redis as ThrottleRedis).throttleConsume(
CONSUME_SCRIPT,
1,
`throttling:${scope}:${identity}`, `throttling:${scope}:${identity}`,
String(bucket.capacity), String(bucket.capacity),
String(bucket.fill_rate), String(bucket.fill_rate),
@@ -131,7 +149,7 @@ export async function consumeToken(
String(Date.now() / 1000), String(Date.now() / 1000),
String(num), String(num),
String(ttl), String(ttl),
)) as [number, string] )
if (Number(result[0]) === 1) return { allowed: true } if (Number(result[0]) === 1) return { allowed: true }
return { allowed: false, wait: Number(result[1]) || 0 } return { allowed: false, wait: Number(result[1]) || 0 }
} }

View File

@@ -438,9 +438,8 @@ export async function bridgeSubmissionEvents(
console.error("Failed to bridge submission event", error) console.error("Failed to bridge submission event", error)
}) })
}) })
subscriber.on("error", (error) => { // 连接层的 error 已经由 createSubscriberRedis 里的 withErrorLogging 打了
console.error("Submission event subscriber error", error) // (带连接名),这里再挂一个只会把同一条错误打两遍
})
await subscriber.subscribe( await subscriber.subscribe(
submissionUpdateChannel, submissionUpdateChannel,
userEventChannel, userEventChannel,