解决大数据导出时阻塞其他接口的问题

📅 2026/8/20 10:11:41
解决大数据导出时阻塞其他接口的问题
理解概念IOInput/Output数据写入写出包括但不限于磁盘文件读写数据库查询发送网络请求等CPU中央处理器核心任务就是解释计算机指令处理软件中的数据所有的操作都是CPU来执行计算的Node.js是单线程事件循环有一个主线程当主线程被占用的时候其他任务都要排队等待调度CPU密集型任务指包含复杂计算的任务会全程占据主线程IO密集型任务只会占据发起、回调处理的一瞬间问题项目数据量大全量导出不分页导致在导出的时候调用接口一直在加载且期间刷新页面、搜索数据的请求都会排队加载直到导出调用结束。解决问题的思路1.分页2.流式写入文件流式导出3.异步导出由于需求和当前代码的原有逻辑决定选择彻底的异步导出抽离耗时的查询和计算逻辑生成taskId把任务号响应给前端点击导出时调用导出接口把查数据库、构建、生成Excel文件的逻辑全部抽离出去变成一个单独的generateFile方法在调用导出时只需要第一时间创建一个独一无二的任务号taskId创建任务将任务号和任务状态存入内存或者redis对性能要求高就用redis// 如果是导出请求立即返回 taskId不查数据库 if (download) { const taskId ${new Date().getTime()} exportTaskManager.createTask(taskId) ctx.status 200 ctx.body { taskId } // 立即让出当前进程前端立刻响应真正的查询构建任务放到下一个进程 setImmediate( () generateExportFile(taskId) ) return }封装任务管理器管理任务号和任务状态完整任务管理器应该包含创建任务修改任务获取任务删除任务过期、失败、成功后自动清理任务的功能use strict // 导出任务管理器 // 核心结构 taskId - { status, fileName, error, createdAt } const taskMap new Map() // 清理定时器存储 const cleanupTimers new Map() // 清理单个任务删除记录 删除文件 const removeTask (taskId) { const task taskMap.get(taskId) if (task) { // 删除文件 if (task.filePath fs.existsSync(task.filePath)) { fs.unlink(task.filePath, (err) { if (err) console.error(删除导出文件失败: ${task.filePath}, err) }) } // 删除任务记录 taskMap.delete(taskId) // 清理定时器 if (cleanupTimers.has(taskId)) { clearTimeout(cleanupTimers.get(taskId)) cleanupTimers.delete(taskId) } } } // 创建任务 const createTask (taskId) { taskMap.set(taskId, { status: pending, filePath: null, fileName: null, error: null, createdAt: new Date() }) } // 更新任务状态合并更新不覆盖整个对象 const updateTask (taskId, update) { const task taskMap.get(taskId) if (task) { Object.assign(task, update) // 任务完成成功或失败后设置自动清理定时器 if (update.status success || update.status failed) { // 10 分钟后自动清理 const timer setTimeout(() removeTask(taskId), 10 * 60 * 1000) cleanupTimers.set(taskId, timer) } } } // 获取任务状态 const getTask (taskId) { return taskMap.get(taskId) } // 下载成功后立即清理 const cleanupAfterDownload (taskId) { removeTask(taskId) } // 清理所有过期任务防止内存泄漏 const cleanExpiredTasks (maxAge 30 * 60 * 1000) { const now new Date() for (const [taskId, task] of taskMap.entries()) { if (now - task.createdAt maxAge) { removeTask(taskId) } } } module.exports { createTask, updateTask, getTask, cleanupAfterDownload, cleanExpiredTasks, removeTask }setImmediate让出当前循环立即响应前端generateExportFile方法包含了查询数据库构建文件处理数据等计算功能用setImmediate让出当前事件循环async function generateExportFile(taskId, queryParams) { try { // 主线程数据库查询异步不阻塞 let findOption { // 查询条件... } const rslt await xxx.findAndCount(findOption) // 主线程构建导出数据创建普通对象相对较快 const header [ { title: 产品编码, key: code }, { title: 产品名称, key: name }, { title: 规格型号, key: productNo }, ... ] const exportData [] for (const d of rslt.rows) { exportData.push({ code: d.code, name: d.name, productNo: d.productNo, ... }) } const fileName xxx列表_${moment().format(YYYYMMDDHHmmss)}.xlsx // 生成Excel文件 // 假设已经拿到了 exportData, header, fileName const savePath path.join(__dirname, ../../downloadFiles, fileName); // 1. 创建文件对象 const file new xlsx.File(); const sheet file.addSheet(sheet_1); // 2. 写标题 const headerRow sheet.addRow(); header.forEach(h { const c headerRow.addCell(); c.value h.title; }); // 3. 写数据 exportData.forEach(item { const row sheet.addRow(); header.forEach(h { const c row.addCell(); c.value item[h.key] ?? ; }); }); // 4. 保存到硬盘 await new Promise((resolve, reject) { file.saveAs() .pipe(fs.createWriteStream(savePath)) .on(finish, resolve) .on(error, reject); }); // 生成成功后改变任务状态 exportTaskManager.updateTask(taskId, { status: success, filePath, fileName }) } catch (error) { app.fs.logger.error(export task ${taskId} failed: ${error}) exportTaskManager.updateTask(taskId, { status: failed, error: error.message }) } }改完之后发现依旧会阻塞定位问题不是前端轮巡次数过多占满了请求口也不是任务号返回阻塞就是generateFile方法过慢且在它完成前别的操作都只能等待所以查询数据库表构建全量数据生成Excel文件循环计算等等操作就是CPU密集型任务会占据主线程哪怕已经用setImmediate把generateFile方法抽出去让出当前事件循环也不能解决因为setImmediate只是把任务放到了下一个事件循环而下一个循环依旧会被generateFile阻塞其中生成excel文档到本地的操作及其耗费CPU性能只能继续改循环的过程中数据量越大就越慢考虑过在遍历构建的过程中限制每100条就setImmediate把剩余内容放到下一个循环让出处理其他请求的口子但是效果依旧不理想只能考虑彻底的异步操作把整个generate方法放到主线程外。用worker_threats新建工作线程将生成excel文件放到工作线程里去use strict; /** * Excel 导出 Worker 线程 * * 在独立的 Worker 线程中执行 Excel 文件生成避免阻塞主线程。 * 主线程负责数据库查询异步非阻塞Worker 负责 CPU 密集型的数据处理和文件生成。 * * 通信协议 * - 主线程发送{ type: generate, data, header, fileName, fileType } * - Worker 回复{ type: success, filePath } 或 { type: error, error } */ const { parentPort, workerData } require(worker_threads); const fs require(fs); const xlsx require(better-xlsx); const path require(path); const moment require(moment); // 递归创建目录 function makeDir(dir) { if (!fs.existsSync(dir)) { makeDir(path.dirname(dir)); fs.mkdirSync(dir); } } // 生成带序号列的 Excel对应 simpleExcelDown function generateExcelWithIndex(data, header, fileName) { const fileDirPath path.join(__dirname, ../downloadFiles); makeDir(fileDirPath); const file new xlsx.File(); const sheet file.addSheet(sheet_1); // 表头样式 const headerStyle new xlsx.Style(); headerStyle.align.h center; ... // 表头行 const headerRow sheet.addRow(); const indexCell headerRow.addCell(); indexCell.value 序号; indexCell.style headerStyle; for (let h of header) { const cell headerRow.addCell(); cell.value h.title; cell.style headerStyle; } // 数据样式 const style new xlsx.Style(); style.align.h left; ... // 数据行 for (let i 0; i data.length; i) { const row sheet.addRow(); const indexCell row.addCell(); indexCell.value i 1; indexCell.style headerStyle; for (let h of header) { const cell row.addCell(); cell.value data[i][h.key] || h.defaultValue || ; cell.style style; } } const savePath path.join(fileDirPath, fileName); return new Promise((resolve, reject) { file.saveAs() .pipe(fs.createWriteStream(savePath)) .on(finish, () resolve(savePath)) .on(error, reject); }); } // 监听主线程消息 parentPort.on(message, async (msg) { if (msg.type generate) { try { const { data, header, fileName } msg; const filePath await generateExcelWithIndex(data, header, fileName); parentPort.postMessage({ type: success, filePath }); } catch (error) { parentPort.postMessage({ type: error, error: error.message }); } } });然后在generate方法里去掉直接生成Excel文件的代码转而用worker实现const fileName xxx_${moment().format(YYYYMMDDHHmmss)}.xlsx // Worker 线程生成 Excel 文件CPU 密集型不阻塞主线程 const workerPath path.join(__dirname, ../../service/exportWorker.js) const worker new Worker(workerPath) const filePath await new Promise((resolve, reject) { worker.on(message, (msg) { if (msg.type success) { resolve(msg.filePath) } else if (msg.type error) { reject(new Error(msg.error)) } }) worker.on(error, reject) worker.on(exit, (code) { if (code ! 0) reject(new Error(Worker exited with code ${code})) }) worker.postMessage({ type: generate, data: exportData, header, fileName }) }) exportTaskManager.updateTask(taskId, { status: success, filePath, fileName })现在无论生成多少行的数据都不会阻塞主线程了。如果还是觉得慢那就考虑把数据库查询也放到worker中去让出主线程。总结在遇到请求耗时且阻塞其他接口的情况可以考虑的优化方向第一时间把CPU密集型操作放到异步处理setImmediate让出当前线程worker_threads创建工作线程直接让出主线程。