NodeJS大量数据导出导致系统内存溢出的解决办法

大量数据导出导致系统内存溢出的解决办法

在web开发中,我们经常可能会遇到导出报表等统计功能,常规做法就是会从数据库中拉取大量数据,甚至有可能还会统计所有数据的一个总量。当我们一次性读取所有数据到内存中时,就极可能导致系统OOM。因为我的后台系统使用的是NodeJS的Nest框架,数据库ORM使用的是Sequelize,下面就这种问题我总结一下处理的方法。

方法一、修改V8内存大小

从《深入浅出NodeJS》中我们得知64位系统内存限制约为1.4GB,所以我们一次性讲数据加载进内存中,就有可能超过这个限制,导致OOM。但是NodeJS提供了一个程序运行参数 --max-old-space-size,可以通过该参数指定V8所占用的内存空间,这样可在一定程度上便面程序内存溢出

方法二、使用非V8内存

这也是在项目中采用的方法。Buffer是一个NodeJS的扩展对象,使用底层的系统内存,不占用V8内存空间。与之相关的文件系统fs和流Stream流操作,都不会占用V8内存。下面我就流操作的解决方法,详细梳理一遍。

MySQL数据库有流式读取方式,不是一次性读取所有数据到内存,而是根据使用者的需要,部分的读取数据。但是Sequelize这个ORM模型又不支持流式读取,所以在系统中我们额外引入了支持流式查询的Knex查询构造器和Mysql2驱动程序。

1、构建数据库服务

import { Injectable } from "@nestjs/common";
import * as config from "config";
import { getLogger } from "log4js";
import * as Knex from "knex";

const logger = getLogger("Knex");
interface MysqlConfig {
  name: string;
  read: [
    {
      host: string,
      port: number,
      username: string,
      password: string
    }
  ];
}

const { name: database, read: [conf] } = config.get<MysqlConfig>("mysql");
const knex = Knex({
  client: "mysql2",
  pool: {
    min: 1,
    max: 10
  },
  connection: {
    database,
    port: conf.port,
    host: conf.host,
    user: conf.username,
    password: conf.password,
    decimalNumbers: true
  }
});

@Injectable()
export class ReadService {
  async* exec(sql, params, limit = 5000) {
    // 读取limit条数据,通过yield返回数据
    // xxx...
  }
}

2、创建Csv相关服务

import * as path from "path";
import * as convert from "iconv-lite";
import * as Achiver from "archiver";
import { getLogger } from "log4js";
import { createReadStream, promises as fs, writeFileSync } from "fs";

const logger = getLogger("ExportCsv");

export class Csv {
  private readonly name: string;
  private readonly path: string;
  // 是否压缩
  private readonly compress: boolean;

  constructor(name: string, columns: string[], rows: any[] = [], compress: boolean = false) {
    this.compress = compress;
    this.name = path.basename(name, ".csv");
    this.path = path.format({
      ext: ".csv",
      root: process.cwd(),
      name: Date.now() + this.name
    });

    let txt = columns.join(",");
    if (Array.isArray(rows)) {
      txt += Csv.parse(rows);
    }
    // 同步创建一个只包含字段名的文件
    writeFileSync(this.path, convert.encode(txt, "GBK", { addBOM: true }));
  }

  // 转换数据中一些特殊符号
  private static parse(rows): string {
    return "\r\n" + rows.map(r => {
        return r
          .map(x => {
            if (typeof x === "string") {
              x = x.replace(/,/g, ",").replace(/\n/g, "-");
            }

            return x;
          })
          .join(",");
      }
    ).join("\r\n");
  }

  // 在同一个文件中,异步新增数据
  async append(rows: any[]) {
    return fs.appendFile(this.path, convert.encode(Csv.parse(rows), "GBK", { addBOM: true }));
  }

  // 根据传入的compress参数判断,是否压缩文件
  // res也是一个流对象,所有可以进行流的相关操作
  async send(res) {
    if (this.compress) {
      // 返回压缩后的文件
    } else {
      // 返回普通文件
    }
  }

  // 监控当文件下载完毕后,则删除服务器生成的文件
  private observe(res) {
    return new Promise((resolve, reject) => {
      res.on("end", () => {
        resolve();
        fs.unlink(this.path).catch(logger.error.bind(logger));
      });
      res.on("error", err => reject(err));
    });
  }
}

3、在控制器函数调用服务

// 用于统计总量的数据
const total = {
      money: 0,
      deposit: 0
    };
// 查询大量数据的SQL
const sql = `xxx`;
// 实例化Csv对象
const csv = new Csv(`订单结算数据${start}至${end}.csv`, title, null, true);
// 执行SQL,一次性读取10000条数据,返回的是一个可遍历的生成器
const reader = this.reader.exec(sql, params, 10000);
// 遍历生成器,不停的向文件中写数据
// 如果我们直接导出数据,那么在csv.append方法中,直接返回[[1, 22, 33], [2, 44, 55]]
// 如果我们要根据所有数据,进行一个统计计算,那么我们可以新写一个parseRows方法来进行统计
for await (const x of reader) {
    await csv.append(await this.parseRows(x, { ems, sites, total }));
}
// 总量数据
const row = [
      _.round(total.money, 2),
      _.round(total.deposit, 2),
    ];
// 写入文件
await csv.append([row]);
// 返回csv对象,在拦截器中处理
return csv;

4、拦截器中处理返回形式

import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from "@nestjs/common";
import { map } from "rxjs/operators";
import { Csv } from "@shared/commons";

export interface Response<T> {
  code: number;
  data: T;
}

@Injectable()
export class FormatInterceptor<T> implements NestInterceptor<T, Response<T>> {
  intercept(context: ExecutionContext, next: CallHandler) {
    return next.handle().pipe(map(data => {
      if (data instanceof Csv) {
        // 处理数据导出数据格式
        const res = context.switchToHttp().getResponse();
        // send方法就是csv服务中的导出方法
        return data.send(res);
      }

      // 处理常规API数据格式
      const result: any = { code: 0, data: "success" };
      if (data) {
        if (data.rows) {
          data.meta = data.rows;
          data.total = data.count;
          delete data.rows;
          delete data.count;
        }

        result.data = data;
      }

      return result;
    }));
  }
}

自此整个流程:流式读取数据->异步写入文件->压缩文件->返回文件前端下载->删除生成的文件就结束了。这样我们分步骤处理数据,就不会因为数据量过大导致内存溢出了。部分方法中的代码我没有写上来,如果有需要可以互相探讨一下。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,001评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,210评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,874评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,001评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,022评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,005评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,929评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,742评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,193评论 1 309
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,427评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,583评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,305评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,911评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,564评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,731评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,581评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,478评论 2 352

推荐阅读更多精彩内容