chore: 后端首次基线提交
运行必须源码:app/(controller/model/service/middleware/lib/utils/validate/router)、config/、database/(migrations/seeders/sql)、启动脚本、app/public/templates 模板。 已忽略运行非必须:node_modules/、logs/、run/、app/public 运行时上传数据、.env*、构建/缓存产物。
This commit is contained in:
83
app/lib/file_io_guard.js
Normal file
83
app/lib/file_io_guard.js
Normal file
@@ -0,0 +1,83 @@
|
||||
'use strict';
|
||||
|
||||
// ==================== 文件 IO 并发护栏 ====================
|
||||
// 背景(2026-08-11 压测结论):
|
||||
// 场景「无带宽限制 + 单 worker + 极端并发」(大量考生持续上传大文件 +
|
||||
// 管理后台同时持续下载大文件)下,单 worker 的 fd / 事件循环会被海量
|
||||
// 并发文件流(分片写入、分片合并、Word 写入)压垮,触发
|
||||
// MaxListenersExceededWarning 并最终雪崩。
|
||||
//
|
||||
// 本护栏把「同时进行的文件 IO 操作数」限制在上限内:
|
||||
// - 未超限:立即放行,计数 +1;
|
||||
// - 已超限:进入有界队列等待(提供连接级背压,而非崩溃);
|
||||
// - 队列满 / 等待超时:抛出 busy 错误,调用方返回 429,由前端
|
||||
// uploadWithRetry / 下载侧重试指数退避重试——保证后端存活。
|
||||
//
|
||||
// 覆盖范围(同一全局配额,按进程单例共享):
|
||||
// - 上传:分片写入、分片合并、Word 写入(controller/upload.js、exam.js)
|
||||
// - 下载:管理后台作品/Word 下载(controller/admin.js 的 _sendFile)
|
||||
// 生产经 X-Accel-Redirect 卸载给 nginx,Node 仅发头、瞬间释放配额;
|
||||
// 开发回退 Node 流式时,配额随「流生命周期」(end/close/error)释放。
|
||||
|
||||
const LIMIT = parseInt(process.env.FILE_IO_CONCURRENCY || '20', 10);
|
||||
const MAX_QUEUE = parseInt(process.env.FILE_IO_MAX_QUEUE || '300', 10);
|
||||
const WAIT_TIMEOUT = parseInt(process.env.FILE_IO_WAIT_TIMEOUT || '8000', 10);
|
||||
|
||||
let active = 0;
|
||||
const queue = [];
|
||||
|
||||
function remove(entry) {
|
||||
const i = queue.indexOf(entry);
|
||||
if (i !== -1) queue.splice(i, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取一个文件 IO 配额。
|
||||
* 成功(或排到队首被放行)resolve;队列满 / 超时则 reject({ busy: true })。
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function acquire() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (active < LIMIT) {
|
||||
active++;
|
||||
return resolve();
|
||||
}
|
||||
if (queue.length >= MAX_QUEUE) {
|
||||
const err = new Error('FILE_IO_BUSY');
|
||||
err.busy = true;
|
||||
return reject(err);
|
||||
}
|
||||
let timer = null;
|
||||
const entry = {
|
||||
run() {
|
||||
if (timer) clearTimeout(timer);
|
||||
active++;
|
||||
resolve();
|
||||
},
|
||||
cancel() {
|
||||
if (timer) clearTimeout(timer);
|
||||
const err = new Error('FILE_IO_BUSY');
|
||||
err.busy = true;
|
||||
reject(err);
|
||||
},
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
remove(entry);
|
||||
entry.cancel();
|
||||
}, WAIT_TIMEOUT);
|
||||
queue.push(entry);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放一个文件 IO 配额(须在 finally 中调用)。
|
||||
*/
|
||||
function release() {
|
||||
if (active > 0) active--;
|
||||
if (active < LIMIT && queue.length) {
|
||||
const entry = queue.shift();
|
||||
entry.run();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { acquire, release, LIMIT, MAX_QUEUE, WAIT_TIMEOUT };
|
||||
47
app/lib/file_path_cache.js
Normal file
47
app/lib/file_path_cache.js
Normal file
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 下载前 file_path 短缓存(进程内,默认 30s)。
|
||||
*
|
||||
* 收益:高并发下载同一文件时,避免每次请求都回查 DB 取 file_path / file_name。
|
||||
*
|
||||
* 取舍(任务 #23 已确认):文件替换 / 删除后,≤ TTL 内可能命中旧路径。
|
||||
* - 下游 _sendFile 仍按实际文件做 fs.existsSync 校验,旧路径不存在 → 404,不会吐错误内容;
|
||||
* - 被替换文件的旧路径在 TTL 内至多吐出旧版本一小段时间(非安全敏感、非实时场景,可接受)。
|
||||
* - 因此本缓存为「只读穿透 + TTL 自愈」,不强制接入上传/删除失效,
|
||||
* 以最小化改动面与回归风险;如需更强一致可后续在上传/删除成功处调用 invalidate()。
|
||||
*
|
||||
* 键约定:调用方自行拼装唯一 key,如 `word:${group}:${id}` / `sub:${group}:${id}`。
|
||||
*/
|
||||
const TTL_MS = 30 * 1000;
|
||||
const store = new Map(); // key -> { filePath, fileName, expire }
|
||||
|
||||
function _now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function getFull(key) {
|
||||
if (!key) return null;
|
||||
const hit = store.get(key);
|
||||
if (!hit) return null;
|
||||
if (hit.expire < _now()) {
|
||||
store.delete(key);
|
||||
return null;
|
||||
}
|
||||
return { filePath: hit.filePath, fileName: hit.fileName };
|
||||
}
|
||||
|
||||
function setFull(key, filePath, fileName) {
|
||||
if (!key || filePath == null || filePath === '') return;
|
||||
store.set(key, {
|
||||
filePath: String(filePath),
|
||||
fileName: fileName == null ? '' : String(fileName),
|
||||
expire: _now() + TTL_MS,
|
||||
});
|
||||
}
|
||||
|
||||
function invalidate(key) {
|
||||
if (key) store.delete(key);
|
||||
}
|
||||
|
||||
module.exports = { getFull, setFull, invalidate, TTL_MS };
|
||||
Reference in New Issue
Block a user