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

NodeJS填充大Excel模板内存溢出问题及解决方案咨询

NodeJS Excel生成服务内存溢出问题解决

问题背景

我有一个NodeJS服务,需加载约26MB的Excel模板并填充数据后提供给React前端用户下载。但当3位用户同时下载,或单个用户选择大量数据下载时,会出现内存堆溢出导致服务崩溃。已知原因是xlsx-populate会将整个Excel模板加载到内存中,我尝试过ExcelJS、xlsx-template均遇到相同内存问题。尝试过流处理,但xlsx-populate无此功能;还考虑过在模板与数据间设置中间层(如让Excel引用JSON文件),但最终需生成完整的Excel文件。

优化方案

1. 避免临时文件冗余拷贝

原代码中先拷贝模板到临时文件再读取,会产生额外磁盘IO和内存占用,直接从模板文件加载工作簿,减少内存拷贝:

// 替换原代码中fs.copyFileSync + fs.readFile的逻辑
XlsxPopulate.fromFileAsync(srcPath)
  .then(workbook => {
    // 数据填充逻辑
  })

2. 流式返回生成结果

生成Excel时直接流式输出到响应,不写入本地临时文件,减少内存和磁盘资源占用:

// 替换workbook.toFileAsync(destPath)的逻辑
const buffer = await workbook.toBufferAsync();
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${nameFile}.xlsx"`);
res.send(buffer);
// 手动解除引用,触发垃圾回收
workbook = null;

3. 分批次处理大数据量

如果数据量极大,将数据分成小批次处理,每处理一批后强制触发垃圾回收(需启动Node时添加--expose-gc参数):

const batchSize = 1000;
for (let i = 0; i < response.data.length; i += batchSize) {
  const batch = response.data.slice(i, i + batchSize);
  batch.forEach(e => {
    // 填充行数据逻辑
  });
  // 触发垃圾回收
  if (global.gc) global.gc();
}

4. 切换到支持流式的Excel库(推荐)

使用exceljs实现模板解析+流式生成,手动复刻模板样式,彻底解决内存溢出问题:

const ExcelJS = require('exceljs');

async function generateExcelStream(res, templatePath, data, resumoData) {
  const templateWorkbook = new ExcelJS.Workbook();
  await templateWorkbook.xlsx.readFile(templatePath);
  
  const worksheet = templateWorkbook.getWorksheet('DADOS');
  const resumoSheet = templateWorkbook.getWorksheet('resumo+ações');
  
  // 填充固定模板内容
  const dtNow = dayjs().format("DD/MM/YYYY");
  resumoSheet.getCell('M1').value = `Data Relatório: ${dtNow}`;
  worksheet.getCell('E1').value = dtNow;
  
  // 填充resumo数据
  let rowNum = 8;
  for (const e of resumoData) {
    const row = resumoSheet.getRow(rowNum);
    row.getCell(3).value = e.id || "";
    // 填充其他单元格逻辑...
    row.commit(); // 提交行,释放内存
    rowNum++;
  }
  
  // 填充DADOS数据
  rowNum = 3;
  for (const e of data) {
    const row = worksheet.getRow(rowNum);
    const dtString = e.dtOperacao.substring(0, 10);
    const mesString = dayjs(dtString, "DD/MM/YYYY", true).isValid()
      ? dayjs(dtString, "DD/MM/YYYY").format("MMMM")
      : "Invalid Date";
    row.getCell(2).value = "";
    // 填充其他单元格逻辑...
    row.commit();
    rowNum++;
  }
  
  // 流式输出到响应
  res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
  res.setHeader('Content-Disposition', `attachment; filename="${nameFile}.xlsx"`);
  await templateWorkbook.xlsx.write(res);
  res.end();
}

原问题代码

const express = require("express");
const XlsxPopulate = require("xlsx-populate");
const fs = require("fs");
const axios = require("axios");
const cors = require("cors");
const dayjs = require("dayjs");
const localizedFormat = require("dayjs/plugin/localizedFormat");
const customParseFormat = require("dayjs/plugin/customParseFormat");
const isSameOrAfter = require("dayjs/plugin/isSameOrAfter");
const isSameOrBefore = require("dayjs/plugin/isSameOrBefore");
const advancedFormat = require("dayjs/plugin/advancedFormat");
const ptBr = require("dayjs/locale/pt-br");
const { v4: uuidv4 } = require("uuid");
const app = express();
const dotenv = require("dotenv");
dotenv.config();
const PORT = process.env.VITE_API_PORT;
const IP = process.env.VITE_NUVEM_URL;
const VITE_API_URL_PORT = process.env.VITE_API_URL_PORT;

app.use(
  cors({
    origin: "*",
    methods: ["GET", "POST", "OPTIONS", "PUT", "PATCH", "DELETE"],
    allowedHeaders: [
      "Origin",
      "X-Requested-With",
      "Content-Type",
      "Accept",
      "Authorization",
    ],
    optionsSuccessStatus: 200,
  })
);

app.use(express.json());

app.get("/update-excel", async (req, res) => {
  const { filter, token } = req.query;

  if (!token) {
    return res.status(400).send("Token é obrigatório.");
  }

  console.log(req.query);

  const parsedFilter = JSON.parse(filter || "{}");
  console.log(parsedFilter);
  console.log(filter);

  const interval = setInterval(() => {
    res.write("gerando\n");
  }, 20000);

  syncDataList(token, parsedFilter, String(uuidv4()), res, interval);
});

async function syncDataList(token, filter, nameFile, res, interval) {
  try {
    const responseResumo = await axios.post(
      `${VITE_API_URL_PORT}/inventory/resume/filter`,
      filter,
      {
        headers: {
          Authorization: `Bearer ${token}`,
        },
        timeout: 300000,
      }
    );

    if (Array.isArray(responseResumo.data)) {
      const response = await axios.post(
        `${VITE_API_URL_PORT}/inventory/resume/download`,
        filter,
        {
          headers: {
            Authorization: `Bearer ${token}`,
          },
          timeout: 300000,
        }
      );

      if (Array.isArray(response.data)) {
        dayjs.extend(customParseFormat);
        dayjs.extend(localizedFormat);
        dayjs.extend(isSameOrAfter);
        dayjs.extend(isSameOrBefore);
        dayjs.extend(advancedFormat);
        dayjs.locale("pt-br");

        const distPath = "\\temp";
        const srcPath = "\\template.xlsx";
        const destPath = `${distPath}\\${nameFile}.xlsx`;

        if (!fs.existsSync(distPath)) {
          fs.mkdirSync(distPath);
        }

        fs.copyFileSync(srcPath, destPath);

        fs.readFile(destPath, (err, data) => {
          if (err) {
            console.error("Erro ao ler o arquivo:", err);
            return;
          }

          XlsxPopulate.fromDataAsync(data)
            .then((workbook) => {
              const worksheet = workbook.sheet("DADOS");
              const worksheetResumo = workbook.sheet("resumo+ações");

              let nuPonto = 3;
              let contador = 1;

              let nuPontoResumo = 8;

              const dtNow = dayjs().format("DD/MM/YYYY");

              const rowOne = worksheetResumo.row(1);
              rowOne.cell(13).value(`Data Relatório: ${dtNow}`);

              responseResumo.data.forEach((e) => {
                if (e) {
                  const row = worksheetResumo.row(nuPontoResumo);

                  row.cell(3).value(e.id || "");
                  //etc

                  nuPontoResumo++;
                } else {
                  console.error("Dados inválidos na linha", nuPontoResumo, e);
                }
              });

              const rowTwo = worksheet.row(1);
              rowTwo.cell(5).value(`${dtNow}`);

              response.data.forEach((e) => {
                if (e) {
                  const row = worksheet.row(nuPonto);
                  const dtString = e.dtOperacao.substring(0, 10);

                  const mesString = dayjs(
                    dtString,
                    "DD/MM/YYYY",
                    true
                  ).isValid()
                    ? dayjs(dtString, "DD/MM/YYYY").format("MMMM")
                    : "Invalid Date";

                  row.cell(2).value(""); //etc

                  nuPonto++;
                  contador++;
                } else {
                  console.error("Dados inválidos na linha", nuPonto, e);
                }
              });

              return workbook.toFileAsync(destPath);
            })
            .then(() => {
              console.log({
                naFile: `${nameFile}.xlsx`,
                address: destPath,
              });
              res.status(200).send({
                naFile: `${nameFile}.xlsx`,
                address: destPath,
              });
            })
            .catch((error) => {
              console.error("Erro ao processar o arquivo Excel:", error);
              res.status(500).send("Houve um erro ao gerar o arquivo excel!!!");
            });
        });
      } else {
        res.status(400).send({ error: "Lista pontos vazia!!!" });
      }
    } else {
      res.status(400).send({ error: "Lista resumo vazia!!!" });
    }
  } catch (error) {
    console.error("Erro ao sincronizar os dados ou gerar o Excel:", error);
    res.status(500).send("Houve um erro ao sincronizar os dados!!!");
  } finally {
    clearInterval(interval);
  }
}

app.listen(PORT, IP, () => {
  console.log(`Server running on ${IP}:${PORT}`);
});

内容的提问来源于stack exchange,提问作者lucas barbosa

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.14 16:44:54