0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

バッチ処理を10倍速にする5つのTips: createMany・CTE・キャッシュ活用など

Posted at

1. はじめに {#introduction}

前回の記事で「プロジェクト単位に分割して進める」バッチ設計を紹介しました。設計自体はうまくいったものの、初期実装では1000グループ処理に5分ぐらいもかかり、とても運用に耐えませんでした。

この記事では、同じ構成をおよそ1分まで短縮したときに実施した最適化テクニックを、NestJS + Prismaを例にまとめます。クエリ回数削減やcreateManyによるバルクINSERTなど、ほかの言語・ORMでも応用できる考え方が中心です。

1.1 実装タスクで意識すること {#takeaways}

  • N+1クエリを潰すためのデータ先読み + Set/Map活用パターン
  • createManyでバルクINSERTを安全に実行する際の注意点
  • IS DISTINCT FROMを使った真の冪等性(NULL含む差分UPDATE)
  • 再帰CTEで無限ループを終わらせる方法
  • パフォーマンス計測とRegression防止の仕組み

2. パフォーマンス劣化の兆候を見つける {#detect}

2.1 最初にぶつかった壁: N+1地獄 {#n-plus-1}

for (const actual of projectActuals) {
  const detailId = detailsMap.get(actual.type);

  // ❌ ループ内で存在チェック
  const existing = await tx.targetMonthly.findFirst({
    where: { detailId, yearMonth: fiscalDate },
  });

  if (!existing) {
    // ❌ ループ内でINSERT
    await tx.targetMonthly.create({
      data: { detailId, yearMonth: fiscalDate, value: actual.amount },
    });
  }
}
  • findFirstがレコード数分走る → 100件で100回
  • createも1件ずつ → ネットワーク往復が100回
  • 結果: 1プロジェクト約5秒 × 1000プロジェクト = 83分

2.2 ログからのシグナル {#logs}

[DEBUG] SELECT ... WHERE detail_id = $1
[DEBUG] SELECT ... WHERE detail_id = $1
...(100回繰り返し)

同じSQLがループ内で何度も走っていたら、まずN+1を疑いましょう。

3. 解決策1: 既存データはまとめて取得 {#bulk-fetch}

3.1 メモリキャッシュ化 {#memory-cache}

const existingMonthlies = await tx.targetMonthly.findMany({
  where: { detailId: { in: Array.from(detailsMap.values()) } },
  select: { detailId: true, yearMonth: true },
});

const existingKeys = new Set(
  existingMonthlies.map((row) => `${row.detailId}_${toYYYYMM(row.yearMonth)}`),
);
  • 先に対象となりうるレコードをまとめて取得
  • キー(detailId + YYYYMM)でSet化 → O(1)で存在確認

3.2 INSERT対象をバッファに溜める {#buffer}

const toInsert: Array<{ detailId: number; yearMonth: Date; value: number }> = [];

for (const actual of projectActuals) {
  const detailId = detailsMap.get(actual.type);
  const ym = new Date(Date.UTC(actual.fiscalYear, actual.fiscalMonth - 1, 1));
  const key = `${detailId}_${toYYYYMM(ym)}`;

  if (existingKeys.has(key)) continue;

  toInsert.push({ detailId, yearMonth: ym, value: actual.amount });
  existingKeys.add(key);
}
  • ループ内では配列にデータを貯めるだけ
  • Setにも追加して重複挿入を防止

3.3 バルクINSERT {#create-many}

if (toInsert.length > 0) {
  await tx.targetMonthly.createMany({
    data: toInsert,
    skipDuplicates: false, // 重複はエラーにして気づけるように
  });
}

skipDuplicates: trueは“バグを握りつぶす”ので避けています。

4. YYYYMMキーと日時の扱い {#yyyymm}

4.1 可読性重視のキー形式 {#key-format}

const toYYYYMM = (date: Date): string => {
  const year = date.getUTCFullYear();
  const month = String(date.getUTCMonth() + 1).padStart(2, '0');
  return `${year}${month}`;
};
  • .getTime()は人間が読めない(1696118400000…)
  • 202510など、ログを目視で追える形式が吉
  • UTC前提で組んでおくとタイムゾーン差異で揺れにくい

5. 冪等性の徹底: IS DISTINCT FROM のすすめ {#idempotency}

5.1 差分UPDATEのSQL {#diff-update}

UPDATE target_monthly AS m
SET actual_value = source.amount
FROM (
  SELECT detail.id AS detail_id,
         make_date(actual.fiscal_year, actual.fiscal_month, 1) AS ym,
         actual.amount
  FROM actual_source AS actual
  JOIN target_detail AS detail ON ...
  WHERE actual.project_id = $1
) AS source
WHERE m.detail_id = source.detail_id
  AND m.year_month = source.ym
  AND m.actual_value IS DISTINCT FROM source.amount;

5.2 IS DISTINCT FROMの挙動 {#isd}

比較ケース != IS DISTINCT FROM
NULL vs NULL NULL false
1 vs NULL NULL true
1 vs 1 false false

NULLを含む差分判定がハマるポイントです。差分が無いときにupdated_atも動かないため、完全な冪等性が担保されます。

6. 解決策2: Recursive CTEで階層探索を1クエリに {#cte}

6.1 ループ版の問題点 {#loop-problem}

  • 親子関係を1件ずつ辿ると階層数だけクエリが発生
  • 1000プロジェクト × 5階層 = 5000クエリ

6.2 CTE版 {#cte-solution}

WITH RECURSIVE hierarchy AS (
  SELECT id, parent_id, type
  FROM organization
  WHERE id IN (...対象の組織ID...)

  UNION

  SELECT parent.id, parent.parent_id, parent.type
  FROM organization AS parent
  JOIN hierarchy AS child ON parent.id = child.parent_id
)
SELECT DISTINCT id
FROM hierarchy
WHERE type = 'TARGET_ROOT';

1回のクエリでルートまで遡れるため、問い合わせ回数が大幅に削減できます。

7. 施策と効果のまとめ {#results}

施策 目的 効果
メモリキャッシュ(Set/Map) N+1解消 DB問い合わせ99%削減
createManyバルクINSERT ネットワーク往復の削減 INSERT回数98%削減
IS DISTINCT FROM 冪等性保証 同じ値ではUPDATEしない
Recursive CTE 階層探索の効率化 クエリ回数80%削減

8. パフォーマンスを維持する仕組み {#maintain}

8.1 計測の自動化 {#measurement}

const start = performance.now();
await service.syncAll();
const elapsed = performance.now() - start;

this.logger.log(`Execution time: ${elapsed.toFixed(0)}ms`);
  • 実行時間は毎回ログに出す
  • 1000件処理で60秒以内、など閾値を決め、CIやアラートで監視

8.2 テストで劣化を防ぐ {#tests}

  • N=1000件のサンプルデータで「1分以内」の回帰テストを書いておく
  • SetMapのキー設計が変わった際、挙動が崩れないかチェック

8.3 フェイルファストの工夫 {#fail-fast}

  • skipDuplicates: falseで重複を検出
  • CTEの結果が0件だった場合は即座にログ&スキップ
  • バッチサイズが環境変数化されている場合も、上限値を決めて監視

9. 最適化チェックリスト {#checklist}

  • ループ内のfindFirstcreateを排除
  • 既存データはまとめて取得し、Set/Mapに載せる
  • createManyでINSERTをバルク化(skipDuplicates: false
  • IS DISTINCT FROMで冪等性を保証
  • 階層探索はRecursive CTEで1回のクエリにまとめる
  • 実行時間をログ出力し、閾値監視を行う
  • パフォーマンス回帰テストを用意

10. まとめ {#conclusion}

  1. N+1クエリはメモリキャッシュとバルク操作で徹底的に潰す
  2. 冪等性を担保すると、再実行やリカバリが怖くなくなる
  3. Recursive CTEや計測の仕組みで、長期的なパフォーマンスを維持

次回は、こうしたバッチ処理の挙動を安心してリリースできるようにするテスト戦略(Vitest + 実データ検証、psqlによる直接テストなど)をまとめます。引き続きチェックしてみてください!

0
1
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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?