Files
competition-website/app/middleware/concurrency_guard.js
qubiaobiao e8a5b34d42 fix(backend): 补齐单次上传端点的并发护栏(BACKLOG C7)
- middleware/concurrency_guard.js: HEAVY_PATTERNS 增加单段上传正则
  /^\/api\/upload\/[^/]+$/i —— 单段锚定([^/]+$)保证不误伤"两段"的轻路由:
  GET /status、GET /submissions、DELETE /:id(放宽成前缀匹配会让「查状态也 429」)
- controller/upload.js: upload() 补 await acquireFileIo() +
  finally { if (ioAcquired) releaseFileIo(); }
  ⚠️ 不用 uploadChunk 的无条件 release:本方法 429 分支是 acquire 失败后 return,
     无条件释放会把"别人的"配额放掉 => 计数下溢 => 护栏静默失效

背景:修复前该端点是**唯一在用**的作品上传路径(Competition.vue:477),却既不在
      concurrencyGuard.HEAVY_PATTERNS(表内 chunk/merge 已成死路由)、也不调 file_io_guard
      => 零护栏。因服务器入带宽仅 ≈1.25MB/s 天然节流,当前风险有限,但按 C3 升入带宽
      后即变实质风险 => 与 C3 绑定,先补护栏。

验证:
- verify-guard-r43.mjs 33/33(单元:路径判别力 + 满载 429 + 配额隔离 + 释放语义 + 变异自证)
- verify-guard-r43-live.mjs 8/9(真接口:30 路 5MB 并发上传 => 20 成功 / 10 个 429 +
  Retry-After: 2;15 个轻路由探针在并发中全 200;唯一失败为清理步骤,根因是
  saveSubmissionRecord 的 upsert 语义,非缺陷)
- 重启后 master PID 23684;[AppBoot][pool] 40 <= 151
2026-09-29 13:17:53 +08:00

229 lines
14 KiB
JavaScript
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.
'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,
];
// 🔴 交卷闸门(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);
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';
let active = 0; // 大文件/重 IO 并发数
let submitActive = 0; // 交卷并发数(与上者独立计数,互不挤占配额)
let loginActive = 0; // 登录并发数(独立计数:登录为 CPU 型重任务,与上两者特性均不同)
return async function concurrencyGuard(ctx, next) {
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;
ctx.set('Retry-After', RETRY_AFTER);
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
return;
}
active++;
try {
await next();
} finally {
active--;
}
};
};