NodeJS大量數(shù)據(jù)導(dǎo)出導(dǎo)致系統(tǒng)內(nèi)存溢出的解決辦法

大量數(shù)據(jù)導(dǎo)出導(dǎo)致系統(tǒng)內(nèi)存溢出的解決辦法

在web開發(fā)中,我們經(jīng)逞ù担可能會(huì)遇到導(dǎo)出報(bào)表等統(tǒng)計(jì)功能氢惋,常規(guī)做法就是會(huì)從數(shù)據(jù)庫中拉取大量數(shù)據(jù),甚至有可能還會(huì)統(tǒng)計(jì)所有數(shù)據(jù)的一個(gè)總量座舍。當(dāng)我們一次性讀取所有數(shù)據(jù)到內(nèi)存中時(shí)沮翔,就極可能導(dǎo)致系統(tǒng)OOM。因?yàn)槲业暮笈_(tái)系統(tǒng)使用的是NodeJS的Nest框架曲秉,數(shù)據(jù)庫ORM使用的是Sequelize采蚀,下面就這種問題我總結(jié)一下處理的方法。

方法一承二、修改V8內(nèi)存大小

從《深入淺出NodeJS》中我們得知64位系統(tǒng)內(nèi)存限制約為1.4GB榆鼠,所以我們一次性講數(shù)據(jù)加載進(jìn)內(nèi)存中,就有可能超過這個(gè)限制亥鸠,導(dǎo)致OOM妆够。但是NodeJS提供了一個(gè)程序運(yùn)行參數(shù) --max-old-space-size,可以通過該參數(shù)指定V8所占用的內(nèi)存空間负蚊,這樣可在一定程度上便面程序內(nèi)存溢出

方法二神妹、使用非V8內(nèi)存

這也是在項(xiàng)目中采用的方法。Buffer是一個(gè)NodeJS的擴(kuò)展對象家妆,使用底層的系統(tǒng)內(nèi)存灾螃,不占用V8內(nèi)存空間。與之相關(guān)的文件系統(tǒng)fs和流Stream流操作揩徊,都不會(huì)占用V8內(nèi)存腰鬼。下面我就流操作的解決方法嵌赠,詳細(xì)梳理一遍。

MySQL數(shù)據(jù)庫有流式讀取方式熄赡,不是一次性讀取所有數(shù)據(jù)到內(nèi)存姜挺,而是根據(jù)使用者的需要,部分的讀取數(shù)據(jù)彼硫。但是Sequelize這個(gè)ORM模型又不支持流式讀取炊豪,所以在系統(tǒng)中我們額外引入了支持流式查詢的Knex查詢構(gòu)造器和Mysql2驅(qū)動(dòng)程序。

1拧篮、構(gòu)建數(shù)據(jù)庫服務(wù)

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條數(shù)據(jù)词渤,通過yield返回?cái)?shù)據(jù)
    // xxx...
  }
}

2、創(chuàng)建Csv相關(guān)服務(wù)

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);
    }
    // 同步創(chuàng)建一個(gè)只包含字段名的文件
    writeFileSync(this.path, convert.encode(txt, "GBK", { addBOM: true }));
  }

  // 轉(zhuǎn)換數(shù)據(jù)中一些特殊符號
  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");
  }

  // 在同一個(gè)文件中缺虐,異步新增數(shù)據(jù)
  async append(rows: any[]) {
    return fs.appendFile(this.path, convert.encode(Csv.parse(rows), "GBK", { addBOM: true }));
  }

  // 根據(jù)傳入的compress參數(shù)判斷,是否壓縮文件
  // res也是一個(gè)流對象礁凡,所有可以進(jìn)行流的相關(guān)操作
  async send(res) {
    if (this.compress) {
      // 返回壓縮后的文件
    } else {
      // 返回普通文件
    }
  }

  // 監(jiān)控當(dāng)文件下載完畢后高氮,則刪除服務(wù)器生成的文件
  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、在控制器函數(shù)調(diào)用服務(wù)

// 用于統(tǒng)計(jì)總量的數(shù)據(jù)
const total = {
      money: 0,
      deposit: 0
    };
// 查詢大量數(shù)據(jù)的SQL
const sql = `xxx`;
// 實(shí)例化Csv對象
const csv = new Csv(`訂單結(jié)算數(shù)據(jù)${start}至${end}.csv`, title, null, true);
// 執(zhí)行SQL顷牌,一次性讀取10000條數(shù)據(jù)剪芍,返回的是一個(gè)可遍歷的生成器
const reader = this.reader.exec(sql, params, 10000);
// 遍歷生成器,不停的向文件中寫數(shù)據(jù)
// 如果我們直接導(dǎo)出數(shù)據(jù)窟蓝,那么在csv.append方法中罪裹,直接返回[[1, 22, 33], [2, 44, 55]]
// 如果我們要根據(jù)所有數(shù)據(jù),進(jìn)行一個(gè)統(tǒng)計(jì)計(jì)算运挫,那么我們可以新寫一個(gè)parseRows方法來進(jìn)行統(tǒng)計(jì)
for await (const x of reader) {
    await csv.append(await this.parseRows(x, { ems, sites, total }));
}
// 總量數(shù)據(jù)
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) {
        // 處理數(shù)據(jù)導(dǎo)出數(shù)據(jù)格式
        const res = context.switchToHttp().getResponse();
        // send方法就是csv服務(wù)中的導(dǎo)出方法
        return data.send(res);
      }

      // 處理常規(guī)API數(shù)據(jù)格式
      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;
    }));
  }
}

自此整個(gè)流程:流式讀取數(shù)據(jù)->異步寫入文件->壓縮文件->返回文件前端下載->刪除生成的文件就結(jié)束了滑臊。這樣我們分步驟處理數(shù)據(jù),就不會(huì)因?yàn)閿?shù)據(jù)量過大導(dǎo)致內(nèi)存溢出了箍铲。部分方法中的代碼我沒有寫上來雇卷,如果有需要可以互相探討一下。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末颠猴,一起剝皮案震驚了整個(gè)濱河市关划,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌翘瓮,老刑警劉巖贮折,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異资盅,居然都是意外死亡调榄,警方通過查閱死者的電腦和手機(jī)踊赠,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來每庆,“玉大人筐带,你說我怎么就攤上這事$土椋” “怎么了伦籍?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長腮出。 經(jīng)常有香客問我帖鸦,道長,這世上最難降的妖魔是什么胚嘲? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任作儿,我火速辦了婚禮,結(jié)果婚禮上慢逾,老公的妹妹穿的比我還像新娘立倍。我一直安慰自己,他們只是感情好侣滩,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布口注。 她就那樣靜靜地躺著,像睡著了一般君珠。 火紅的嫁衣襯著肌膚如雪寝志。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天策添,我揣著相機(jī)與錄音材部,去河邊找鬼。 笑死唯竹,一個(gè)胖子當(dāng)著我的面吹牛乐导,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播浸颓,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼善茎,長吁一口氣:“原來是場噩夢啊……” “哼跟狱!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤九火,失蹤者是張志新(化名)和其女友劉穎倒淫,沒想到半個(gè)月后笨鸡,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體狸眼,經(jīng)...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年谢鹊,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了算吩。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片留凭。...
    茶點(diǎn)故事閱讀 38,161評論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖赌莺,靈堂內(nèi)的尸體忽然破棺而出冰抢,到底是詐尸還是另有隱情,我是刑警寧澤艘狭,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布挎扰,位于F島的核電站,受9級特大地震影響巢音,放射性物質(zhì)發(fā)生泄漏遵倦。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一官撼、第九天 我趴在偏房一處隱蔽的房頂上張望梧躺。 院中可真熱鬧,春花似錦傲绣、人聲如沸掠哥。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽续搀。三九已至,卻和暖如春菠净,著一層夾襖步出監(jiān)牢的瞬間禁舷,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工毅往, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留牵咙,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓攀唯,卻偏偏與公主長得像洁桌,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子侯嘀,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,916評論 2 344

推薦閱讀更多精彩內(nèi)容