Node.js下载Cloud Storage文件夹及子文件时代码挂起问题
GCS存储桶文件夹下载程序挂起问题排查与修复
核心故障原因
- 无限制并发触发资源耗尽:代码中通过
files.map直接为所有文件发起并发下载请求,没有做并发数控制。当文件夹下文件数量较多时,会瞬间打满Node.js进程的TCP连接池、操作系统文件句柄上限,GCS服务端也会对过量并发请求做限流排队,导致后续请求无响应、进程挂死。 - 分页逻辑缺失:调用
getFiles时设置了autoPaginate: false,但没有手动处理分页游标,默认最多只会拉取单页(通常1000个)的文件列表,不仅无法下载全量文件,单页内的大文件并发请求很容易直接堵死连接。 - 无效参数触发客户端异常:调用
storage.bucket(bucketName).file()时错误传入了optionsGetFiles(仅用于列举文件的prefix配置)作为第二个参数,该参数不属于file方法的合法配置项,可能触发客户端内部未捕获的静默异常,导致对应promise永远处于pending状态。 - 错误处理不完善:下载请求的reject分支仅打印日志,没有做超时兜底、状态标记,只要有一个请求因为网络、限流原因卡住无返回,
Promise.all就会永久等待,表现为程序挂起无响应。 - 冗余IO增加阻塞风险:每遍历一个文件就调用一次
fs.mkdirSync同步创建目录,大量重复的同步IO调用会阻塞Node.js事件循环,拖慢整体执行效率。
可直接落地的修复方案
- 开启文件列表自动分页,拉取全量文件清单
- 移除file方法调用时传入的无效配置参数
- 增加并发控制,将同时下载的并发数限制在5~10区间(可根据网络带宽调整)
- 为每个下载请求增加超时兜底,避免单个请求卡死整个流程
- 优化目录创建逻辑,使用异步IO方法减少事件循环阻塞
修复后的参考代码:
import path from 'path'; import fs from 'fs/promises'; import { createWriteStream } from 'fs'; import { pipeline } from 'stream/promises'; import { Storage } from '@google-cloud/storage'; const storage = new Storage(); // 可根据实际网络带宽、服务器配置调整并发数 const concurrencyLimit = 8; let runningCount = 0; const taskQueue = []; async function runTask(task) { runningCount++; try { await task(); } catch (err) { console.error('Task run error:', err); } finally { runningCount--; if (taskQueue.length > 0) { const nextTask = taskQueue.shift(); runTask(nextTask); } } } function addTask(task) { if (runningCount < concurrencyLimit) { runTask(task); } else { taskQueue.push(task); } } export async function downloadFolder(bucketName, srcFolder) { // 开启自动分页,拉取全量文件列表 const optionsGetFiles = { prefix: `${srcFolder}/`, autoPaginate: true }; const [files] = await storage.bucket(bucketName).getFiles(optionsGetFiles); const baseLocalPath = path.resolve("./utils/buckets"); const downloadTasks = []; for (const file of files) { // 跳过目录占位对象 if (file.name.endsWith('/')) continue; const destPath = path.join(baseLocalPath, file.name); const destDir = path.dirname(destPath); // 异步创建目录,避免阻塞事件循环 await fs.mkdir(destDir, { recursive: true }); // 封装单个下载任务,加入并发控制队列 const task = () => new Promise((resolve) => { // 15秒超时兜底,避免请求永久挂起 const timeout = setTimeout(() => { console.log(`Download timeout: ${file.name}`); resolve(false); }, 15000); // 移除file方法的无效传参,用流方式下载更适配大文件场景 const fileStream = storage.bucket(bucketName).file(file.name).createReadStream(); const writeStream = createWriteStream(destPath); pipeline(fileStream, writeStream) .then(() => { clearTimeout(timeout); console.log(`File downloaded: ${file.name}`); resolve(true); }) .catch((err) => { clearTimeout(timeout); console.log(`Error on file: ${file.name} | ${err.message}`); resolve(false); }); }); downloadTasks.push(new Promise((resolve) => { addTask(() => task().then(resolve)); })); } await Promise.all(downloadTasks); // 等待队列剩余任务全部执行完成 while (runningCount > 0 || taskQueue.length > 0) { await new Promise(r => setTimeout(r, 100)); } console.log(`All files downloaded`); return true; }
额外排查提示
如果修复后仍偶发挂起,可在启动Node.js进程时添加--trace-warnings参数,打印TCP连接、句柄泄漏的相关日志,定位具体卡住的请求点。
内容的提问来源于stack exchange,提问作者DamDam
相关产品推荐
相关产品推荐

