🔄

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管理されています。

https://www.prisma.io/

この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_upsertupsertの2種類があり、データベースの種類やスキーマ定義(ユニーク制約の有無など)によってquery-compilerがどちらを利用するか判定します。

  • native_upsert時: 発行クエリは INSERT ... ON CONFLICT ... DO UPDATE(PostgreSQLなどで利用可能)
  • upsert時: 発行クエリは SELECT -> INSERT or UPDATE(check-then-act方式)

https://github.com/prisma/prisma-engines/blob/0c8ef2ce45c83248ab3df073180d5eda9e8be7a3/query-compiler/core/src/query_graph_builder/write/upsert.rs#L53-L183

しかし、どちらの方式でも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

https://www.prisma.io/docs/orm/prisma-client/special-fields-and-types/null-and-undefined#current-behavior

従来の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

クエリ引数(wheredata)に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です!記事をお楽しみに!

脚注
  1. 前職まではRubyをメインで触っていたこともあり馴染みがあるORMapperはActiveRecord、泣かされたORMapperはMongoMapperです。 ↩︎

Ubie テックブログ

Discussion