perf(限流): 桶参数加 60 秒进程内缓存;Redis 连接补 error 监听
getBucketConfig 每次都查一次 `throttling` 配置项,而限流点在提交判题、AI 分析、 流程图评分上(5 处调用)—— 判题高峰期等于每条提交多一趟数据库,只为读一个几乎 从不变的值。上一代在 options/options.py 的 my_property 里也是带 TTL 缓存的, 重写时漏掉了。 缓存放进程内而不是 Redis:每站只有一个 api 进程服务读请求(oj-api 单容器、 Bun.serve 没有 reusePort、worker 只消费队列),进程内 Map 就等于全站缓存, 放 Redis 只是多一趟网络加一次序列化。异常分支特意不写缓存 —— 数据库抖一下不该 让接下来一整分钟全站都按默认参数限流。`throttling` 没有后台界面、只能直接改库, 改完最多一分钟后生效。 顺带给三条 Redis 连接都挂上 error 监听。ioredis 对没有监听者的 error 走 silentEmit:不崩进程,但把连接错误直接 console.error 到 stderr,绕开这里的日志, 而且不说是哪条连接 —— 这个进程同时开着会话读写、两条队列、一条订阅,「哪条」正是 要先知道的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB
This commit is contained in:
@@ -2,18 +2,40 @@ import Redis from "ioredis"
|
|||||||
|
|
||||||
import { config } from "./config"
|
import { config } from "./config"
|
||||||
|
|
||||||
export const redis = new Redis(config.redisUrl, {
|
/**
|
||||||
maxRetriesPerRequest: 1,
|
* 每条连接都要挂 error 监听。
|
||||||
})
|
*
|
||||||
|
* ioredis 对没有监听者的 error 走 silentEmit —— 不会像普通 EventEmitter 那样崩进程,
|
||||||
|
* 但会把连接错误直接 `console.error("[ioredis] Unhandled error event:", ...)` 打到
|
||||||
|
* stderr,绕开这里的日志,而且不说是哪条连接出的事。这个进程同时开着会话读写、
|
||||||
|
* 两条队列、一条订阅,出问题时「哪条」正是要先知道的。
|
||||||
|
*/
|
||||||
|
function withErrorLogging(client: Redis, name: string) {
|
||||||
|
client.on("error", (error) => {
|
||||||
|
console.error(`Redis connection error (${name})`, error)
|
||||||
|
})
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话、限流、发布事件都走这条。`maxRetriesPerRequest: 1` 是故意的:每个带鉴权的
|
||||||
|
* 请求都要读一次会话,Redis 不可用时快速失败成 500,比让请求挂在重试里更好。
|
||||||
|
*/
|
||||||
|
export const redis = withErrorLogging(
|
||||||
|
new Redis(config.redisUrl, { maxRetriesPerRequest: 1 }),
|
||||||
|
"main",
|
||||||
|
)
|
||||||
|
|
||||||
export function createBlockingRedis() {
|
export function createBlockingRedis() {
|
||||||
return new Redis(config.redisUrl, {
|
return withErrorLogging(
|
||||||
maxRetriesPerRequest: null,
|
new Redis(config.redisUrl, { maxRetriesPerRequest: null }),
|
||||||
})
|
"blocking",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSubscriberRedis() {
|
export function createSubscriberRedis() {
|
||||||
return new Redis(config.redisUrl, {
|
return withErrorLogging(
|
||||||
maxRetriesPerRequest: null,
|
new Redis(config.redisUrl, { maxRetriesPerRequest: null }),
|
||||||
})
|
"subscriber",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,16 +75,38 @@ function parseBucketConfig(value: unknown, fallback: BucketConfig): BucketConfig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桶参数的进程内缓存。
|
||||||
|
*
|
||||||
|
* 限流点在提交判题、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> {
|
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]
|
const fallback = throttlingDefaults[scope]
|
||||||
|
let value: BucketConfig
|
||||||
try {
|
try {
|
||||||
const values = await getOptions(["throttling"])
|
const values = await getOptions(["throttling"])
|
||||||
const throttling = values.throttling
|
const throttling = values.throttling
|
||||||
if (!throttling || typeof throttling !== "object" || Array.isArray(throttling)) return fallback
|
value = !throttling || typeof throttling !== "object" || Array.isArray(throttling)
|
||||||
return parseBucketConfig((throttling as Record<string, unknown>)[scope], fallback)
|
? fallback
|
||||||
|
: parseBucketConfig((throttling as Record<string, unknown>)[scope], fallback)
|
||||||
} catch {
|
} catch {
|
||||||
|
// 读不到就退回默认值,但**不写缓存** —— 数据库抖一下不该让接下来一整分钟
|
||||||
|
// 全站都按默认参数限流
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
bucketCache.set(scope, { value, expiresAt: Date.now() + BUCKET_CACHE_TTL })
|
||||||
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ConsumeResult = { allowed: true } | { allowed: false; wait: number }
|
export type ConsumeResult = { allowed: true } | { allowed: false; wait: number }
|
||||||
|
|||||||
Reference in New Issue
Block a user