You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.17 01:30:47