7,000万行の大量UPSERT高速化:MERGEとLEFT JOIN(UPDATE+INSERT)を実行計画で最適化する方法

7,000万行超の大量UPSERTで、従来のMERGE(約25分)をUPDATE+INSERT(未存在のみ追加)に置き換えたところ、開発/単体テストでは約12分まで短縮できたのに、夜間バッチでは計算リソースを増やしているはずなのに約18分かかる――。このズレは「SQLの書き方」よりも、実行計画・統計情報・インデックス・分散/競合・I/Oといった環境要因で起きることが多い。

目次

結論:MERGEかLEFT JOIN方式かは「環境と実行計画」で決まる

最初に結論から言うと、「LEFT JOIN(UPDATE+INSERT)の方が必ず速い」「MERGEは遅い」という絶対則はありません。大量UPSERTの性能は、SQLの見た目ではなく、DBが選ぶ実行計画(クエリプラン)と、そこで発生するI/O(読み書き)・ログ・ロック・メモリ・(分散環境なら)データ移動のバランスで決まります。

開発環境で約12分、夜間バッチで約18分というズレは、LEFT JOIN方式そのものの良し悪しというよりも、「その環境で何がボトルネックになっているか」が違うサインであることが多いです。スケールアップしても短縮しないのは、CPU以外(ストレージI/O、トランザクションログ、ネットワーク、ロック競合など)が詰まっている、あるいはスケール条件に合わせて実行計画が変わり“別の遅さ”が出ている可能性があります。

大量UPSERTで比較されがちな2パターン

MERGE(単一ステートメントで更新+追加)

MERGEは「一致したらUPDATE、不一致ならINSERT」を1つの文で表現できます。要件を短く書ける反面、DB製品やバージョンによっては最適化が難しく、内部的にスプール(中間退避)や余計なソートが入りやすいケースもあります。

UPDATE(JOIN)+INSERT(未存在のみ)

多くの現場で採用されるのが、更新と追加を分離するやり方です。INSERT側はLEFT JOINで「未存在」判定をする実装が多いですが、意味の明確さと重複耐性の観点では、NOT EXISTS(アンチジョイン)で書くほうが事故が少なくなります。

観点MERGEUPDATE+INSERT(LEFT JOIN / NOT EXISTS)
可読性1文でまとまる。要件を表現しやすい2文になるが意図が分かりやすい。ログや失敗箇所も切り分けやすい
最適化のされやすさ製品・版によって差が大きい。スプール等が入ることがある単純なUPDATE/INSERTになり、意図したインデックスを使わせやすい
テーブルスキャン回数1回で済むこともあるが、内部で複数パスになる場合も設計次第で2回以上になりがち(更新と追加で別々に探索)
同時実行・競合条件次第でロックが強くなったり、競合が増えることがある分割実行により競合を減らしやすいが、2文の間の整合性に注意
スケール特性“計画が当たれば”強いが、外れると伸びない“計画が当たりやすい”ことがあるが、I/O・ログが支配すると頭打ち

まず把握すべき「UPSERTの内訳」:更新率・追加率・変更率

7,000万行のUPSERTと言っても、実際に更新される行が何%なのか、追加される行が何%なのかで最適解は変わります。さらに、更新対象の中でも値が変わらないのにUPDATEしている行が多いと、それだけでログ量とロックが増え、スケールが鈍ります。

指標意味なぜ重要か
更新率(マッチ率)ステージングのうち、既存行と一致してUPDATE対象になる割合高いほどログとロックが支配しやすい(CPUを増やしても伸びにくい)
追加率(未存在率)ステージングのうち、未存在でINSERTになる割合高いほど索引更新とページ分割(行ストア)/マイクロパーティション(列ストア)の影響が出やすい
変更率(真の更新率)UPDATE対象のうち、実際に値が変わる行の割合低いなら「差分がある行だけUPDATE」で大幅短縮できる可能性がある

この内訳を先に数値化しておくと、チューニングの当たりが付けやすくなります(例えば「更新率99%」ならI/O/ログ対策が主戦場、「追加率が大きい」ならINSERT時の索引更新や分散設計が主戦場、という具合です)。

「スケールアップしたらもっと速くなるはず」という期待が外れる理由

計算リソース(CPU/ワーカー)を増やすと、すべての処理が同じ比率で速くなるわけではありません。大量UPSERTは、次のようなCPU以外の要素が支配しやすい処理です。

  • トランザクションログの書き込み:UPDATEは書き換えログが増えがちで、ログ書き込み帯域が頭打ちになりやすい
  • ストレージI/O:更新対象の探索、インデックス更新、チェックポイント、VACUUM/自動メンテなどで読み書きが増える
  • ロック・ラッチ競合:同じキー範囲や同じページへの更新が集中すると待ちが増える
  • メモリ不足によるスピル:ハッシュ結合やソートが一時領域に退避して急激に遅くなる
  • 分散環境のデータ移動:MPP(分散DWH)ではシャッフルやブロードキャストが効くと伸びない

つまり、夜間バッチで18分かかるのは「CPUが足りない」のではなく、別の律速(ボトルネック)が支配している可能性が高い、ということです。開発環境ではCPUがボトルネックで12分、夜間はI/Oや競合がボトルネックで18分、という構図は珍しくありません。

最優先:単体テスト環境と夜間バッチ環境の“実行計画”を比べる

同じSQLでも、環境が違うと別の実行計画になり得ます。特に次の条件が変わると、最適化の判断が変わり、時間が大きく変動します。

  • 統計情報の鮮度(ロード直後に未更新、サンプリング率が低い)
  • テーブルサイズ・パーティションの増減、データ偏り
  • キャッシュ状態(温かい/冷たい)
  • 同時実行(夜間は他バッチが多い、オンライン処理が混ざる)
  • 並列度設定やリソースクラス(メモリ上限、同時実行上限)

改善の近道は「SQLをさらにこねる」より、実行計画と実行時内訳(待機・I/O・メモリ)を可視化して差分を取ることです。

観測すると原因が一気に絞れる“4点セット”

観測項目見るべき理由遅いときの典型サイン
実行計画(推定行数と実績行数)見積もりが外れると結合方式や並列化が崩れる推定が桁違い/ハッシュ結合のスピル/不要なソート
I/O量(読み取り・書き込み)スキャンや一時領域が増えるとCPUを増やしても効かない読み取りが増える/Temp領域が膨らむ
待機要因(ロック、ログ、I/O、ネットワーク)“何待ちで止まっているか”が分かる例:SQL ServerならWRITELOG、PostgreSQLならロック待ち、分散DWHならデータ移動待ちが増える
メモリ(メモリグラント、スピル)並列化で必要メモリが増え、足りないと一気に遅くなるハッシュ/ソートがディスク退避、実行時間が跳ねる

DB別:実行計画・プロファイルを取る“入口”

系統代表例まず見る機能(例)
行指向RDBSQL Server / PostgreSQL / MySQL / Oracle実行計画(Actual Plan / EXPLAIN ANALYZE)、実行統計(I/O・時間)、ロック/待機
分散DWH(MPP)クラウドDWH各種クエリプロファイル(ステージ時間、データ移動、スピル)、分散の偏り

SQL例(代表的な書き方)

INSERT側はLEFT JOINでも書けますが、NULLの扱いと重複耐性を考えると、次のようなNOT EXISTSが扱いやすいことが多いです。

-- 更新:一致するキーだけ更新(DB方言に合わせて調整)
UPDATE tgt
SET
  tgt.col1 = src.col1,
  tgt.col2 = src.col2,
  tgt.updated_at = CURRENT_TIMESTAMP
FROM target_table AS tgt
JOIN staging_table AS src
  ON tgt.id = src.id;

-- 追加:存在しないキーだけ挿入
INSERT INTO target_table (id, col1, col2, created_at, updated_at)
SELECT src.id, src.col1, src.col2, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
FROM staging_table AS src
WHERE NOT EXISTS (
  SELECT 1
  FROM target_table AS tgt
  WHERE tgt.id = src.id
);

ただし、これは「書き方の優劣」を示すものではなく、あくまで実行計画が読みやすく、観測もしやすいという意味合いです。速度はインデックス・統計情報・分散や並列度で変わります。

2文方式の落とし穴:二重挿入(レース)と重複データ

UPDATE+INSERTに切り分けると、「更新→追加」の間に別トランザクションが同じキーをINSERTしてしまうなど、同時実行時に二重挿入が起きる可能性があります。実運用では、次のガードを入れるのが現実的です。

リスク起きる原因実務でのガード
二重挿入(同一キー重複)INSERT判定後、実INSERTまでの間に他トランザクションがINSERTJOINキーにユニーク制約を付ける(まずこれ)。必要なら隔離レベル/ロック設定も検討
ステージング側の重複で更新が膨らむ同じidが複数行あり、UPDATEが複数回走る/不定になるステージング投入後にidで1行に正規化(最新行だけ残す、集約する)
NULLキーや不正キーJOINできずINSERT扱いになって膨らむロード前にバリデーション(NULL/型/範囲)を実施し、異常系を隔離

「速度」だけを追うとここを落としがちですが、7,000万行規模は失敗コストも大きいので、データ品質と制約で守ることが結果的に運用コストを下げます。

スケールアップしても伸びない“典型原因”と対策

夜間バッチで時間が伸びない/伸びが鈍いときは、次のどれかに当たっていることが多いです。表の「兆候」を実測できると、原因の特定が早くなります。

原因起きること兆候対策の方向性
トランザクションログ律速書き込みがログ待ちで詰まるログ関連の待機が多い/CPUは余っているバッチ分割、更新列削減、インデックス見直し、ログ先ストレージ強化
ストレージI/O律速読み取りや一時領域アクセスで詰まるI/O待ちが多い/読み取り量が異常索引・パーティション、キャッシュ、スピル削減、データ量削減
データ偏り(スキュー)一部キーに更新が集中し、並列が効かない一部ワーカーだけ長時間動く分散キー見直し、事前の集約・重複排除、ホットキー回避
統計情報が古い/不足件数見積もりが外れ、非効率な結合・ソートになる推定行数と実績行数が大きく乖離ロード後に統計更新、サンプリング率調整、ヒストグラム確認
インデックス不足・不適合探索がスキャンになり、更新コストも増えるフルスキャン/キー探索が遅いJOINキーに適切な索引、更新列の見直し、不要索引の整理
並列化の副作用並列オーバーヘッドやメモリ不足で逆に遅くなるスピル発生、メモリ不足、交換(Exchange)待ち並列度調整、メモリ上限/クラス調整、クエリ分割
夜間のリソース競合他バッチと奪い合いになり伸びない同時刻だけ遅い/日によって揺れるジョブ順序・同時実行数調整、優先度(リソースクラス)設定

インデックス設計:JOINキーが“速さ”を決める

7,000万行規模のUPSERTでは、JOINキー(例:id)に対して「探すのが速い」ことと、更新時に「余計なインデックス更新が発生しない」ことが両方重要です。

最低限の考え方

  • ターゲット側:JOINキーにユニーク(または高選択性)なインデックスがあるか
  • ステージング側:JOINキーでソート/ハッシュが組みやすい状態か(必要ならインデックスやクラスタリング)
  • 更新列:更新のたびに更新されるインデックス(含む)を増やし過ぎていないか
対象よく効く設計注意点
target_tableJOINキー(id)にユニークインデックス/パーティションキーと整合したクラスタインデックスが多すぎると更新コストが跳ねる(INSERT/UPDATEの両方で)
staging_tableロード後に重複排除(idで1行に)/idで索引 or ソート済みを維持ステージングに重複があると更新・追加が膨らみ、計画も崩れる
監査列(updated_at等)本当に必要なときだけ更新(差分がない行は更新しない)「値が変わっていないのにUPDATEする」だけでログ量とロックが増える

差分がない行を更新しない(ログ削減に直結)

大量UPSERTで効きやすいのが、「変化がある行だけUPDATEする」工夫です。たとえばハッシュ値や更新対象列の比較で、変更のない行を除外すると、ログ量・ロック・インデックス更新が減り、スケールしやすくなることがあります。

-- 例:値が変わる行だけ更新する(比較列は要件に合わせて)
UPDATE tgt
SET
  tgt.col1 = src.col1,
  tgt.col2 = src.col2,
  tgt.updated_at = CURRENT_TIMESTAMP
FROM target_table AS tgt
JOIN staging_table AS src
  ON tgt.id = src.id
WHERE (tgt.col1 IS DISTINCT FROM src.col1)
   OR (tgt.col2 IS DISTINCT FROM src.col2);

※ IS DISTINCT FROM は方言依存です。対応していないDBではNULLを考慮した比較式に置き換えます。

統計情報:ロード直後に“更新されていない”が最も多い落とし穴

開発環境ではテーブルが小さかったり、たまたま統計が新しかったりして計画が当たりやすいのに、本番夜間はデータが増えた/偏ったのに統計が古いままで、結合方式が外れて時間が伸びる、というパターンは非常に多いです。

  • ステージング投入後に統計を更新していない
  • 自動統計があっても更新タイミングが遅い(しきい値)
  • サンプリング率が低く、偏りを捉えられていない

統計の問題は、推定行数と実績行数の乖離として実行計画に現れます。乖離が大きい場合は、まず統計更新が第一候補です。

分散・偏り:MPP環境では“データ移動”がコストの主役になる

クラウドDWHや分散DB(MPP)では、計算リソースを増やすとワーカー数が増えますが、同時にネットワーク越しのデータ移動も増えます。特にUPSERTはターゲットとステージングのキー分布がズレると、シャッフル(再分散)が発生し、CPUよりネットワークとディスクが支配します。

偏り(スキュー)を疑うべきサイン

  • 処理時間の大半が「一部のステージ」や「一部ワーカー」に偏る
  • トップNのキーが極端に多い(同一idが大量、または特定日付に集中)
  • スケールアップしても、ある一定以上はほぼ短縮しない

対策としては、分散キー(distribution key)やクラスタリングの見直しに加え、ステージング側で事前に重複排除・集約してからUPSERTするのが実務的です。UPSERTの前に「idごとに最終行だけ残す」「同一キーの更新を1回に畳む」だけで、更新量が何分の一にもなり得ます。

バッチ分割:7,000万行を“1トランザクションで一気に”が遅さを呼ぶ

一括で処理すると、ログ・ロック・一時領域が膨らみ、途中失敗時のリカバリも重くなります。そこで効果的なのが、キー範囲や日付でバッチ分割し、コミットを刻む方法です。

分割設計の例

  • IDレンジ(1,000万件単位など)で区切る
  • 日付(取込日、更新日)で区切る
  • パーティション単位で区切る(可能ならパーティションスイッチも検討)
分割の狙い得られる効果注意点
ログ量の平準化ログ書き込み待ちを減らし、途中再実行が楽になる分割しすぎるとオーバーヘッドが増える(最適サイズを探す)
ロック競合の低減同じ範囲への同時更新が減り待ちが減るアプリ側の参照と干渉しない時間帯・範囲設計が必要
スピルの抑制ハッシュ/ソートに必要なメモリが減り、ディスク退避を防げる範囲選択が偏ると逆効果(偏りチェックが前提)

夜間バッチ特有の“見落とし”チェック

「開発では速いのに夜間だけ遅い」は、ほぼ必ず理由があります。SQL以外の要因を疑うチェックポイントを並べます。

  • 同時実行ジョブ:同じテーブル/同じストレージ/同じログ領域を使う処理が並走していないか
  • キューイング/スロットリング:夜間だけリソースクラスや同時実行上限で待たされていないか
  • キャッシュ:開発は繰り返し実行でキャッシュが温かいが、本番はコールドスタートになっていないか
  • メンテナンス:統計更新、バックアップ、クリーンアップ、再編成が同じ時間帯に走っていないか
  • ネットワーク:分散環境でデータ移動が増えるタイミング(他負荷)と重なっていないか

MERGEをやめる/続ける判断基準

MERGEは便利ですが、次の観点で判断すると失敗しにくいです。

判断軸MERGEが向きやすいUPDATE+INSERTが向きやすい
運用上の切り分け1文で完結させたい更新と追加の件数を別々に把握したい/失敗箇所を切り分けたい
性能の安定性実行計画が安定しており、統計・索引が整っている計画がブレやすい/統計更新が難しい/夜間の条件変動が大きい
同時実行の安全性適切な制約・隔離レベルで競合を制御できるトランザクションを分割し、再実行しやすい設計にしたい

どちらを選んでも、最終的には(1)適切な制約(ユニークキー)と(2)統計・索引と(3)観測に基づくチューニングが揃わないと、7,000万行規模では安定しません。

改善アクションチェックリスト(現場でそのまま使える)

最後に、今回のような「単体テストでは速いのに夜間バッチで伸びない」ケースで、効果が出やすい順にチェック項目を整理します。上から順に潰すと、遠回りせず原因に到達しやすくなります。

優先度チェック項目具体的にやること狙い
高単体テスト/夜間バッチの実行計画を比較推定行数と実績行数、結合方式(ハッシュ/マージ/ネスト)、スピル有無、一時領域の有無を並べて差分を見る「SQL差」か「環境差」かを最短で切り分ける
高統計情報の更新(特にステージング投入後)ロード直後に統計更新・ANALYZE相当を実行。サンプリング率やヒストグラムも確認件数見積もりの外れによる計画崩壊を防ぐ
高JOINキーのインデックス/制約を点検ターゲットのJOINキーにユニーク制約+適切な索引。ステージングの重複排除(idで1行に)もセットで実施探索を速くし、二重挿入や無駄更新を防ぐ
中データ偏り(スキュー)と分散を確認キーの頻度分布、パーティション/分散単位の行数差、ワーカー別処理時間の偏りを確認。偏りが大きいなら分散キー再検討スケールアップが効かない根本原因を潰す
中UPDATE/INSERTのバッチ分割IDレンジや日付で分割し、コミットを刻む。失敗時の再実行が容易な単位にするログ・ロック・メモリ圧迫を避けて安定化する
中夜間の実行条件を固定して比較同時実行ジョブを減らした時間帯で再計測、または同じリソースクラスで実行して揺れを抑える「夜間だけ遅い」要因(競合/スロットリング)を切り分ける
低差分がない行を更新しない変更率が低い場合、比較条件を追加してUPDATE対象を絞る(監査列の更新も要件を見直す)ログ量とI/Oを減らし、スケールしやすくする

最短で原因を特定するための実験設計

「どちらが速いか」を議論する前に、比較条件を揃えて“科学実験”の形にします。これをやるだけで、原因が環境依存なのかSQL依存なのかがはっきりします。

  1. 同じデータ(同じステージング内容、同じターゲット状態)で比較する
  2. 同じ実行条件(同時実行なし、同じリソースクラス、同じ並列度)で1回目・2回目を分けて測る
  3. 実行計画と実行時統計(行数、I/O、待機、スピル)を必ず保存する
  4. 結果は1回で決めず、揺れを見る(夜間は特に)

まとめ:この相談で見落としがちなポイント

  • LEFT JOIN方式が速いのは“その環境・その計画”でそうだっただけで、常勝ではない
  • スケールアップしても速くならないのは、CPU以外(ログ/I/O/競合/データ移動)が支配している合図
  • 最優先は「単体テスト vs 夜間バッチ」の実行計画と待機/I/O/スピルの差分比較
  • 統計更新、JOINキー索引、偏り対策、差分更新、バッチ分割で改善余地が大きい

最終的に“どちらが正解”ではなく、あなたのデータ分布・インデックス・統計・運用条件で最も安定して速い形を見つけることが正解です。観測→仮説→検証のサイクルで、夜間バッチの18分をさらに詰められる可能性は十分あります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次