SQL ServerのMERGEでUPSERTを実装すると、キー一致行が値変更なしでも毎回UPDATEになり、ログ・ロック・トリガーなどの無駄が発生しがちです。差分がある行だけ更新する定番パターンとNULL対策、性能面の考え方を具体SQLで整理します。
MERGEのUPSERTで「変更がないのにUPDATE」が起きる理由
MERGEは、ターゲット(更新先)とソース(取り込み元)をキーで突き合わせ、条件に応じてINSERT/UPDATE/DELETEを1文で実行できる便利な構文です。ところが、よくあるUPSERTの書き方だと「キーが一致した行は常にUPDATEする」指定になっているため、値がまったく同じでも毎回UPDATE扱いになります。
SQL Serverは「UPDATE文が実行された」という事実を重視するため、結果として次のような“見えにくいコスト”が積み上がります。
| 起きること | 現場で困りやすいポイント | 影響が出る例 |
|---|---|---|
| トランザクションログが増える | バックアップや可用性構成(AGなど)で転送量が増える | 夜間バッチが伸びる、ログファイルが肥大化する |
| ロックが増える・保持される | 同時実行が多いと待ちが増えやすい | 参照系クエリがブロックされる |
| インデックス更新やページ分割の可能性 | 更新が多いほどI/Oが増え、断片化の原因にもなる | クラスタ化インデックスが肥大化する |
| UPDATEトリガーやCDC/監査が動く | 「変わっていないのに更新イベント」が大量発生する | 監査テーブルが膨れる、下流処理が無駄に走る |
| 更新日時・rowversionが進む | 「実データが変わっていないのに更新扱い」になる | 差分連携が誤検知する、キャッシュが無駄に無効化される |
つまり、問題の本質は「MERGEが悪い」ではなく、WHEN MATCHEDのUPDATEが“差分判定なし”で実行されていることです。解決策はシンプルで、WHEN MATCHEDに「差分があるときだけUPDATEする条件」を足します。
再現例:同じMERGEを2回実行してもUPDATEが発生する
まずは現象を把握しやすいように、最小構成で再現します。ポイントは、OUTPUT $actionで「INSERT/UPDATEが何件起きたか」を見える化することです。
-- ターゲット(本番)テーブル
CREATE TABLE dbo.Customer (
custid int NOT NULL CONSTRAINT PK_Customer PRIMARY KEY,
custname nvarchar(100) NULL,
custcity nvarchar(100) NULL
);
-- ソース(取り込み)テーブル:実務だとステージングや一時表を想定
CREATE TABLE dbo.CustomerStage (
custid int NOT NULL,
custname nvarchar(100) NULL,
custcity nvarchar(100) NULL
);
INSERT INTO dbo.CustomerStage (custid, custname, custcity) VALUES
(1, N'鈴木商店', N'東京'),
(2, N'田中商事', N'大阪');
-- ありがちなUPSERT(差分判定なし)
MERGE dbo.Customer AS T
USING dbo.CustomerStage AS S
ON T.custid = S.custid
WHEN MATCHED THEN
UPDATE SET
T.custname = S.custname,
T.custcity = S.custcity
WHEN NOT MATCHED BY TARGET THEN
INSERT (custid, custname, custcity)
VALUES (S.custid, S.custname, S.custcity)
OUTPUT $action AS merge_action, inserted.custid;
上記を同じステージングデータのまま2回実行すると、2回目は本来「何も変わっていない」状態でも、merge_actionにUPDATEが出ます。実務ではここが原因で「毎回更新されているように見える」「差分連携が走ってしまう」といった問題になります。
| 実行回数 | 想定したい動き | 差分判定なしMERGEの実際 |
|---|---|---|
| 1回目 | 未存在なのでINSERT | INSERT(OK) |
| 2回目 | 差分なしなので何もしない | UPDATE(不要な更新) |
解決策:WHEN MATCHEDに「差分があるときだけUPDATE」を書く
定番の解決策は、WHEN MATCHEDに条件を追加して「値が違う場合だけUPDATE」を実現することです。すでに実務で多く採用されているパターンで、不要な更新の大半を取り除けます。
基本形(NULLが入らない前提)
差分判定は「いずれかの列が違う」を表現するため、ORでつなぐのがポイントです(ANDにすると“全部違うときだけ”になり、更新漏れの原因になります)。
MERGE dbo.Customer AS T
USING dbo.CustomerStage AS S
ON T.custid = S.custid
WHEN MATCHED
AND (
T.custname <> S.custname
OR T.custcity <> S.custcity
)
THEN UPDATE SET
T.custname = S.custname,
T.custcity = S.custcity
WHEN NOT MATCHED BY TARGET THEN
INSERT (custid, custname, custcity)
VALUES (S.custid, S.custname, S.custcity)
OUTPUT $action AS merge_action, inserted.custid;
これだけで、2回目以降は「差分がない行」がUPDATE対象から外れ、ログやロック、トリガー発火などの無駄を抑えられます。
NULLの考慮:そのまま<>比較すると“差分があるのに検知できない”
ここが落とし穴です。SQL Serverの比較は三値論理(TRUE/FALSE/UNKNOWN)なので、NULLが絡むと<>や=の結果がUNKNOWNになります。WHERE句やMERGEの条件はUNKNOWNを「成立しない」と扱うため、NULLを含む列は差分判定が抜けることがあります。
| ターゲットT | ソースS | T.col <> S.col の結果 | 差分判定として使うと… |
|---|---|---|---|
| NULL | NULL | UNKNOWN | 「違わない」とも「違う」とも判定できず、条件が成立しない |
| NULL | ‘A’ | UNKNOWN | 本来は差分だが、条件が成立しない(更新漏れ) |
| ‘A’ | ‘A’ | FALSE | 差分なしとして正しく除外 |
| ‘A’ | ‘B’ | TRUE | 差分ありとして更新対象 |
そのため、NULLが入り得る列では、次のいずれかの方法で「NULL同士は同じ」「NULLと値は違う」を扱える形に変換してから比較します。
方法1:ISNULL/COALESCEで埋め値を入れて比較する(最も手軽)
質問文にある通り、もっとも簡単なのはISNULL(またはCOALESCE)で埋め値に置き換える方法です。NULLを空文字に変えるのは文字列では定番ですが、実データとして空文字が入り得るかは必ず確認してください。
WHEN MATCHED
AND (
ISNULL(T.custname, N'') <> ISNULL(S.custname, N'')
OR ISNULL(T.custcity, N'') <> ISNULL(S.custcity, N'')
)
THEN UPDATE SET
T.custname = S.custname,
T.custcity = S.custcity
数値・日付・GUIDなど型が異なる場合は、その型で「現実に入り得ない値」を埋め値にします。例をまとめます。
| 型 | 埋め値の例 | 注意点 |
|---|---|---|
| int / bigint | -1 や最小値など | 業務上取り得る値と衝突しないこと |
| decimal | -1.0 など | 丸め規則・スケールに注意 |
| datetime2 | ‘19000101’ など | 有効範囲に収まる値を選ぶ |
| uniqueidentifier | ‘00000000-0000-0000-0000-000000000000’ | 実データで使わないことを保証する |
| bit | 0/1 | bitは取り得る値が2つしかないため、埋め値方式は不向き |
埋め値方式は読みやすく速いことが多い一方で、「埋め値が実データに出現する」「型変換で意図しない比較になる」などのリスクがあります。運用設計で安全にできる場合に向いた方法です。
方法2:EXCEPTで“行として違うか”を判定する(NULLに強い)
列が増えて埋め値の管理がつらくなってきたら、EXCEPTを使って「比較対象列の組が同一か」を判定する書き方も実用的です。EXCEPTは集合演算なので、NULLも含めて“同じ行”として扱えるのがメリットです。
WHEN MATCHED
AND EXISTS (
SELECT
T.custname, T.custcity
EXCEPT
SELECT
S.custname, S.custcity
)
THEN UPDATE SET
T.custname = S.custname,
T.custcity = S.custcity
比較対象列が多いほど効果を発揮しますが、内部的には行構成の処理が入るため、単純なISNULL比較よりCPUコストが増えることがあります。とはいえ、不要なUPDATEの削減効果が大きい環境では、総合的にプラスになりやすいです。
方法3:ハッシュ比較で“変更検知”をまとめる(列が非常に多い場合)
更新対象列が数十列以上ある、あるいは差分条件のメンテナンスを極力減らしたい場合は、比較対象列からハッシュを作って比較する方法もあります。ただし、文字列連結のルール・型変換・区切り文字などを誤ると誤判定の原因になるため、運用ルールを固めた上で採用するのが安全です。
-- 例:文字列化してSHA2_256で比較(概念例)
WHEN MATCHED
AND HASHBYTES('SHA2_256',
CONCAT(
ISNULL(T.custname, N''), N'|',
ISNULL(T.custcity, N'')
)
)
<>
HASHBYTES('SHA2_256',
CONCAT(
ISNULL(S.custname, N''), N'|',
ISNULL(S.custcity, N'')
)
)
THEN UPDATE SET
T.custname = S.custname,
T.custcity = S.custcity
ハッシュ方式は「差分判定のSQLが短くなる」メリットが大きい反面、ハッシュ計算自体のCPUコストが増えます。大規模バッチでは、ハッシュをステージング側で事前計算して列として持たせ、MERGE側はその列だけ比較する設計にすると安定します。
| 差分判定の方法 | NULL耐性 | 読みやすさ | 実装コスト | 性能の傾向 |
|---|---|---|---|---|
| 列ごとに<>比較 | 弱い(NULLで崩れる) | 高い | 低い | 軽いがNULL対策が必須 |
| ISNULL/COALESCEで埋め値 | 中〜高(埋め値次第) | 高い | 低〜中 | 多くのケースで速い |
| EXCEPTで行比較 | 高い | 中 | 中 | 列が多いほど書きやすい。CPUは増えることがある |
| ハッシュ比較 | 中〜高(設計次第) | 高い | 中〜高 | 比較列が非常に多いときに有効。CPU増を許容できるかが鍵 |
不要なUPDATEを避けると、何が改善しやすいのか
差分がない行をUPDATE対象から外すと、次のような改善が期待できます。特に「ステージングには毎回大量に来るが、実際に変わるのは一部」というデータ連携では効果が出やすいです。
- ログ量の削減:UPDATEはINSERTよりもログが重くなりやすく、無駄なUPDATEが多いほど影響が大きい
- ロック競合の緩和:更新ロック・排他ロックの取得範囲が減る
- インデックス保守の軽減:更新に伴うインデックスのメンテや断片化の進行を抑えやすい
- 下流処理の抑制:トリガー、CDC、監査、レプリケーション、ETLの差分検知が過剰に動くのを防ぐ
- 更新日時の意味が保たれる:本当に変わった行だけが更新扱いになる
性能は良くなる?効果が出やすい・出にくいパターン
「差分条件を足すと必ず速くなる」とは限りません。比較処理(差分判定)の分だけCPUは増えるため、データの性質に合わせて判断します。
| データの状況 | 差分条件追加の効果 | 理由 |
|---|---|---|
| 一致行の大半が変更なし | 改善しやすい | UPDATEが激減し、ログ・ロック・インデックス更新の削減効果が大きい |
| 一致行の大半が変更あり | 差が小さいことが多い | 結局ほとんどUPDATEするため、比較のCPUが純増になりやすい |
| 比較対象列が少ない(数列) | 改善しやすい | 差分判定が軽く、UPDATE削減のリターンが出やすい |
| 比較対象列が多い・巨大な列(nvarchar(max)など)を含む | 要検証 | 差分判定自体が重い可能性。ハッシュ事前計算などの工夫が必要 |
| UPDATEトリガー・監査・CDCが重い | 改善インパクトが大きい | 不要な更新イベントを減らす効果が直接出る |
実データで測定する手順(実行プラン・IO/TIME・ログ量)
最終的には、実データ量・インデックス・同時実行の状況で結果が変わります。机上の比較ではなく、次の手順で数字を取りにいくのが確実です。
手順1:OUTPUTでINSERT/UPDATE件数を把握する
差分条件を入れたMERGEが意図通りに動いているか、まず件数で確認します。
DECLARE @Result TABLE (
action nvarchar(10),
custid int
);
MERGE dbo.Customer AS T
USING dbo.CustomerStage AS S
ON T.custid = S.custid
WHEN MATCHED
AND (
ISNULL(T.custname, N'') <> ISNULL(S.custname, N'')
OR ISNULL(T.custcity, N'') <> ISNULL(S.custcity, N'')
)
THEN UPDATE SET
T.custname = S.custname,
T.custcity = S.custcity
WHEN NOT MATCHED BY TARGET THEN
INSERT (custid, custname, custcity)
VALUES (S.custid, S.custname, S.custcity)
OUTPUT $action, inserted.custid INTO @Result(action, custid);
SELECT action, COUNT(*) AS cnt
FROM @Result
GROUP BY action;
手順2:STATISTICS IO/TIMEで相対比較する
同じ入力データ、同じ条件で「差分判定なし」と「差分判定あり」を比較します。
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
-- ここにMERGE(差分判定なし / あり)を実行
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
IO(論理読み取り)とCPU/経過時間の変化を見ます。差分判定を足すとCPUが増えることはありますが、UPDATE削減によりIOや待機が減って結果的に速くなるケースが多いです。
手順3:ログ量やファイル増加を観測する
環境により監視方法は異なりますが、少なくとも「不要なUPDATEがログを増やしているか」は確認しておくと判断がブレません。代表的には次のような観測があります。
- バッチ前後でログ使用率・サイズの変化を見る(運用監視ツールでも可)
- 必要ならログバックアップのサイズ推移を見る(フル復旧モデルの場合)
実務で効く:更新日時・更新者を“差分がある時だけ”更新する
多くのテーブルにはModifiedAtやModifiedByのような更新管理列があります。差分判定なしでMERGEすると、値が変わっていないのに更新日時が毎回更新され、後続処理の差分抽出が破綻しがちです。差分条件を入れると、更新管理列も「本当に変わったときだけ」更新できます。
WHEN MATCHED
AND (
ISNULL(T.custname, N'') <> ISNULL(S.custname, N'')
OR ISNULL(T.custcity, N'') <> ISNULL(S.custcity, N'')
)
THEN UPDATE SET
T.custname = S.custname,
T.custcity = S.custcity,
T.ModifiedAt = SYSDATETIME(),
T.ModifiedBy = S.ModifiedBy;
「更新日時を信用できる」状態に戻るだけで、運用の見通しが一気に良くなることがあります。単純な性能改善だけでなく、データ品質・監査の観点でも価値があります。
MERGEが複雑になるなら、UPDATEとINSERTを分けるのも有力
差分条件を追加しても、要件が増えるほどMERGEは読みづらくなりがちです。現場によっては、最初から「UPDATE(差分ありだけ)→ INSERT(未存在だけ)」の2文に分ける設計が好まれます。可読性が上がり、デバッグや障害対応が楽になることが多いからです。
| 方式 | メリット | 注意点 |
|---|---|---|
| MERGEでUPSERT | 1文で書ける/OUTPUTで処理結果を取りやすい | 要件が増えると複雑化しやすい。ソース側の重複キーなどに注意 |
| UPDATE + INSERTに分割 | 読みやすい/段階的に最適化しやすい | 同時実行がある場合は整合性(競合)を意識したロック設計が必要 |
分割する場合のテンプレートは次の通りです。UPDATE側に差分条件を入れるのがポイントです。
-- 1) 差分がある行だけUPDATE
UPDATE T
SET
T.custname = S.custname,
T.custcity = S.custcity
FROM dbo.Customer AS T
JOIN dbo.CustomerStage AS S
ON T.custid = S.custid
WHERE
ISNULL(T.custname, N'') <> ISNULL(S.custname, N'')
OR ISNULL(T.custcity, N'') <> ISNULL(S.custcity, N'');
-- 2) 未存在の行だけINSERT
INSERT INTO dbo.Customer (custid, custname, custcity)
SELECT S.custid, S.custname, S.custcity
FROM dbo.CustomerStage AS S
WHERE NOT EXISTS (
SELECT 1
FROM dbo.Customer AS T
WHERE T.custid = S.custid
);
差分更新つきMERGEのおすすめテンプレート
最後に、実務でそのまま流用しやすい形のテンプレートを載せます。差分判定(NULL対策込み)と、結果を追えるOUTPUTをセットにしておくと運用が楽です。
DECLARE @MergeLog TABLE (
action nvarchar(10),
custid int
);
MERGE dbo.Customer AS T
USING dbo.CustomerStage AS S
ON T.custid = S.custid
WHEN MATCHED
AND (
ISNULL(T.custname, N'') <> ISNULL(S.custname, N'')
OR ISNULL(T.custcity, N'') <> ISNULL(S.custcity, N'')
)
THEN UPDATE SET
T.custname = S.custname,
T.custcity = S.custcity
WHEN NOT MATCHED BY TARGET THEN
INSERT (custid, custname, custcity)
VALUES (S.custid, S.custname, S.custcity)
OUTPUT $action, inserted.custid INTO @MergeLog(action, custid);
-- 実行結果を確認(運用ログにも使える)
SELECT action, COUNT(*) AS cnt
FROM @MergeLog
GROUP BY action;
よくある落とし穴チェックリスト
- ソース側に重複キーがある:同じキーで複数行が来ると、MERGEがエラーになったり意図しない結果になります。ステージング投入時に重複排除(集約)する設計が安全です。
- 差分判定の列がUPDATE対象とズレている:差分判定に含めていない列をUPDATEしていると、想定と異なる更新が起きます。基本は「UPDATEする列は差分判定にも入れる」です。
- 空文字とNULLの扱いが業務ルールと合っていない:埋め値方式はここで事故りやすいので、ルール(空文字を許すか)を決めてから実装します。
- 浮動小数点(float/real)をそのまま比較している:誤差で常に差分扱いになることがあります。桁を丸める、decimalに寄せるなどを検討します。
- インデックスが不足している:突き合わせキー(ON句の列)に適切なインデックスがないと、差分判定以前に結合コストが支配的になります。
まとめ:不要なUPDATEを止めるだけで、運用も性能も安定する
MERGEのUPSERTで「変更がない行までUPDATEされる」問題は、WHEN MATCHEDに差分条件を追加するだけで解消できるケースが大半です。まずはISNULL/COALESCEでNULLを吸収した差分条件を入れ、OUTPUT $actionで件数、STATISTICS IO/TIMEで性能を確認すると、効果を定量的に判断できます。要件が増えて複雑になってきたら、UPDATE/INSERT分割やEXCEPT方式も含めて、保守性の高い形に寄せていくのがおすすめです。

コメント