'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)而非崩溃」。 const HEAVY_PATTERNS = [ /^\/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--; } }; };