SQL Serverで30億行級の累積テーブルに日次データを「既存にない行だけ」差分INSERTしたいのに、EXCEPTを使うと照合で何時間もかかる──。原因を分解し、NOT EXISTSとインデックス設計で現実的に高速化する手順をまとめます。
状況:30億行(約350GB)の巨大テーブルに日次10万行を追記したい
前提は次のようなケースです。全列が一致したら既存扱い(全列が照合条件)という「行単位の重複排除」をしながら、日次データを累積テーブルへ取り込みます。
| 項目 | 内容 | ポイント |
|---|---|---|
| ターゲット | dbo.large_accumulation_table(約30億行、約350GB) | 全表スキャンが起きると致命的に遅い |
| 日次データ | dbo.todays_data(約10万行) | 小さい側から巨大側へ「存在確認」をする設計が基本 |
| 要件 | 全列一致なら重複(既存)、一致しなければINSERT | NULLやFLOATの扱いで「一致」の定義がブレやすい |
| 現状SQL | INSERT… SELECT… EXCEPT… | 差分取得の内部処理が重くなりがち |
なぜEXCEPTが遅くなりやすいのか(巨大テーブル相手だと破綻しやすい理由)
EXCEPTは「左集合 − 右集合」を返す演算ですが、SQL Serverの実行計画上は、結果として重複排除(DISTINCT相当)を伴うことが多く、以下のようなコストが発生しやすくなります。
- 両集合を比較するためにソート/ハッシュが発生しやすい(特に列数が多い、行が大きいほど不利)
- 巨大側を丸ごと読み切る可能性が高い(30億行の読込=I/O地獄)
- メモリ不足でtempdbへスピルしやすい(ハッシュ/ソートがディスクに退避して遅延が増幅)
- 結果が小さくても、途中経過が巨大になり得る(「差分が少ない」ことは救いにならない)
ざっくり言うと、日次が10万行でも、EXCEPT側の計画が「大きいテーブル同士をセット演算で照合」になってしまうと、比較のために巨大テーブル全体を処理する方向へ寄ります。ここがボトルネックです。
| やりたいこと | 遅くなりがちな実装 | 速くしやすい実装 |
|---|---|---|
| 既存にない行だけ追加 | EXCEPT(セット演算+重複排除) | NOT EXISTS(反結合)+照合用インデックス |
| 重複排除の精度 | NULLは同値扱いになりやすい | NULL同士一致を明示しないと挙動が変わる |
| 巨大テーブルへのアクセス | 全体走査・大規模ソートになりやすい | 日次10万行→巨大側へ「インデックスSEEKで存在確認」 |
まず効く:EXCEPTをやめてNOT EXISTS(反結合)に置き換える
差分INSERTの基本は、「小さい集合(todays_data)を起点にして、巨大側(large_accumulation_table)に存在確認をかける」ことです。これを素直に書けるのがNOT EXISTSです。
基本形(NULLが存在しない/NULLを考慮しなくてよい場合)
まずは骨格です。実運用ではSELECT *は避け、列を明示してください(列追加や並び変更で事故りやすい、実行計画の読みやすさも落ちます)。
INSERT INTO dbo.large_accumulation_table
(amt, lastdate, [type], doc1, line1, type1, doc2, line2, type2)
SELECT
t.amt, t.lastdate, t.[type], t.doc1, t.line1, t.type1, t.doc2, t.line2, t.type2
FROM dbo.todays_data AS t
WHERE NOT EXISTS (
SELECT 1
FROM dbo.large_accumulation_table AS l
WHERE t.amt = l.amt
AND t.lastdate = l.lastdate
AND t.[type] = l.[type]
AND t.doc1 = l.doc1
AND t.line1 = l.line1
AND t.type1 = l.type1
AND t.doc2 = l.doc2
AND t.line2 = l.line2
AND t.type2 = l.type2
);
これ自体は「同値比較の羅列」ですが、照合列に合ったインデックスがあれば、SQL Serverは日次側の各行に対して巨大側へ高速な存在確認(SEEK)を行えるため、EXCEPTよりスケールしやすくなります。
EXCEPTと同じ判定にしたいなら要注意:NULL同士を一致扱いにする
重要な落とし穴がNULLです。t.col = l.colは、両方NULLでもTRUEになりません(UNKNOWN扱い)。一方で、EXCEPTはセット演算としてNULLを同値として扱う挙動になります。つまり、そのままNOT EXISTSにすると、EXCEPTと結果が変わる可能性があります。
NULLを含み得る列があるなら、列ごとに「NULL同士一致」を明示します。たとえばamtだけNULLがあり得るなら、次のようにします。
INSERT INTO dbo.large_accumulation_table
(amt, lastdate, [type], doc1, line1, type1, doc2, line2, type2)
SELECT
t.amt, t.lastdate, t.[type], t.doc1, t.line1, t.type1, t.doc2, t.line2, t.type2
FROM dbo.todays_data AS t
WHERE NOT EXISTS (
SELECT 1
FROM dbo.large_accumulation_table AS l
WHERE
(
(t.amt = l.amt)
OR (t.amt IS NULL AND l.amt IS NULL)
)
AND t.lastdate = l.lastdate
AND t.[type] = l.[type]
AND t.doc1 = l.doc1
AND t.line1 = l.line1
AND t.type1 = l.type1
AND t.doc2 = l.doc2
AND t.line2 = l.line2
AND t.type2 = l.type2
);
列が複数NULLになり得るなら、このNULL同値条件を対象列分だけ増やします。冗長に見えますが、「一致」の定義を仕様として固定するために必要です。
NULL同値の書き方は複数ある:読みやすさと安全性で選ぶ
NULL同値比較は、いくつか実装パターンがあります。それぞれメリット・デメリットがあるので、チームの運用方針に合わせて選ぶのがおすすめです。
| 実装パターン | 例 | メリット | 注意点 |
|---|---|---|---|
| ORで明示(推奨) | (a=b OR (a IS NULL AND b IS NULL)) | 意図が明確、誤判定が起きにくい | 列数が増えると長くなる |
| ISNULL/COALESCEで正規化 | ISNULL(a, -1)=ISNULL(b, -1) | SQLが短くなる | 置換値(-1など)が実データに出現すると誤判定 |
| 正規化した計算列を作る | NULL→固定値、型→統一してから比較 | 比較が短くなり、インデックスにも乗せやすい | 設計と移行が必要(巨大テーブルでは計画的に) |
EXCEPTは「日次側の重複」も落とす:NOT EXISTSでは明示が必要
EXCEPTは集合演算なので、左側(todays_data)に同じ行が2回あっても、結果は1行になりがちです。一方、NOT EXISTSはそのままだと重複行を2回INSERTしてしまいます。
日次データに重複が混ざる可能性が少しでもあるなら、次のどちらかを入れておくと安全です。
- SELECT DISTINCTで日次側を先に一意化(最も簡単)
- 一時テーブル/ステージングテーブルでユニーク制約を作り、そこで重複を弾く(大規模バッチで堅い)
INSERT INTO dbo.large_accumulation_table
(amt, lastdate, [type], doc1, line1, type1, doc2, line2, type2)
SELECT DISTINCT
t.amt, t.lastdate, t.[type], t.doc1, t.line1, t.type1, t.doc2, t.line2, t.type2
FROM dbo.todays_data AS t
WHERE NOT EXISTS (
SELECT 1
FROM dbo.large_accumulation_table AS l
WHERE
((t.amt = l.amt) OR (t.amt IS NULL AND l.amt IS NULL))
AND t.lastdate = l.lastdate
AND t.[type] = l.[type]
AND t.doc1 = l.doc1
AND t.line1 = l.line1
AND t.type1 = l.type1
AND t.doc2 = l.doc2
AND t.line2 = l.line2
AND t.type2 = l.type2
);
LEFT JOINでも書けるが、NULL判定の事故を避けるならNOT EXISTSが扱いやすい
反結合はLEFT JOINでも書けますが、列が多いほどJOIN条件の書き間違いが起きやすく、さらにNULL同値の条件も混ざると可読性が下がります。チームで統一するなら、まずはNOT EXISTSを基準にするのが無難です。
本丸:照合に効くインデックス(できれば一意制約)を用意する
NOT EXISTSに書き換えただけで速くなるケースもありますが、30億行クラスではインデックス設計が勝負です。照合条件が「全列一致」なら、照合に使う列の組み合わせに対して、巨大側に「探しやすい入口」を作る必要があります。
選択肢:どのインデックス戦略を選ぶべきか
| 戦略 | 概要 | メリット | 注意点 |
|---|---|---|---|
| 全列キーのUNIQUEインデックス | 照合列すべてをキーにして一意性を保証 | 重複が物理的に入らない/存在確認が速い | 列数・列幅が大きいとインデックスが巨大化しやすい |
| 全列キーの非ユニークインデックス | 一意性は保証しないが、SEEK用の入口を作る | NOT EXISTSが高速化しやすい | すでに重複があると判定が複雑化、データ品質に依存 |
| 行ハッシュ(計算列)+インデックス | 全列からハッシュを作り、まずハッシュで候補を絞る | 列数が多い/長いときに有効、比較が短くなる | 衝突リスクがゼロではないため「最終的な全列比較」が必要 |
| 日付で絞れるなら先頭キーを日付に寄せる | 例:lastdateが日次範囲に集中するなら先頭に配置 | 検索範囲が狭まり、I/O削減に効くことがある | データ分布に依存するため、統計と計画の確認が必須 |
全列をキーにしたインデックスは「重い」けれど、遅さの原因を根本から断つ
「インデックスを追加するとデータがもう1つ増えて350GBが倍になるのでは?」と心配されがちですが、ここは誤解されやすいポイントです。
- クラスター化インデックスの場合:テーブルの実体(データの並び)そのものがインデックスになります。別コピーを丸ごと持つイメージではありません。
- 非クラスター化インデックスの場合:キー列+行を特定するための情報(クラスタキー等)を持つため、列が多いほど増えます。ただし「必要な列だけ」をキーにできるなら増分は抑えられます。
とはいえ30億行です。インデックスの作成・再構築は時間・ログ・tempdbのいずれも大きく消費します。まずは次の方針で現実解を探るのが安全です。
- まずは照合に使う列(9列)の非クラスター化インデックスを検討し、実行計画がSEEKに寄るか確認する
- 「この9列の組み合わせが本当に一意」なら、最終的にはUNIQUE制約で重複を物理的に禁止する
- 列幅が大きくてインデックスが辛いなら、後述の行ハッシュも選択肢に入れる
日次の差分INSERTが“速くなる”インデックスの考え方
NOT EXISTSの条件がすべて等価比較(=)なら、理想はその列をキーにしたインデックスです。等価比較だけならキーの並び順は劇的には効きにくいものの、先頭に選択性の高い列を置くと探索の枝刈りがしやすくなることがあります。
例として、9列をキーにした非クラスター化インデックスを作るイメージです(実際は列型・NULL可否・データ分布・既存インデックスを見て調整してください)。
CREATE INDEX IX_large_accumulation_match
ON dbo.large_accumulation_table
(
lastdate,
[type],
doc1,
line1,
type1,
doc2,
line2,
type2,
amt
);
このインデックスがあると、NOT EXISTSの内側は「このキーで一致する行があるか?」を比較的低コストで探せるようになります。日次10万行なら、10万回のSEEKは現実的なラインに収まりやすいです(もちろんディスク性能やメモリ、バッファキャッシュ状況に依存します)。
“最強”はユニーク制約:二重取り込みを論理的に封じる
差分INSERTは、運用上「同じ日次を誤って2回流す」「ジョブがリトライされる」「別経路から同一行が入る」といった事故が起きがちです。SQLのWHERE句だけで防ぐのではなく、データベース側に一意性を持たせるのが最も堅牢です。
9列の組み合わせが本当に一意なら、次のようなUNIQUEインデックス(またはUNIQUE制約)を検討します。
CREATE UNIQUE INDEX UX_large_accumulation_row
ON dbo.large_accumulation_table
(
lastdate,
[type],
doc1,
line1,
type1,
doc2,
line2,
type2,
amt
);
これにより、万一アプリやバッチが重複行をINSERTしようとしてもDBが拒否します。さらに、NOT EXISTSの存在確認もこのインデックスで効率化されやすくなります。
補足:NULLを許す列が含まれる場合、UNIQUE制約でのNULLの扱いが要件に合うかは事前に確認が必要です。要件が「NULL同士も同一行として扱う」なら、NULLを許さない設計に寄せる、または「NULLを固定値へ正規化した列」を別途用意して一意化する、といった設計面の整理が有効です。
同時実行(レース)への備え:NOT EXISTSだけでは“理論上”二重取り込みが起き得る
バッチが単独で走るなら問題になりにくい一方で、取り込みジョブが多重起動したり、複数経路で同時INSERTする可能性があるなら要注意です。典型的なレースはこうです。
- セッションAが「存在しない」を確認
- 同時にセッションBも「存在しない」を確認
- AもBもINSERTしてしまい、重複が入る
これを確実に防ぐには、最終的に一意制約(UNIQUE)で物理的に禁止するのが王道です。制約を作れない事情がある場合は、隔離レベルやロックヒントで直列化する方法もありますが、システム全体への影響が大きいので、まずは一意制約+適切なインデックスで解決できないかを検討するのが現実的です。
列が多い・長いなら行ハッシュ(計算列)で“候補を絞る”
全列比較が長くなるほど、実行計画は重くなりがちです。そこで「行の内容からハッシュ値を作り、まずハッシュで当たりを付ける」手があります。考え方はこうです。
- 各行に対してrow_hash(SHA系など)を計算する
- 巨大側にrow_hashのインデックスを作る
- 照合時は「row_hash一致の候補だけ」を拾い、最後に全列比較で確定
ハッシュは理論上衝突があり得るため、ハッシュだけで重複判定を確定させないのが安全です(「候補の絞り込み」と割り切る)。
例(概念):
-- 例:計算列を追加(文字列化の方法は列型に合わせて要設計)
-- ALTER TABLE ... ADD row_hash AS (HASHBYTES(...)) PERSISTED;
INSERT INTO dbo.large_accumulation_table (...)
SELECT ...
FROM dbo.todays_data t
WHERE NOT EXISTS (
SELECT 1
FROM dbo.large_accumulation_table l
WHERE l.row_hash = t.row_hash
AND ((t.amt = l.amt) OR (t.amt IS NULL AND l.amt IS NULL))
AND ...(残りの全列比較)
);
「9列全部をインデックスキーにするのは重いが、ハッシュ1列なら入口を作れる」という場合に選択肢になります。特に、文字列列が長い、列数がさらに多い、といったスキーマでは検討余地があります。
運用で詰まりやすいポイントと対策(ログ・ロック・tempdb)
差分INSERTが遅いとき、SQL文そのもの以外に「運用条件」が効いていることも多いです。30億行テーブルは、処理が速くてもロック、ログ、tempdbで詰まると一気に体感が悪化します。
トランザクションログが肥大する/I/O待ちが増える
- 一度に10万行を入れると、環境によってはログ書き込みがボトルネックになる
- インデックスが多いほど、INSERTのたびにインデックス更新が増える
- 「遅い=CPU」ではなく、ログ待ち(WRITELOG)やディスク待ちのケースも多い
対策として、後述のバッチ分割は効果が出やすいです。また、バルクロードに近い形で入れるならTABLOCK指定や復旧モデルの影響が絡みますが、ここは運用ポリシー(RPO/RTO)と相談になります。
ロックとブロッキング:夜間バッチでも“読み取り”が足を引っ張る
巨大テーブルに対する存在確認は、インデックスがあっても読み取りが発生します。別の処理(参照系、集計、メンテ)が同時に走ると、ロック競合で遅くなることがあります。観測ポイントは次のとおりです。
- 差分INSERT中に待機が増えていないか(ブロッキングチェーン)
- 読み取り一貫性(スナップショット系)を使うかどうか
- 「NOT EXISTSで二重取り込みが起きない」保証が必要なら、SQLだけでなく一意制約も組み合わせる
バッチ分割:1万行ずつなど、負荷をならして詰まりにくくする
日次10万行でも、環境によっては一括INSERTが重いことがあります。小さく刻んでコミットすることで、ログやロックを平準化できます。
例(概念):
DECLARE @BatchSize int = 10000;
WHILE 1 = 1
BEGIN
;WITH cte AS (
SELECT TOP (@BatchSize)
t.amt, t.lastdate, t.[type], t.doc1, t.line1, t.type1, t.doc2, t.line2, t.type2
FROM dbo.todays_data t
WHERE NOT EXISTS (
SELECT 1
FROM dbo.large_accumulation_table l
WHERE ((t.amt = l.amt) OR (t.amt IS NULL AND l.amt IS NULL))
AND t.lastdate = l.lastdate
AND t.[type] = l.[type]
AND t.doc1 = l.doc1
AND t.line1 = l.line1
AND t.type1 = l.type1
AND t.doc2 = l.doc2
AND t.line2 = l.line2
AND t.type2 = l.type2
)
)
INSERT INTO dbo.large_accumulation_table
(amt, lastdate, [type], doc1, line1, type1, doc2, line2, type2)
SELECT
c.amt, c.lastdate, c.[type], c.doc1, c.line1, c.type1, c.doc2, c.line2, c.type2
FROM cte AS c;
IF @@ROWCOUNT = 0 BREAK;
END;
実際には「同じ行を何度も評価しない」ために、todays_data側に処理済みフラグを付ける/一時テーブルに詰め替えるなどの工夫を入れるとより堅くなります。とはいえ、まずはバッチ化だけでもログ待ちの山が崩れることがあります。
tempdbの圧迫:EXCEPTからNOT EXISTSに変えても安心しきらない
EXCEPTでtempdbが苦しくなるケースは多いですが、NOT EXISTSでも「日次側の重複排除のためのDISTINCT」「不足インデックスによるハッシュ結合」などが入ると、tempdbへ作業領域を取りに行く場合があります。次を意識するとトラブルシュートが速くなります。
- 実行計画でHash MatchやSortが巨大になっていないか
- メモリグラントが過大/不足になっていないか(不足はスピルにつながる)
- tempdbのデータファイル構成・I/O性能がボトルネックになっていないか
データ型・仕様の落とし穴:FLOATとNULLは「一致判定」を壊しやすい
差分INSERTは「一致判定が正しい」ことが前提です。ここがブレると、速くしても重複が入る/入るべきものが入らないという最悪の事故になります。特に注意したいのがFLOATとNULLです。
金額でFLOATを使うと“同じに見える値”が一致しないことがある
FLOATは二進浮動小数点で表現されるため、0.1のような十進小数を厳密に表せないことがあります。その結果、計算経路や取り込み経路の違いで微小な誤差が入り、=比較で一致しないことが起き得ます。
金額・数量など「桁と丸めが業務仕様で決まる値」は、基本的にDECIMAL(p,s)へ寄せるのが安全です。差分INSERTの一致判定が安定し、ハッシュ化や一意制約とも相性が良くなります。
| 型 | 向いている用途 | 差分INSERTの重複判定 |
|---|---|---|
| FLOAT | 誤差を許容する科学技術計算、近似値 | 微小差で不一致になりやすい(事故ポイント) |
| DECIMAL | 金額、数量、桁が重要な値 | 一致判定が安定しやすい(推奨) |
NULLは「未知」:NULL同士を一致と見なすか、仕様として決める
NULLを含む列があると、重複判定の仕様が揺れます。今回のように「EXCEPTの挙動と揃えたい」なら、NULL同士一致を明示したNOT EXISTSが必要です。
一方で、そもそも業務要件として「NULLは許さない」「NULLになるなら別のコード値に正規化する」ほうが、将来的な不具合は減ります。巨大テーブルは一度運用に入ると後から直すコストが跳ね上がるため、ここは性能チューニングと同じくらい重要です。
文字列列がある場合:空白・照合順序・大小文字で「同一」の定義が変わる
今回の列は数値・日付・コード系に見えますが、もし文字列列(varchar/nvarchar)が含まれる場合、以下も重複判定に影響します。
- 末尾空白の扱い(同じに見えても一致判定が揺れることがある)
- 照合順序(Collation)による大小文字・濁点・半角全角の扱い
- 正規化(全角/半角、表記揺れ)をどこで吸収するか
「全列一致」の重複排除は、DBの比較仕様がそのまま要件になります。性能だけでなく、仕様の固定という意味でも、列型と照合順序は整理しておくと後々ラクです。
実装テンプレ:速度と安全性を両立する“現実解”
ここまでの要点を踏まえ、現場で扱いやすい手順をテンプレ化します。ポイントは日次データを一度ステージングで整形し、そこから差分INSERTすることです。これにより「日次側の重複」「型変換」「NULLの正規化」を事前に吸収できます。
手順:日次データをステージングで一意化してから差分INSERT
- 日次データを一時テーブルへ格納
- 一時テーブルにユニークインデックス(または主キー)を作り、日次内の重複を排除
- ターゲットへNOT EXISTSで差分INSERT(ターゲット側には照合用インデックス)
例(概念、NULL同値をamtだけに適用する想定):
-- 1) ステージング(重複があり得るなら DISTINCT を付ける)
SELECT DISTINCT
amt, lastdate, [type], doc1, line1, type1, doc2, line2, type2
INTO #stage_todays
FROM dbo.todays_data;
-- 2) 日次内の重複を防ぐ(要件に応じて)
CREATE UNIQUE INDEX UX_stage_row
ON #stage_todays (lastdate, [type], doc1, line1, type1, doc2, line2, type2, amt);
-- 3) 差分INSERT
INSERT INTO dbo.large_accumulation_table
(amt, lastdate, [type], doc1, line1, type1, doc2, line2, type2)
SELECT
s.amt, s.lastdate, s.[type], s.doc1, s.line1, s.type1, s.doc2, s.line2, s.type2
FROM #stage_todays AS s
WHERE NOT EXISTS (
SELECT 1
FROM dbo.large_accumulation_table AS l
WHERE ((s.amt = l.amt) OR (s.amt IS NULL AND l.amt IS NULL))
AND s.lastdate = l.lastdate
AND s.[type] = l.[type]
AND s.doc1 = l.doc1
AND s.line1 = l.line1
AND s.type1 = l.type1
AND s.doc2 = l.doc2
AND s.line2 = l.line2
AND s.type2 = l.type2
);
この形にしておくと、改善の打ち手(インデックス追加、バッチ化、型変更、ハッシュ導入)を段階的に入れやすくなります。「いきなり本番テーブルへ直接当てる」よりも、運用事故が減り、検証もしやすいです。
チェックリスト:実行計画と計測で“どこが遅いか”を特定する
チューニングは闇雲にやるより、観測して当てるほうが速いです。最低限、次の観点で確認すると原因の切り分けが進みます。
| 確認ポイント | 見え方 | 改善の方向性 |
|---|---|---|
| 実行計画でSEEKになっているか | large_accumulation_table側がIndex Seekで存在確認している | 照合列インデックスを作る/統計情報を整える |
| Hash Match / Sortが巨大化していないか | メモリグラント不足でtempdbスピル、または過大で他処理を圧迫 | EXCEPT回避、DISTINCTの位置見直し、インデックスで回避 |
| 待機種別が何か | WRITELOG、PAGEIOLATCH、LCK_* など | バッチ化、I/O改善、同時実行の整理、隔離レベル検討 |
| 日次データの重複 | 同一行が複数ある | DISTINCT、ステージングのユニーク制約 |
| データ型の揺れ | FLOAT誤差、文字列正規化不足 | DECIMAL化、正規化列の導入 |
「SQLを書き換えたのに速くならない」ときは、インデックスが効いていない(SEEKになっていない)ことがほとんどです。30億行は、ちょっとした全表走査が即アウトです。
まとめ:30億行×全列一致の重複排除は“キー設計”がすべてを決める
- EXCEPTは巨大テーブル相手だと、重複排除の内部処理が重くなりやすい
- まずはNOT EXISTS(反結合)にして「日次→巨大側へ存在確認」を明示する
- EXCEPTと同じ結果にするなら、NULL同士一致を列ごとに明示する
- 本丸は照合列に効くインデックス(できればUNIQUE)を持つこと
- 同時実行があり得るなら、一意制約で物理的に重複を禁止して事故を止める
- 運用面ではバッチ分割でログ・ロックを平準化し、tempdbやI/Oの詰まりを避ける
- FLOATや文字列の揺れは、性能以前に一致判定そのものを壊すので型設計を見直す
差分INSERTを「SQLの小手先」だけで何とかしようとすると、30億行規模ではすぐ限界が来ます。現実的に速く、そして事故らない構成にするには、重複判定のキーを設計し、インデックス(または一意制約)としてDBに持たせるのが最も効きます。

コメント