【Prisma】findManyで100万規模のデータを取得する

# :sunny: はじめに

今回は約100万件ほどのデータを<font color="Blue">**生SQLクエリ**</font>ではなく<font color="Red">**Prisma（ORM）でデータ取得をする**</font>というところに焦点を当てて、実装までに当たった問題点や内容のご共有ができたらと思います。

# :four_leaf_clover: 環境

* TypeScript：4.7.4
* Prisma：5.9.0
* Provider：MySQL

# :gear: 実装したコード

```TypeScript:service.ts
type DataType = {
  id: number
  name: string
  createdAt: Date
}

async getData(): Promise<{ result: DataType[] }> {
  const batchSize = 150000
  let lastId = 0
  let results: DataType[] = []
  let specificData: DataType[] = []

  do {
    results = await this.prisma.data.findMany({
      where: {
        id: {
          gt: lastId
        }
      },
      select: {
        id: true,
        name: true,
        createdAt: true
      },
      take: batchSize,
      orderBy: {
        id: 'asc'
      }
    })

    if (results.length > 0) {
      lastId = results[results.length - 1].id
    }

    specificData = specificData.concat(results)
  } while (results.length === batchSize)

  return {
    result: specificData.length < 1000 ? specificData : []
  }
}
```

# :bulb: 実装内容の説明

### 全体の処理の流れ
データ取得までの処理の流れとしては以下のようになります。

① `const batchSize = 150000`といったようにバッチサイズを定義
```TypeScript
const batchSize = 150000
```
② `do...while`文の中でクエリオプションの`take`によりバッチサイズ分のデータを取得（今回は1回のループで150,000個分のデータを取得）
```TypeScript
do {
  results = await this.prisma.data.findMany({
    // ~~~ 省略 ~~~
    take: batchSize,
    // ~~~ 省略 ~~~
  })

  // ~~~ 省略 ~~~
  
} while (results.length === batchSize)
```
③ 取得できたデータの最後のidを取得して変数（`lastId`）に格納
```TypeScript
let lastId = 0

do {
  results = await this.prisma.data.findMany({
    // ~~~ 省略 ~~~
  })

  if (results.length > 0) {
    lastId = results[results.length - 1].id
  }

  // ~~~ 省略 ~~~
  
} while (results.length === batchSize)
```
④ ループ毎に取得するデータの被りが発生しないように、where文の中で`gt`演算子を用いて`lastId`よりidが大きいユーザーデータを取得するようにする
```TypeScript
do {
  results = await this.prisma.data.findMany({
    where: {
      id: {
        gt: lastId
      }
    },
    // ~~~ 省略 ~~~
  })

  if (results.length > 0) {
    lastId = results[results.length - 1].id
  }

  // ~~~ 省略 ~~~
  
} while (results.length === batchSize)
```
⑤ 取得したデータ数とバッチ数が同じである場合はループを継続して、ループ毎に`findMany`を繰り返し実行
```TypeScript
do {
  results = await this.prisma.data.findMany({
    where: {
      id: {
        gt: lastId
      }
    },
    select: {
      id: true,
      name: true,
      createdAt: true
    },
    take: batchSize,
    orderBy: {
      id: 'asc'
    }
  })

  if (results.length > 0) {
    lastId = results[results.length - 1].id
  }

  specificData = specificData.concat(results)
} while (results.length === batchSize)
```
⑥ ループ毎に取得したデータ`results`は変数`specificData`に`concat`関数で配列結合をして再代入
```TypeScript
specificData = specificData.concat(results)
```
⑦ 取得したデータ数がバッチサイズに満たなかった場合、その時点でループ終了。specificDataをreturnする。
<font color="Gray">※今回のケースではspecificDataのlengthを条件に返すデータ量を絞っています。</font>
```TypeScript
async getData(): Promise<{ result: DataType[] }> {
  const batchSize = 150000
  let lastId = 0
  let results: DataType[] = []
  let specificData: DataType[] = []

  do {
    results = await this.prisma.data.findMany({
      where: {
        id: {
          gt: lastId
        }
      },
      select: {
        id: true,
        name: true,
        createdAt: true
      },
      take: batchSize,
      orderBy: {
        id: 'asc'
      }
    })

    if (results.length > 0) {
      lastId = results[results.length - 1].id
    }

    specificData = specificData.concat(results)
  } while (results.length === batchSize)

  return {
    result: specificData.length < 1000 ? specificData : []
  }
}
```

### なぜバッチ処理をするのか？

単純に考えるとバッチ処理でループなどさせず、一度のfindManyで該当するデータを取得したほうが遥かに効率的と思えるのですが、それをできない理由があります。

Prismaのバグで大量のデータを一度にfindManyしようとすると不可解に型変換エラーが必ず発生してしまい処理が実行できません。
現在GitHub上のissueにてこちらのバグが指摘されており、未対応の状態になっています。<font color="Gray">※2024年7月現在時点</font>

https://github.com/prisma/prisma/issues/13864

そもそもPrisma公式ではfindManyにて大量のデータを扱う際には、パフォーマンスの観点からクエリオプションの`skip`と`take`も用いて、ページネーションのように分割してデータ取得することを推奨されているようなので、個人的には未対応なのも仕方がないのかなと思いました。

https://www.prisma.io/docs/orm/prisma-client/queries/pagination

# :mag: パフォーマンス向上のためにしたこと

### 取得するカラムをselectで厳選する

単純に一度のクエリで取得する情報が少なければ少ないほどパフォーマンスは上がるよねという話です。
今回取得するカラムはidとnameとcreatedAtに留めています。

```TypeScript
results = await this.prisma.data.findMany({
  where: {
    id: {
      gt: lastId
    }
  },
  select: {
    id: true,
    name: true,
    createdAt: true
  },
  take: batchSize,
  orderBy: {
    id: 'asc'
  }
})
```

これが仮にテーブル結合を伴うクエリになると**パフォーマンスは一気に悪化**します。

```TypeScript
results = await this.prisma.data.findMany({
  where: {
    id: {
      gt: lastId
    }
  },
  // includeによりテーブル結合
  include: {
    extraData: true
  },
  take: batchSize,
  orderBy: {
    id: 'asc'
  }
})
```

極力必要なカラムのみ取得するようにしましょう。
かといってテーブル結合をしなければ取得できない情報もあるかと思うので、ここはパフォーマンスを逐一図りながら、場合によってはテーブル設計の見直しも検討すべき部分かもしれません。

### 配列操作にconcatを使用する

ループ毎に`findMany`にて取得したデータ（results）をconcat関数でspecificDataに配列結合をして再代入をしています。

```TypeScript
specificData = specificData.concat(results)
```

当初この部分はスプレッド演算子とpush関数を利用してspecificDataの配列へデータを追加していました。

```TypeScript
specificData.push(...results)
```

しかしこれでは追加する要素が多い場合、push関数へそれぞれ追加したい要素が個別の引数として渡されるので、JavaScriptエンジンの呼び出しスタックが限界を超えてしまい`Maximum call stack size exceeded`エラーを引き起こす原因となってしまいました。

これを回避するためconcat関数による配列結合に置き換わりました。

ただconcat関数は新しい配列を返すため、今回のように元の配列を変更する場合には、下記`for...of`とpush関数を使用するやり方でも良いかもしれません。

```TypeScript
for (const result of results) {
  specificData.push(result);
}
```

データ量に応じてパフォーマンスも変わるかもしれませんが、100万件のデータ取得でテストしたところconcat関数とさほどパフォーマンスの違いはありませんでした。

`push`と`concat`と`for...of`のパフォーマンスの差に関しては下記記事を参考にしています。

https://www.mathkuro.com/javascript/array-concat-performance/

https://ja.stackoverflow.com/questions/54125/%E9%85%8D%E5%88%97%E3%81%AB%E9%85%8D%E5%88%97%E3%82%92%E8%BF%BD%E5%8A%A0%E3%81%99%E3%82%8B%E3%83%99%E3%82%B9%E3%83%88%E3%83%97%E3%83%A9%E3%82%AF%E3%83%86%E3%82%A3%E3%82%B9

# まとめ

いかがでしたでしょうか。
Prisma（ORM）で大きなデータを扱う際にはまだまだPrisma（ORM）側のバグがあったりと課題がありそうなので、できるだけ生SQLクエリを用いてデータ取得をするのが良さそうです。

# 参考

https://qiita.com/irohafox/items/36023ab86ff01b2e8e47

https://github.com/prisma/prisma/issues/13864

https://www.prisma.io/docs/orm/prisma-client/queries/pagination

https://www.mathkuro.com/javascript/array-concat-performance/

https://ja.stackoverflow.com/questions/54125/%E9%85%8D%E5%88%97%E3%81%AB%E9%85%8D%E5%88%97%E3%82%92%E8%BF%BD%E5%8A%A0%E3%81%99%E3%82%8B%E3%83%99%E3%82%B9%E3%83%88%E3%83%97%E3%83%A9%E3%82%AF%E3%83%86%E3%82%A3%E3%82%B9