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分以内」の回帰テストを書いておく
-
Set
やMap
のキー設計が変わった際、挙動が崩れないかチェック
8.3 フェイルファストの工夫 {#fail-fast}
-
skipDuplicates: false
で重複を検出 - CTEの結果が0件だった場合は即座にログ&スキップ
- バッチサイズが環境変数化されている場合も、上限値を決めて監視
9. 最適化チェックリスト {#checklist}
-
ループ内の
findFirst
やcreate
を排除 -
既存データはまとめて取得し、
Set
/Map
に載せる -
createMany
でINSERTをバルク化(skipDuplicates: false
) -
IS DISTINCT FROM
で冪等性を保証 - 階層探索はRecursive CTEで1回のクエリにまとめる
- 実行時間をログ出力し、閾値監視を行う
- パフォーマンス回帰テストを用意
10. まとめ {#conclusion}
- N+1クエリはメモリキャッシュとバルク操作で徹底的に潰す
- 冪等性を担保すると、再実行やリカバリが怖くなくなる
- Recursive CTEや計測の仕組みで、長期的なパフォーマンスを維持
次回は、こうしたバッチ処理の挙動を安心してリリースできるようにするテスト戦略(Vitest + 実データ検証、psqlによる直接テストなど)をまとめます。引き続きチェックしてみてください!