Files
competition-website/app/middleware/concurrency_guard.js

229 lines
14 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
// ==================== 请求级并发闸门(大文件上传/下载) ====================
// 背景(2026-08-11 压测结论):
// 场景「无带宽限制 + 单 worker + 极端并发」(大量考生同时上传大文件 +
// 管理后台同时下载大文件)下,单 worker 即使有文件 IO 护栏(file_io_guard),
// 仍会在「被接受的并发重请求数」过高时被海量大文件流压垮(OOM 被系统杀掉,
// 无任何 Node 异常日志)。原因是 file_io_guard 只限制「同时进行的文件流」,
// 而 90 个并发请求的连接已先被 TCP 接受、请求体已在传输中。
//
// 本闸门在「读取请求体之前」对重 IO 接口做全局并发上限:
// - 命中重路由且 active < MAX:放行(active++),请求结束(finally)后 active--;
// - 命中重路由且 active >= MAX:立即回 429(连接级背压),请求体不会被解析/缓冲,
// 由前端 uploadWithRetry / 下载侧重试指数退避重试 —— 保证后端存活而非雪崩。
// - 非重路由:直接放行,不影响登录/统计等轻接口。
//
// 与 file_io_guard 互补:本闸门限制「被接受的并发重请求数」(防连接/内存被打满),
// file_io_guard 限制「同时进行的文件流数(按流生命周期释放)」(防 fd/事件循环耗尽)。
// 双保险,使单 worker 在极端并发下「降级(429)而非崩溃」。
// 🔴 单次流式上传(2026-09-29 · R43 补齐,原为**零护栏**)
//
// 背景:R42 排查 C5(作品上传错峰)时发现 —— **在用的**上传端点 `POST /api/upload/:group`
// (router.js:101 → controller/upload.js 的 upload())**不在本表**,因此它既不经过本闸门,
// 当时也未调用 file_io_guard ⇒ 真正承载考生作品上传的唯一路径**完全无护栏**。
// 同表内 `chunk`/`merge` 反倒是**前端已无调用点的死路由**(Upload.vue 于 B2 删除后
// 失去唯一调用方,见 api/upload.js 的死代码清理),护栏加在了无人走的路上。
//
// 性质判定:单次上传是**重 IO**(长连接 + 大文件流式落盘 + 边写边算 md5),
// 与 chunk/merge 同类,故**共享同一配额 MAX**(不另开独立上限)——
// 配额的意义是「让 worker 有余力 accept 新连接」(见本文件顶部背景),
// 三者共享 20 比各自开 20 更能守住这个目的。
//
// ⚠️ 正则为何必须是**单段** `[^/]+$`(勿"顺手"放宽成 `/^\/api\/upload\//i`):
// 同一前缀下还有三条**轻**路由,路径同前缀但**多一段**:
// · GET /api/upload/:group/status (查状态,轻)
// · GET /api/upload/:group/submissions (列表,轻)
// · DELETE /api/upload/:group/:id (删作品,轻)
// `[^/]+$` 要求「/api/upload/」之后**恰好一段且到串尾**,故上面三条(两段)**天然不命中**;
// 放宽成前缀匹配会把它们一并计入配额,在考生端表现为「查状态也 429」——语义错误。
// 本表的 `chunk`/`merge` 同理带 `$` 锚定(既有写法,勿动)。
const HEAVY_PATTERNS = [
/^\/api\/upload\/[^/]+$/i,
/^\/api\/upload\/[^/]+\/chunk$/i,
/^\/api\/upload\/[^/]+\/merge$/i,
/^\/api\/exam\/[^/]+\/word$/i,
/^\/api\/admin\/submission-file\//i,
/^\/api\/admin\/submissions\/[^/]+\/[^/]+\/word$/i,
];
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
// 🔴 交卷闸门(2026-09-21 并发压测结论)
//
// 背景:submit-all 不在 HEAVY_PATTERNS 内,因此交卷高峰**完全无闸门**。压测实测:
// 400 并发同刻交卷 → 约 20% 考生在 TCP accept 层被拒(客户端 WinError 10061),
// 服务端零业务错误、零崩溃。根因是单 worker 的 accept 循环被重任务(一次交卷≈百次
// DB 往返)饿死 → 内核 listen 队列溢出 → **Windows 在队列满时回 RST**
// (Linux 是静默丢 SYN,所以本机现象与 Linux 经验相反,极易误判为「网络问题」)。
//
// 纳入闸门的意义:过载时由「静默 RST(考生不知道是否交上)」变为「明确 429
// + Retry-After(可识别、可重试)」,配合前端交卷自动重试即可收敛;
// 同时闸门在**读取请求体之前**拒绝,避免请求体缓冲进一步挤占内存。
//
// ⚠️ 交卷使用**独立上限**,不与上传共用 20:
// - 交卷是纯 DB 重任务、无文件流,与上传的大文件 IO 特性不同,共用会过紧;
// - 但上限的作用是「让 worker 有余力 accept 新连接」,**不是**提高吞吐 ——
// 实测单 worker 硬容量约 15 交卷/秒,把上限开到几百只会让每个请求都变慢,
// 反而更容易饿死 accept。默认 24 ≈ 略高于单 worker 舒适并发,形成良性背压。
// - 多 worker 部署时总上限 = worker 数 × SUBMIT_REQ_MAX(进程内计数)。
const SUBMIT_PATTERNS = [
/^\/api\/exam\/[^/]+\/submit-all$/i,
];
// 🔴 登录闸门(2026-09-21 并发压测**第二轮**结论)
//
// 背景:submit-all 已于同日纳入闸门(见上),但**登录链路当时仍无闸门** ——
// 压测实测(每组 400 人 = 800 人同刻登录):
// · 仅 484/800 取得 token → **39.5% 在 TCP accept 层被拒**(WinError 10061);
// · 连接级重试 1134 次;
// · **P50 = 9957ms、P95 = 24350ms** —— 考生点「登录」后要干等十秒以上,
// 且近四成直接失败(客户端只看到「无法连接」,不知道是否要重试)。
// 根因与交卷侧**同源**:单 worker 的 accept 循环被重任务饿死 → accept 队列溢出
// → Windows 回 RST(Linux 静默丢 SYN,故 Linux 生产下表现为客户端长时间超时)。
// 按「同源族一并修」原则纳入闸门。
//
// 为何登录需要**独立**上限(不与交卷共用 24):
// - 登录是 **CPU 型**重任务:单请求的绝大部分成本是口令哈希比对
// (BCRYPT_ROUNDS=10,见 extend/helper.js),另加 2~3 次 DB 往返
// (findByPhone + isScorePublished);
// - 交卷是 **IO 型**重任务(约 23 条 SQL 往返),等待 DB 期间可让位给 accept;
// CPU 密集任务的换手粒度更粗,对 accept 的挤压更直接;
// - 故默认取 16(低于交卷的 24)。上限的作用是**保命**(让 worker 有余力
// accept 新连接),**不是**提速 —— 把上限开大只会让每个请求都变慢。
//
// 🔴 R8(2026-09-21)后的状态变化 —— 闸门保留,但**理由已改变**(勿照旧文本理解):
// 口令哈希实现已由 `bcryptjs`(纯 JS,**同步阻塞主线程**)换为**原生 bcrypt**
// (N-API,走 libuv 线程池)。实测:16 并发 compare 由 1129ms → 265ms(4.20×);
// 改动前**心跳达成率 0%**(主线程被冻结 1.1s,期间无法 accept 任何连接),
// 改动后心跳持续(环境实测见 probes/_bench_eventloop.cjs)。
// ⇒ "accept 被 CPU 计算饿死"这一**原始**理由已大幅减弱,但闸门**仍然必要**:
// ① libuv 线程池默认仅 **4** 线程,16 并发仍需排队;长时间满载会挤压**其它**
// 走线程池的工作(DNS / fs / crypto)—— 闸门把排队**限界**,避免无界堆积;
// ② 每个登录请求另有 2~3 次 DB 往返,是**连接池与 DB 的真实压力源**,
// 与哈希是否阻塞事件循环**无关**;
// ③ 过载时给出**可识别**的 429 + Retry-After,优于 TCP 层静默 RST
// (考生至少知道"要重试"而非"不知道登没登上")。
// ⛔ 因此**不要**因为"换了原生 bcrypt"就顺手删掉本闸门 —— 理由②③与哈希实现无关。
// - 多 worker 部署时总上限 = worker 数 × LOGIN_REQ_MAX(进程内计数)。
//
// ⛔ 为何**不**把 /api/auth/admin-login 一并纳入(重要设计决策,勿"顺手"补上):
// 管理后台登录是极低频瞬时动作(并发个位数),**从不构成 accept 压力源**,
// 因此它不属"同源缺陷族"(缺陷族 = 同一根因 **且** 同一压力形态);
// 而一旦纳入共用/独立计数,考生入场洪峰会挤占管理员配额 →
// 制造出「考试期间管理员登不进后台」这一**新**故障面,得不偿失。
// 其防爆破由 router 级 loginRateLimit 单独负责(该中间件同时挂在两条登录路由上)。
//
// 🔴 与 loginRateLimit 的执行顺序(本改动的关键红线,勿破坏):
// 本中间件是**全局**中间件(config/config.default.js 的 config.middleware),
// 而 loginRateLimit 是 **router 级**(router.js 挂在 /api/auth/login 单条路由上);
// Egg 的请求链为「全局中间件 → router 匹配 → router 级中间件」,
// 因此本闸门**先于** loginRateLimit 执行 —— 一旦在此处返回 429,
// loginRateLimit 的 `await next()` **根本不会执行**,其"按失败计数"逻辑不生效。
// 这是**必须**的性质:否则考生每被背压拒绝一次就被记一次失败,
// 重试 10 次即撞上 15 分钟账号锁(业务码 4290)—— 那比「静默 RST」更糟(锁死)。
// ⚠️ 后人若把本中间件降为 router 级、或把登录判断挪到 `await next()` 之后,
// 会立刻破坏该性质。门禁 scripts/verify_login_guard.py 对此有专项断言。
const LOGIN_PATTERNS = [
/^\/api\/auth\/login$/i,
];
module.exports = (options, app) => {
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
const SUBMIT_MAX = parseInt(
process.env.SUBMIT_REQ_MAX || (options && options.submitMax) || '24',
10
);
const LOGIN_MAX = parseInt(
process.env.LOGIN_REQ_MAX || (options && options.loginMax) || '16',
10
);
// 🔴 Retry-After 取值依据(2026-09-21 R8 实测,勿凭直觉调大)
//
// 原为**三处各自硬编码 `'2'`** 的魔数(无依据、无同源保证)。经实测给出依据:
//
// 一个「闸门满载窗口」的服务时间 = 并发上限 × 单请求 compare 耗时:
// · 改动前 bcryptjs(纯 JS,主线程**串行**):16 × ~85ms ≈ 1.11 s
// (实测 16 并发 compare = 1092~1133 ms,期间**心跳达成率 0%**,主线程完全冻结)
// · 改动后 原生 bcrypt(N-API + libuv 线程池):实测 265 ms
//
// 客户端应在**一个满载窗口之后**再回来:窗口结束时槽位集中释放,命中概率最高;
// 过早回来只会撞墙并制造无谓往返(臂2 实测登录侧请求 +14%、重试 +28%)。
//
// ⇒ 取 **2s**:≈1.8 个满载窗口(改动前)/ ≈7.5 个(改动后),足以覆盖一次拥挤周期,
// 又不过长以免挤掉前端后续的指数退避重试机会(前端 cap 15s / 总窗口 ~45s)。
//
// ⛔ **不要盲目调大(如 5–8s)**:本闸门是「**立即拒绝**」(无排队),槽位是**连续释放**的,
// 等待过久会**错过本可成功的时机**,并让总收敛时间超过前端重试窗口 ⇒ 反而更多失败。
// ⛔ **也不要 < 1s**:退化为忙等,把连接级背压变成新的请求风暴。
// ⛔ **三处必须同源同值**:登录/交卷/上传的差异只体现在**配额**(LOGIN_MAX / SUBMIT_MAX / MAX),
// 不体现在等待时长上。历史上此值散落三处,改一处漏两处即产生"同源族漂移"。
const RETRY_AFTER = '2';
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
let active = 0; // 大文件/重 IO 并发数
let submitActive = 0; // 交卷并发数(与上者独立计数,互不挤占配额)
let loginActive = 0; // 登录并发数(独立计数:登录为 CPU 型重任务,与上两者特性均不同)
return async function concurrencyGuard(ctx, next) {
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
const path = ctx.path;
// ---- 登录(独立配额,2026-09-21 第二轮压测新增)----
if (LOGIN_PATTERNS.some((re) => re.test(path))) {
if (loginActive >= LOGIN_MAX) {
// 连接级背压:把「TCP 层静默 RST(考生不知是否登录成功)」换为
// 「明确 429 + Retry-After(可识别、客户端可定时重试)」。
// 注意:此处直接 return,故 router 级 loginRateLimit 不执行 → 不计失败次数。
ctx.status = 429;
ctx.set('Retry-After', RETRY_AFTER);
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(登录高峰)');
return;
}
loginActive++;
try {
await next();
} finally {
loginActive--;
}
return;
}
const isSubmit = SUBMIT_PATTERNS.some((re) => re.test(path));
if (isSubmit) {
if (submitActive >= SUBMIT_MAX) {
// 连接级背压:明确告知「服务器忙 + 多久后重试」,而非让请求堆积到 accept 队列溢出。
ctx.status = 429;
ctx.set('Retry-After', RETRY_AFTER);
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试(交卷高峰)');
return;
}
submitActive++;
try {
await next();
} finally {
submitActive--;
}
return;
}
if (!HEAVY_PATTERNS.some((re) => re.test(path))) {
return next();
}
if (active >= MAX) {
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
ctx.status = 429;
feat(exam/auth): 登录闸门 + 交卷稳定性加固(R4-R8) R4 登录/交卷背压闸门 - app/middleware/concurrency_guard.js: 新增。超限即 429 + Retry-After(立即拒绝式, 不排队;实测 429 P50=34ms)。RETRY_AFTER 收敛为单一有依据常量 = '2' (≈1.8 个满载窗口;实测 bcryptjs 1129ms / 原生 265ms 每窗口)。 配额:LOGIN_MAX=16 / SUBMIT_MAX=24 / HEAVY_REQ_MAX=20,各 *REQ_MAX 覆盖。 - config/config.default.js: 闸门配额与连接池容量配置;DB dialect 锁 mysql。 R7 顺序红线 + R8 Retry-After 同源 + 原生 bcrypt - 3 处硬编码 '2' 收敛为单一常量;补"勿因换实现而删闸门"红线注释。 - bcryptjs -> bcrypt@6(N-API,Windows/Linux 均免编译;drop-in,存量哈希 $2b$10$ 兼容)。 - app/model/account.js / app/service/auth.js: 同步哈希实现注释(D4 全族 5 处陈旧 SHA256 注释修正)。 答题链路与综合大题 - app/controller/exam.js: 中职/高职综合大题接口(GET big-question / POST big-answer, 逐问 2000 字上限校验);uploadWord 对中职/高职封堵(400)。 - app/service/exam.js: 抽题快照 question_snapshot 落库、答案归集键修正。 - app/controller/auth.js / app/service/auth.js: toClientUser() 唯一出口, 未发布态按组剔除 score,exam_score/word_score/ppt_score 任何情况下不下发。 - app/router.js: 新增大题与管理端路由。 - app/extend/helper.js: 下载文件名 RFC5987 编码(禁双重编码)。 - app.js: 池容量断言按 env 区分单/多 worker(防 dev 假日志)。 验证:ci-gates.sh 9/9 全绿;verify_login_guard.py 28/28(含 D 组变异 9/9); 真实登录/抽题/交卷端到端 code=0;前端构建 BUILD_RC=0。
2026-09-22 09:16:55 +08:00
ctx.set('Retry-After', RETRY_AFTER);
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
return;
}
active++;
try {
await next();
} finally {
active--;
}
};
};