审查 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:
@@ -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: {
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user