【Node.js】ORMでの大量レコードSELECTによるヒープアウトからメモリを救う話

時と場合により、一度のAPIリクエストで数万行を超えるレコードを取得して、それを加工してレスポンスしたいってこともあると思います。

Nodeだと、オプション指定せずに起動させるとデフォルトのメモリ割り当てが512MB(700MBとか1400MBのケースもある？)なので、意外と簡単にヒープアウトしてしまいます。

例として、自分の環境ではTypeORMで25万行を超えるレコードを読み込み、それを加工しようとするとヒープアウト => APIサーバが死亡しました。

1回のAPIコールでサーバの死亡を引き起こす可能性があるのは当然許容できないので、対策を考えていきます。

# 対策
できることは全てやろうということで、考えられる対策を思いつく限り挙げていきます。

## V8エンジンへのメモリ割り当てを増やす
最も安直かつ簡単に取れる対策です。根本的な解決ではないため、これだけやって終わりにするのはエンジニアとしても終わりです。

安直とは書きましたが、少なくともデフォルトの512MBは本番稼働するAPIサーバとしては心もとないため、増やしておくのが無難でしょう。

割当量はNode起動コマンドの実行時に、`--max-old-space-size`オプションで指定できます。

```bash
node --max-old-space-size=2048 dist/app.js
```

上記では、メモリ割当を2GBに指定しています。

## 一度に取得するデータ量を減らす
この検討も必須です。
そもそもそんなに大量のデータを一度に取得する必要ってあるんだっけ？って話ですね。

もちろん、「何かしらのデータを1年分、CSVとしてダウンロードする」のような要件であれば、この程度のデータ量になることは往々にしてあると思います。

ただ、例えば「あるページに表示するためのデータを取得する」要件である場合、そんなに大量のデータを頑張って取得してフロントエンドに表示させたところで、ユーザビリティは最悪ですし、ユーザーのブラウザ環境によりレンダリング速度に大きな差がでるなど、また別の問題に直面するであろうことは明らかです。

この場合、そもそもページネーションを実装してないのがおかしい => ページネーションさえ実装すればAPI側のヒープアウトも自然解消する、といったケースもあるでしょう。

## DBから取得するデータ量を必要最低限に絞る

### 取得カラムを減らす
カラムが減ればメモリに読み込むデータも減ります。
当然、取得データ量が減ればネットワークIOの時間も減ります。
さらに、SELECT対象のカラムが減ることでSQL実行時間が減る場合もあります。

特にORMを使っていると、安易に対象テーブルの全カラムを取得するコードを書きがち(少なくとも僕はそうです)なのですが、JOINするテーブルが増えていくと、これが無視できないレベルになってきます。

```typescript
// 書きがちなダメダメクエリビルダー

import { getManager } from "typeorm"

async function getOrdersByIds(orderIds: number[]) {
  return getManager()
    .createQueryBuilder()
    .select("o") // <- ordersテーブルの全カラムを取得している
    .from(Order, "o")
    .innerJoinAndSelect("o.items", "oi") // <- .innerJoin"AndSelect"を使っているため、order_itemsの全カラムがSELECT句に反映されてしまう
    .where("o.id IN (:orderIds)", { orderIds })
    .getMany()
}
```

本当に全カラムが必要な場合は仕方ないとして、多少面倒でも、SELECTカラムは必要最低限にしておくのがベストでしょう。

### 不要なJOINをしてないか再度確かめる
実際コード内では使ってない不要なテーブルをJOINしちゃってる場合など、それを削除するだけでレコード量が10分の1とかになることはままあります。

これ一発でボトルネックが解消されることも無くはないので、ちゃんとチェックしましょう。

## ORマッパーを利用する際の自動変換に注意する
ORMの本懐ですが、取得したデータを自動的にデータ構造を保ったオブジェクトとして返却してくれるのって便利ですよね。

ただ、当たり前ですが、この処理の裏側ではDBからのローデータを頑張ってオブジェクトに変換する処理がORMにより行われているわけです。
つまり、データが大きくなるにつれて、ORMのユーザー(つまり僕たち)がハンドリングできない領域でのメモリ占有率が大きくなります。

例えばTypeORMだと、

```typescript
import { Entity, Column, PrimaryGeneratedColumn, OneToMany, ManyToOne, getManager } from "typeorm"

@Entity()
class Order {
  @PrimaryGeneratedColumn("increment")
  public id: number

  @Column({ type: "varchar", length: 127 })
  public fieldA: string

  // ...

  @Column({ type: "varchar", length: 127 })
  public fieldZ: string

  @OneToMany(
    () => OrderItem,
    item => item.order
  )
  public items?: OrderItem[]
}

@Entity()
class OrderItem {
  @PrimaryGeneratedColumn("increment")
  public id: number

  @Column({ type: "int" })
  public orderId: number

  @Column({ type: "varchar", length: 127 })
  public fieldA: string

  // ...

  @Column({ type: "varchar", length: 127 })
  public fieldZ: string

  @ManyToOne(
    () => Order,
    order => order.items
  )
  @JoinColumn({ name: "order_id" })
  public order?: Order
}

const orders = await getManager()
  .createQueryBuilder()
  .select("o")
  .from(Order, "o")
  .innerJoinAndSelect("o.items", "oi")
  .where("o.id <= :lastId", { lastId: 50000 })
  .getMany()
```

のように書けるわけですが、このとき最終的に受け取った`orders`は`Entity`デコレータで定義した`class Order`のインスタンスとなっています。
`order.items`のようにOrderItemにもアクセスすることができるようになっています。

DBからドライバを通じて受け取れるのはフィールドごと、もしくは行ごとのデータなので、ここまで変換するのがORMの仕事です。

ただこのクエリ、よく見ると5万件ほどのオーダーを取得しています。
さらにオーダーアイテムもJOINされているので、割と地獄のようなレコード数になりそうです。

ORマッピングの仕様は各ORM次第ですが、僕が使っているTypeORMでは少なくとも

- 検索条件に合致する全レコードを配列の形で取得する
- 全レコード配列をイテレートしてインスタンスの配列になおす

という、当たり前ですが非常に愚直な処理を行っています。
仮に1件のオーダーに平均10のアイテムが紐づくものとすれば、レコード数は500000件です。

50万件分の配列がメモリに読み込まれ、最低でも50万回のイテレーションが行われるわけですね。
この量の配列になると、数百MB~数GBに及ぶ場合もあります。

これだけでもAPIに多大なる負荷をかけそうですが、場合によって、ここで取得できたordersに対して更に何らかの処理を加えたい場合などもあります。

そうなると、せっかくORMが加工してくれた50万件のインスタンスを再度イテレーションする。。。
ということで、輪をかけて負荷が上昇します。

このような場合、ORMの変換処理に頼らず、自ら変換処理を書くことも一つの手段となります。

例えばTypeORMの場合、`getMany`ではなく`getRawMany`というメソッドが用意されており、後者はインスタンスへの変換を行わず、DBから取得されたレコードの配列をそのまま返却します。

必要であれば、それらのレコードに対して自前の変換処理を書くことにより、APIへの負荷を軽減することができます。

## メモリに一度に読み込まれるデータ量を減らす
これは上記のもう1段階発展系ですが、DBからの出力を「全てメモリに読み込む」のではなく、「1行ずつメモリに読み込む」ことによりメモリへの負荷を劇的に軽減させることができます。

Nodeには標準APIとしてStream APIが組み込まれており、かつTypeORMにはDBからのローデータをReadStreamとして返却する`.stream()`メソッドが用意されています。

これらを活用すると、下記のようなコードが書けます。

```typescript
import { Transform, TransformCallback } from "stream"

/* 中略 */

const queryBuilder = getManager()
  .createQueryBuilder()
  .select("o") // <- 本当は絞ったほうが良いけど例なので許してください
  .from(Order, "o")
  .innerJoinAndSelect("o.items", "oi")  // <- 本当は絞ったほうが良いけど例なので許してください
  .where("o.id <= :lastId", { lastId: 50000 })

// @NOTE: 自前のクエリランナーを用意してやらないと、クエリが実行される前にコネクションが閉じてしまう。バグ?
const queryRunner = queryBuilder.connection.createQueryRunner()

const dbReadStream = await queryBuilder
  .setQueryRunner(queryRunner)
  .stream()

await new Promise((resolve, reject) => {
  dbReadStream
    .pipe(
      new Transform({
        objectMode: true,
        transform(chunk: any, encoding: string, cb: TransformCallback) {
          // ここでchunk(= DBからのRawDataPacket)に対する変換処理を行う
          cb()
        }
        flush(cb: TransformCallback) => {
          // @NOTE: chunkの最終行が読み込まれた後に発火する
          cb()
        }
      })
    )
    .on("finish", () => {
      // @NOTE: 自前のクエリランナーは自分で解放する必要がある
      queryRunner.release()
      resolve(result) // <- ここで変換後のデータを返す
    })
    .on("error", (err) => {
      // @NOTE: 自前のクエリランナーは自分で解放する必要がある
      queryRunner.release()
      reject(err)
    })
})
```

TypeORMから返却されたReadStreamに対してTransformStreamを接続(pipe)し、任意の変換を行います。
この処理をPromiseでラップし、変換が完了(finish)ないし失敗(error)した場合にそれぞれresolve, rejectしてやることで、パケットの逐次処理を行いつつ、それらの処理をawaitすることができています。

可読性は当然下がりますが、場合によってはこのような実装を行う必要も出てくると思います。

# まとめ
大量データのロードによるヒープアウトでのAPI死亡をできる限り避けるためには,,,

- V8エンジンに明示的にメモリを割り当てておく
- そもそもその大量データを1度に取得する必要があるのか再検討、可能であればページネーションなど実装する
- DBからの取得データ総量を減らすようにクエリをチューニング・修正する
- ORマッパーに変換処理を任せるべきかどうかを判断する
- メモリへの負荷が少ない処理を実装する

あたりが解決策になるかと思います。

もし「他にもこんなことできるよ」というのがありましたら是非教えて下さい m(_ _)m