From ec1509c46d2aa2eea4b8caaa3531107cf550dd14 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 7 Sep 2026 07:41:09 -0600 Subject: [PATCH] =?UTF-8?q?perf(=E9=99=90=E6=B5=81):=20=E6=A1=B6=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=8A=A0=2060=20=E7=A7=92=E8=BF=9B=E7=A8=8B=E5=86=85?= =?UTF-8?q?=E7=BC=93=E5=AD=98=EF=BC=9BRedis=20=E8=BF=9E=E6=8E=A5=E8=A1=A5?= =?UTF-8?q?=20error=20=E7=9B=91=E5=90=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01XvmqDsZNyUo9P3sFQtoWVB --- apps/api/src/redis.ts | 40 ++++++++++++++++++++++------- apps/api/src/services/throttling.ts | 26 +++++++++++++++++-- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/apps/api/src/redis.ts b/apps/api/src/redis.ts index 5e9f9c7..4012bd4 100644 --- a/apps/api/src/redis.ts +++ b/apps/api/src/redis.ts @@ -2,18 +2,40 @@ import Redis from "ioredis" 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() { - return new Redis(config.redisUrl, { - maxRetriesPerRequest: null, - }) + return withErrorLogging( + new Redis(config.redisUrl, { maxRetriesPerRequest: null }), + "blocking", + ) } export function createSubscriberRedis() { - return new Redis(config.redisUrl, { - maxRetriesPerRequest: null, - }) + return withErrorLogging( + new Redis(config.redisUrl, { maxRetriesPerRequest: null }), + "subscriber", + ) } diff --git a/apps/api/src/services/throttling.ts b/apps/api/src/services/throttling.ts index 3ac5fc5..b442da5 100644 --- a/apps/api/src/services/throttling.ts +++ b/apps/api/src/services/throttling.ts @@ -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 { + 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 - if (!throttling || typeof throttling !== "object" || Array.isArray(throttling)) return fallback - return parseBucketConfig((throttling as Record)[scope], fallback) + value = !throttling || typeof throttling !== "object" || Array.isArray(throttling) + ? fallback + : parseBucketConfig((throttling as Record)[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 }