- 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
229 lines
14 KiB
JavaScript
229 lines
14 KiB
JavaScript
'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--;
|
||
}
|
||
};
|
||
};
|