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。
This commit is contained in:
@@ -26,19 +26,172 @@ const HEAVY_PATTERNS = [
|
||||
/^\/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
|
||||
);
|
||||
|
||||
let active = 0;
|
||||
// 🔴 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) {
|
||||
if (!HEAVY_PATTERNS.some((re) => re.test(ctx.path))) {
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user