Files
OJ2/apps/api/src/services/throttling.ts
T
xuyueandClaude Opus 5 5d60bb15bb
Deploy / deploy (push) Has been cancelled
perf(redis): 热路径合并往返,限流脚本改走 EVALSHA
审查 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
2026-09-07 18:44:59 -06:00

156 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { redis } from "../redis"
import { getOptions } from "./options"
/**
* 令牌桶限流,搬运自旧后端 `utils/throttling.py` 的 TokenBucket。
*
* 参数与旧后端 `options/options.py:120` 的默认值逐字对齐:
* user: { capacity: 20, fill_rate: 0.03, default_capacity: 10 }
* 和旧后端一样,实际值以数据库 `throttling` 配置项为准,缺失时用上面的默认值。
*
* 旧后端还有一个按 IP 计数的桶,OJ2 从来没调用过(机房整个班共用一个出口 IP,
* 按 IP 限流等于按班限流),已随其余 IP 功能一并删除。库里 `throttling` 配置项
* 残留的 `ip` 键读不到就忽略,不用清。
*
* 旧实现在注释里写明「对于单个 key 的操作不是线程安全的」;这里改用 Lua 脚本做成原子操作,
* 算法和参数不变 —— 限流要挡的正是并发突发,读改写有竞态的话等于没挡。
*/
export type BucketConfig = {
capacity: number
fill_rate: number
default_capacity: number
}
export const throttlingDefaults: Record<"user", BucketConfig> = {
user: { capacity: 20, fill_rate: 0.03, default_capacity: 10 },
}
// KEYS[1] = bucket key
// ARGV = capacity, fill_rate, default_capacity, now(seconds), num, ttl(seconds)
const CONSUME_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local fill_rate = tonumber(ARGV[2])
local default_capacity = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local num = tonumber(ARGV[5])
local ttl = tonumber(ARGV[6])
local last_capacity = tonumber(redis.call('HGET', key, 'last_capacity'))
local last_timestamp = tonumber(redis.call('HGET', key, 'last_timestamp'))
if last_capacity == nil or last_timestamp == nil then
last_capacity = default_capacity
last_timestamp = now
end
local current = last_capacity + fill_rate * (now - last_timestamp)
if current > capacity then current = capacity end
local allowed = 0
local wait = 0
if current >= num then
current = current - num
allowed = 1
else
wait = (num - current) / fill_rate
end
redis.call('HSET', key, 'last_capacity', tostring(current), 'last_timestamp', tostring(now))
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<string, unknown>
const pick = (key: keyof BucketConfig) =>
typeof raw[key] === "number" && Number.isFinite(raw[key]) && (raw[key] as number) > 0
? (raw[key] as number)
: fallback[key]
return {
capacity: pick("capacity"),
fill_rate: pick("fill_rate"),
default_capacity: pick("default_capacity"),
}
}
/**
* 桶参数的进程内缓存。
*
* 限流点在提交判题、AI 分析、流程图评分上,原来每检查一次就查一次 `throttling`
* 配置项 —— 判题高峰期等于每条提交多一趟数据库,只为读一个几乎从不变的值。
* 上一代在 `options/options.py` 的 my_property 里也是带 TTL 缓存的,重写时漏掉了。
*
* 放进程内而不是 Redis:值只有几十字节,跨进程共享省不下什么,反倒要多一趟网络。
* `throttling` 没有后台界面,只能直接改库,改完最多一分钟后生效。
*/
const BUCKET_CACHE_TTL = 60_000
const bucketCache = new Map<"user", { value: BucketConfig; expiresAt: number }>()
export async function getBucketConfig(scope: "user"): Promise<BucketConfig> {
const cached = bucketCache.get(scope)
if (cached && cached.expiresAt > Date.now()) return cached.value
const fallback = throttlingDefaults[scope]
let value: BucketConfig
try {
const values = await getOptions(["throttling"])
const throttling = values.throttling
value = !throttling || typeof throttling !== "object" || Array.isArray(throttling)
? fallback
: parseBucketConfig((throttling as Record<string, unknown>)[scope], fallback)
} catch {
// 读不到就退回默认值,但**不写缓存** —— 数据库抖一下不该让接下来一整分钟
// 全站都按默认参数限流
return fallback
}
bucketCache.set(scope, { value, expiresAt: Date.now() + BUCKET_CACHE_TTL })
return value
}
export type ConsumeResult = { allowed: true } | { allowed: false; wait: number }
export async function consumeToken(
scope: "user",
identity: string,
num = 1,
): Promise<ConsumeResult> {
const bucket = await getBucketConfig(scope)
// 桶全满需要 capacity / fill_rate 秒;留出余量后过期,避免残留 key 无限堆积。
// 每次调用都会刷新 TTL,因此只有长时间无提交才会过期,届时桶早已回满,
// 重新按 default_capacity 初始化只会更严,不会放水。
const ttl = Math.ceil(bucket.capacity / bucket.fill_rate) + 60
const result = await (redis as ThrottleRedis).throttleConsume(
`throttling:${scope}:${identity}`,
String(bucket.capacity),
String(bucket.fill_rate),
String(bucket.default_capacity),
String(Date.now() / 1000),
String(num),
String(ttl),
)
if (Number(result[0]) === 1) return { allowed: true }
return { allowed: false, wait: Number(result[1]) || 0 }
}