Node.js使用exceljs生成大数据量Excel报堆内存溢出如何解决?
问题根因
- 第一,
workbook、worksheet定义在全局作用域,所有接口请求共享同一个实例,不仅会出现多请求数据混淆的bug,还会导致内存无法被GC回收,请求量越大内存占用越高,最终溢出。 - 第二,使用
Promise.all一次性发起所有分页请求,10万条数据按3000/页拆分后会同时发起30+个请求,所有返回的原始数据、转换后的行数据、Excel的内存对象全部堆在内存中,内存峰值会非常高。同时并发请求还会导致Excel的行顺序和分页顺序不一致,数据排序错乱。 - 第三,生成文件时先把整个Excel写入内存Buffer,再转成base64格式返回,base64会比原二进制文件大33%,相当于同时在内存中存储了Excel对象、二进制Buffer、base64字符串三个超大对象,进一步放大内存压力。
优化方案
1. 核心逻辑改造
- 将
workbook、worksheet的定义移到接口处理函数内部,每次请求新建独立实例,请求结束后实例会被自动回收,避免内存泄漏和数据混淆。 - 替换
Promise.all全并发请求为串行/限并发请求,保证数据顺序的同时,避免大量数据同时堆在内存中,处理完的分页数据可以及时被GC回收。 - 放弃base64格式返回,改用流式输出直接把Excel文件写入响应体,不需要把整个文件暂存在内存中,内存占用可以降到KB级别。
2. 改造后Node.js代码示例
const axios = require('axios'); const excel = require("exceljs"); // 并发控制函数,可根据服务器性能调整并发数 const limitConcurrency = async (tasks, concurrency = 3) => { const results = []; let index = 0; const executeNext = async () => { if (index >= tasks.length) return; const currentIndex = index++; results[currentIndex] = await tasks[currentIndex](); await executeNext(); }; const workers = Array(concurrency).fill().map(executeNext); await Promise.all(workers); return results; }; exports.getTicketData = async (req, res, next) => { res.connection.setTimeout(0); const { body } = req; const { token, organization_id, server, sideFilter } = body; const baseurl = 'url for server end to fetch data'; if (!baseurl) { return res.status(400).json({ type:'error', msg:'please define server name' }); } try { const limit = 3000; const count = await getCount(token, limit, organization_id, baseurl, sideFilter); // 每次请求新建独立的workbook和worksheet const workbook = new excel.Workbook(); const worksheet = workbook.addWorksheet("My Sheet"); worksheet.columns = [ { header: "TicketId", key: "ticketId" }, { header: "Email", key: 'user_email' }, { header: "User", key : 'user_name' }, { header: "Subject", key: "subject" }, // 其余表头 ]; // 生成任务列表,限并发执行 const tasks = Array.from({length: count}, (_, i) => async () => { const page = i + 1; const response = await axios.post(baseurl+`/v2/get-export`, { page, organization_id, per_page: limit, filter: "", sorted:"", ...sideFilter },{ headers: {"Authorization" : `Bearer ${token}`} }); const dataTemp = response.data.data.data.map(t => ({ ...t, name: t.name, // 其余字段转换 })); worksheet.addRows(dataTemp); // 手动释放无用变量,帮助GC response.data = null; return null; }); await limitConcurrency(tasks, 3); // 设置响应头,直接返回Excel文件流 res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', 'attachment; filename="export.xlsx"'); // 直接写入响应流,无需缓存全量文件 await workbook.xlsx.write(res); res.end(); } catch (err) { console.error(err); return res.status(400).json({ type:'error', msg:'File not generated please contact support staff' }); } }; let getCount = (token,limit, organization_id, baseurl, sideFilter) => { // 原有逻辑不变 }
3. 改造后React前端代码示例
exportLink = () => { const postData ={ // 原有参数不变 }; return axios.post(`${baseurl}/api/ticketing/get-ticket`, postData, { // 指定返回类型为blob responseType: 'blob' }).then(function (response) { const downloadLink = document.createElement("a"); const fileName = "export.xlsx"; // 用返回的二进制流生成下载链接 downloadLink.href = window.URL.createObjectURL(new Blob([response.data])); downloadLink.download = fileName; downloadLink.click(); // 释放blob资源 window.URL.revokeObjectURL(downloadLink.href); }).catch(function(error){ throw error; }); }
4. 额外优化建议
- 如果数据量超过20万条,可以拆分多个Sheet存储,单个Sheet最多存储5万行,避免单个Sheet过大导致的内存上涨。
- 可以根据服务器配置适当调整并发数,并发数越高处理速度越快,但内存占用也会越高,建议压测后选择最优值。
- 如果是部署在Docker等容器环境,需要确认容器的内存上限是否高于你设置的
max_old_space_size,否则调整Node内存参数无效。
内容的提问来源于stack exchange,提问作者hu7sy
相关产品推荐
相关产品推荐

