はじめに
今回は約100万件ほどのデータを生SQLクエリではなくPrisma(ORM)でデータ取得をするというところに焦点を当てて、実装までに当たった問題点や内容のご共有ができたらと思います。
環境
- TypeScript:4.7.4
- Prisma:5.9.0
- Provider:MySQL
実装したコード
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 : []
}
}
実装内容の説明
全体の処理の流れ
データ取得までの処理の流れとしては以下のようになります。
① 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
は変数specificData
にconcat
関数で配列結合をして再代入
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にて大量のデータを扱う際には、パフォーマンスの観点からクエリオプションのskip
とtake
も用いて、ページネーションのように分割してデータ取得することを推奨されているようなので、個人的には未対応なのも仕方がないのかなと思いました。
パフォーマンス向上のためにしたこと
取得するカラムを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関数とさほどパフォーマンスの違いはありませんでした。
push
とconcat
とfor...of
のパフォーマンスの差に関しては下記記事を参考にしています。
まとめ
いかがでしたでしょうか。
Prisma(ORM)で大きなデータを扱う際にはまだまだPrisma(ORM)側のバグがあったりと課題がありそうなので、できるだけ生SQLクエリを用いてデータ取得をするのが良さそうです。
参考