Prismaを用いた定期バッチ処理を独自upsertで最適化した
こんにちは、Ubieの bosshi/ぼっしー です。
普段はSoftware Engineerとして医師監修メディア「ユビー 病気のQ&A」の開発をしています。
今回はPrismaを用いた定期バッチ処理の改善について紹介します。
背景
PrismaとはNode.js/TypeScript向けのORM(Object-Relational Mapping)[1]です。
hokacchaが過去に書いた記事「Node.jsで作るモジュラモノリスの設計と技術選定」でも紹介されているモジュラモノリスなサービスでPrismaを使用しており、「ユビー 病気のQ&A」の関連ドメインもmodule管理されています。
このmoduleが扱うデータは定期バッチ処理により、社内の別データソースからREST API経由でコンテンツデータを同期しています。
同期処理はDELETE AND INSERT方式(deleteMany -> createMany)を採用しており、毎回全件削除後に全件挿入することで冪等性を担保していました。
しかし、同期対象データ量の増加(数万件規模)によりtransaction timeout error(60秒指定超え)が発生するようになりました。
今後も同期対象データは線形に増えていく見通しから、根本的に処理を見直すことにしました。
3つの改善案の検討
前提としてwriterはバッチのみのため処理外の要因におけるrace conditionは発生しないものとします。
❌ 案1: Table Swap案
更新対象の一時テーブルを作成し、テーブル名をrenameすることで置き換える方針です。
swap処理以外は現在のDELETE AND INSERTと大きな処理内容の変更なく実現できそうですが、以下の課題がありました。
- DDL実行権限: テーブル作成・リネームには通常のDML権限以上の権限が必要
- 外部キー制約: リネーム時に外部キー制約が参照するテーブル名を一時的に無効化する必要がある
-
Prismaの制約: テーブル操作には
executeRawUnsafeによるraw SQL実行が必要で、型安全性が失われる - テストの困難さ: テーブル構造の動的変更をテストで再現・検証するのが困難
これらの課題から見送りました。
❌ 案2. Prismaのupsertメソッドを使う案
Prismaのupsertには内部的にnative_upsertとupsertの2種類があり、データベースの種類やスキーマ定義(ユニーク制約の有無など)によってquery-compilerがどちらを利用するか判定します。
-
native_upsert時: 発行クエリは
INSERT ... ON CONFLICT ... DO UPDATE(PostgreSQLなどで利用可能) -
upsert時: 発行クエリは
SELECT->INSERTorUPDATE(check-then-act方式)
しかし、どちらの方式でもupsertはレコード単位にしか実行できず、createManyのような一括処理ができません。つまり、数万件のデータに対して数万回のクエリが発行されてしまいます。結果として、更新対象データ量が変わらずタイムアウト問題は解決しません。
✅ 案3. 独自upsertによる差分同期案
findMany -> createMany / update でクエリ発行数を最小化するupsertを実現する方針です。
案2との違いは、新規データを一括処理できる点と更新対象を限定できる点です。
-
新規データは
createManyで一括挿入(1回のクエリで全件処理) -
更新データは個別更新が必要(
updateManyは異なるデータの一括更新に対応していないため) - 更新対象を直近3日以内のデータに限定することで、更新クエリ数を大幅に削減
更新対象の限定により、数万件のデータ同期でも更新クエリは百件~数百件前後程度に抑えられ、トランザクションタイムアウトを回避できます。
具体の実装例は次の通りです。
// 同期元APIから全データを取得
const documentsDataFromAPI = await this.fetchDocumentsFromAPI();
const toCreate: Prisma.DocumentsCreateManyInput[] = [];
const toUpdate: {
uniquedKey: string;
data: Prisma.DocumentsUpdateInput;
}[] = [];
// 既に作成済みデータのユニークキーを一括取得
const existingDocuments = await tx.documents.findMany({
select: { uniquedKey: true },
});
const existingKeys = new Set(existingDocuments.map((d) => d.uniquedKey));
// 更新対象も全件の更新は無駄があるため、一定の閾値(例: 直近3日前)を設ける
const cutoffDate = this.getRecentContentCutoffDate(); // 例: 3日前の日時を返す
// 同期元から取得したデータを新規作成(INSERT) or 更新(UPDATE)に振り分ける
for (const doc of documentsDataFromAPI) {
if (!existingKeys.has(doc.uniquedKey)) {
// 新規作成
toCreate.push(doc);
} else if (new Date(doc.updatedAt) >= cutoffDate) {
// 差分更新(例: 直近3日以内のデータのみ)
toUpdate.push({ uniquedKey: doc.uniquedKey, data: doc });
}
// noop (それ以外は処理をSKIP)
}
await this.db.client.$transaction(
async (tx) => {
// 新規データは一括作成(1回のクエリで完了)
if (toCreate.length > 0) {
await tx.documents.createMany({ data: toCreate });
}
// 更新データは個別更新
// updateMany()は異なるデータの一括更新に使えないため
for (const { uniquedKey, data } of toUpdate) {
await tx.documents.update({
where: { uniquedKey },
data,
});
}
// 同期元に存在しないデータを削除
const syncedKeys = new Set(
documentsDataFromAPI.map((doc) => doc.uniquedKey)
);
await tx.documents.deleteMany({
where: { uniquedKey: { notIn: Array.from(syncedKeys) } }
});
},
{ timeout: 60000 } // 60秒(デフォルトは5秒) ※元のDelete and Insert方式の設定時から変更無し
);
落とし穴: Prismaのundefined/null処理の違い
処理をupsertに変更する中で、従来のDELETE AND INSERT方式では発生しなかった問題に直面しました。
問題の発生
同期元APIから取得したデータで、特定のフィールド(例:categoryId)の値がundefinedで渡ってきていました。しかし、categoryIdフィールドがnullに更新されることを期待していたものの、実際には既存の値が残り続けました。
const data = {
title: doc.title,
content: doc.content,
categoryId: doc.categoryId, // <- undefinedが渡ってくる
};
await tx.documents.update({
where: { uniquedKey: doc.uniquedKey },
data,
});
// -> categoryIdがnull更新されない
これはPrisma デフォルト(v7.2.0現在)で値にundefinedが指定されたフィールドは処理が無視される仕様によるものでした。
Prisma Client differentiates between null and undefined:
- null is a value
- undefined means do nothing
従来のDELETE AND INSERT方式では、deleteManyで全件削除した後にcreateManyで全件挿入するため、フィールドがundefinedでも新規レコードとして挿入される際にnullが設定されていました。しかし、updateに変更したことで、undefinedのフィールドは更新対象から除外されるようになり、既存の値が残り続ける問題が発生しました。
最終的にはnullable fieldに対してはundefinedが渡ってきた場合に明示的にnullに変換する処理を加えるようにしました。
const data = {
title: doc.title,
content: doc.content,
categoryId: doc.categoryId ?? null, // <- undefinedをnullに変換
};
await tx.documents.update({
where: { uniquedKey: doc.uniquedKey },
data,
});
// -> categoryIdがnull更新される
Prisma公式紹介の予防策
今回は明示的にnullを渡すように変更しました。Prisma公式で紹介されている予防策についても触れておきます。
previewFeatures strictUndefinedChecks
クエリ引数(whereや data)にundefinedを渡すことを禁止する設定です。
値がundefinedを渡した場合に指定fieldへの処理が無視されるデフォルト(v7.2.0現在)の挙動がランタイムでエラーを発生するように変わります。
// NG
// Error: Invalid value for argument `data`: explicitly `undefined` values are not allowed.
await prisma.document.update({
where: { uniquedKey },
data: { title, content, uniquedKey, categoryId: undefined }
})
compilerOptions exactOptionalPropertyTypes
optionalなpropertyに明示的に undefined を代入することを禁止する設定です。
つまり「propertyが存在しない状態」と「値がundefinedである状態」の2つの混在した状態から、コンパイル時点で前者のパターンのみを許容するようになります。
interface Document {
title: string;
content: string;
categoryId?: number;
uniquedKey: string;
}
// OK
const doc1: Document = { title: "doc1", content: "OK case", uniquedKey: 'key' };
// NG
// Type 'undefined' is not assignable to type 'number'.
const doc2: Document = { title: "doc2", content: "NG case", uniquedKey: 'key', categoryId: undefined };
おわりに
本記事では、Prismaを用いた定期バッチ処理の改善について紹介しました。
独自upsertにより更新対象とするデータ量を最小限に限定(全件更新 -> 直近3日以内の更新のみ)でき、処理時間を大幅に短縮することができました。
今回は詳しく触れませんでしたが、PrismaのQueryCompilerがRustで書かれてWASMファイルとして出力されていたりと、技術的にも興味深いです。まだPrisma歴は浅いですが、今後も仲良くしていきたいです。
Ubie Tech Advent Calendar 2025の明日(22日目)担当はkaminaです!記事をお楽しみに!
-
前職まではRubyをメインで触っていたこともあり馴染みがあるORMapperはActiveRecord、泣かされたORMapperはMongoMapperです。 ↩︎
Discussion