Express+Remix应用中异步大文件复制阻塞浏览器请求问题
问题描述
我用Express.js部署Remix应用,服务端启动时设置了多个定时后台任务,其中一项任务会检查远程Jenkins构建是否完成,若完成则将多个大PDF文件从GSA上的网络路径复制到另一个路径。
我编写了创建链式glob+copyFile的Promise数组的函数:
import { copyFile } from 'node:fs/promises'; import { promisify } from "util"; import glob from "glob"; // ... async function getFiles() { let result: Promise<void>[] = []; let globPromise = promisify(glob); for (let wildcard of wildcards) { // 大量文件通配符 result.push(globPromise(wildcard).then( (files: string[]) => { if (files.length < 1) { // 错误处理逻辑 } else { for (let srcFile of files) { let tgtFile = tgtDir + basename(srcFile); return copyFile(srcFile, tgtFile); } } }, (reason: any) => { // 错误处理逻辑 })); } return result; }
另一个异步函数获取该数组并执行Promise.allSettled:
copyPromises = await getFiles(); console.log("CALLING ALLSETTLED.THEN()..."); return Promise.allSettled(copyPromises).then( (results) => { console.log("ALLSETTLED COMPLETE..."); // ...
在"CALLING"和"COMPLETE"日志消息之间的数分钟内,服务器不再响应浏览器请求,导致请求超时。但在此期间,其他后台定时器仍能正常运行(我设置了一个每5秒运行一次的测试定时器,在文件复制期间运行流畅)。这并非阻塞整个服务器,只是阻止了浏览器请求的处理,一旦"COMPLETE"日志出现,浏览器请求即可正常响应。
Express启动脚本中Remix的配置如下:
const { createRequestHandler } = require("@remix-run/express"); // ... app.all( "*", createRequestHandler({ build: require(BUILD_DIR), mode: process.env.NODE_ENV, }) );
请问这是什么原因导致的,该如何解决?
原因分析
核心问题在于Node.js的线程池资源被占满:
- Node.js的异步IO操作(包括
fs.promises.copyFile)依赖libuv的线程池,默认线程池大小为4。 - 你一次性启动大量针对网络文件系统(GSA路径)的大文件复制任务,这些IO操作会持续占用线程池里的线程。
- Express/Remix的HTTP请求处理同样依赖线程池来完成底层的网络IO(比如读取请求体、返回响应),当线程池被文件复制任务占满时,新的请求无法获得线程处理,导致请求阻塞超时。
- 而定时器(setInterval/setTimeout)属于事件循环的timers阶段,不需要占用线程池资源,因此能正常运行。
解决方案
1. 调整Node.js线程池大小
通过设置环境变量UV_THREADPOOL_SIZE,增加线程池的可用线程数,让文件复制和请求处理都能获得资源。比如启动服务时:
UV_THREADPOOL_SIZE=16 node server.js
注意:不要设置过大(比如超过CPU核心数的2倍),避免线程切换开销过高。
2. 优化文件复制逻辑
分批处理复制任务
不要一次性启动所有复制任务,而是分批执行,避免瞬间占满线程池:
import { copyFile } from 'node:fs/promises'; import { promisify } from "util"; import glob from "glob"; import { basename } from 'node:path'; async function getAllMatchingFiles() { const globPromise = promisify(glob); const allFiles: string[] = []; // 先收集所有匹配的文件路径 for (const wildcard of wildcards) { try { const files = await globPromise(wildcard); if (files.length > 0) { allFiles.push(...files); } } catch (err) { // 处理glob匹配错误 console.error(`Glob failed for ${wildcard}:`, err); } } return allFiles; } // 分批执行复制,控制同时运行的任务数 async function copyFilesInBatches(files: string[], batchSize: number = 4) { const allResults = []; for (let i = 0; i < files.length; i += batchSize) { const batch = files.slice(i, i + batchSize); const batchPromises = batch.map(srcFile => { const tgtFile = `${tgtDir}${basename(srcFile)}`; return copyFile(srcFile, tgtFile).catch(err => { // 单个文件复制失败不影响其他任务 console.error(`Copy failed for ${srcFile}:`, err); return err; }); }); const batchResults = await Promise.allSettled(batchPromises); allResults.push(...batchResults); } return allResults; } // 使用方式 const allFiles = await getAllMatchingFiles(); console.log("STARTING BATCHED FILE COPY..."); const results = await copyFilesInBatches(allFiles); console.log("ALL COPY TASKS COMPLETE...");
改用流复制大文件
对于大文件,使用流(Stream)复制可以降低内存占用,并且不会长时间占用线程池:
import { createReadStream, createWriteStream } from 'node:fs'; import { pipeline } from 'node:stream/promises'; import { basename } from 'node:path'; async function copyFileWithStream(srcFile: string, tgtDir: string) { const tgtFile = `${tgtDir}${basename(srcFile)}`; const readStream = createReadStream(srcFile); const writeStream = createWriteStream(tgtFile); try { await pipeline(readStream, writeStream); } catch (err) { console.error(`Stream copy failed for ${srcFile}:`, err); throw err; } } // 在分批逻辑中替换copyFile为copyFileWithStream即可
3. 隔离文件复制任务到独立进程
如果上述优化仍无法满足需求,可以将文件复制任务放到独立的子进程中运行,彻底与主进程的请求处理隔离:
// 主进程中启动子进程 import { fork } from 'node:child_process'; function startCopyTask() { const copyProcess = fork('./copy-files.js'); copyProcess.on('message', (msg) => { console.log(`Copy task status: ${msg.status}`); if (msg.status === 'complete') { // 处理任务完成逻辑 } }); copyProcess.on('error', (err) => { console.error('Copy process error:', err); }); copyProcess.on('exit', (code) => { console.log(`Copy process exited with code ${code}`); }); } // copy-files.js 子进程文件 async function runCopyTask() { // 这里放原来的文件收集和复制逻辑 const allFiles = await getAllMatchingFiles(); const results = await copyFilesInBatches(allFiles); process.send({ status: 'complete', results }); process.exit(0); } runCopyTask().catch(err => { console.error('Copy task failed:', err); process.send({ status: 'failed', error: err.message }); process.exit(1); });
内容的提问来源于stack exchange,提问作者Ernest Crvich
相关产品推荐
相关产品推荐

