8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-07-29

:sunny: はじめに

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

:four_leaf_clover: 環境

  • TypeScript:4.7.4
  • Prisma:5.9.0
  • Provider:MySQL

:gear: 実装したコード

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といったようにバッチサイズを定義

const batchSize = 150000

do...while文の中でクエリオプションのtakeによりバッチサイズ分のデータを取得(今回は1回のループで150,000個分のデータを取得)

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

  // ~~~ 省略 ~~~
  
} while (results.length === batchSize)

③ 取得できたデータの最後のidを取得して変数(lastId)に格納

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が大きいユーザーデータを取得するようにする

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を繰り返し実行

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は変数specificDataconcat関数で配列結合をして再代入

specificData = specificData.concat(results)

⑦ 取得したデータ数がバッチサイズに満たなかった場合、その時点でループ終了。specificDataをreturnする。
※今回のケースではspecificDataのlengthを条件に返すデータ量を絞っています。

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にてこちらのバグが指摘されており、未対応の状態になっています。※2024年7月現在時点

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

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

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

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

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

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

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

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

配列操作にconcatを使用する

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

specificData = specificData.concat(results)

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

specificData.push(...results)

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

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

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

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

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

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

まとめ

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

参考

8
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?