2026-08-17 12:25:42 +08:00
|
|
|
|
'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-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,
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2026-08-17 12:25:42 +08:00
|
|
|
|
module.exports = (options, app) => {
|
|
|
|
|
|
const MAX = parseInt(process.env.HEAVY_REQ_MAX || (options && options.max) || '20', 10);
|
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';
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
2026-09-22 09:16:55 +08:00
|
|
|
|
let active = 0; // 大文件/重 IO 并发数
|
|
|
|
|
|
let submitActive = 0; // 交卷并发数(与上者独立计数,互不挤占配额)
|
|
|
|
|
|
let loginActive = 0; // 登录并发数(独立计数:登录为 CPU 型重任务,与上两者特性均不同)
|
2026-08-17 12:25:42 +08:00
|
|
|
|
|
|
|
|
|
|
return async function concurrencyGuard(ctx, next) {
|
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))) {
|
2026-08-17 12:25:42 +08:00
|
|
|
|
return next();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (active >= MAX) {
|
|
|
|
|
|
// 🔴 连接级背压:直接拒绝,避免请求体被解析/缓冲占用内存
|
|
|
|
|
|
ctx.status = 429;
|
2026-09-22 09:16:55 +08:00
|
|
|
|
ctx.set('Retry-After', RETRY_AFTER);
|
2026-08-17 12:25:42 +08:00
|
|
|
|
ctx.body = ctx.helper.error(429, '服务器繁忙,请稍后重试');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
active++;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await next();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
active--;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|