SQL Server MERGEでUPSERT時の不要なUPDATEを回避する方法(差分更新・NULL対策・性能改善)

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回目未存在なのでINSERTINSERT(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ソースST.col <> S.col の結果差分判定として使うと…
NULLNULLUNKNOWN「違わない」とも「違う」とも判定できず、条件が成立しない
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’実データで使わないことを保証する
bit0/1bitは取り得る値が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でUPSERT1文で書ける/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方式も含めて、保守性の高い形に寄せていくのがおすすめです。

この記事を書いた人

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

コメント

コメントする

目次