web-dev-qa-db-ja.com

条件付きのINSERTおよびSELECTよりもOUTPUTを使用したMERGEの方が優れていますか?

「存在しない場合は挿入する」という状況によく遭遇します。 Dan Guzmanのブログ は、このプロセスをスレッドセーフにする方法について優れた調査を行っています。

文字列をSEQUENCEから整数に単純にカタログ化する基本的なテーブルがあります。ストアドプロシージャでは、値の整数キーが存在する場合はそれを取得するか、またはINSERTを取得して、結果の値を取得する必要があります。 _dbo.NameLookup.ItemName_列には一意性の制約があるため、データの整合性は危険にさらされませんが、例外に遭遇したくありません。

これはIDENTITYではないため、_SCOPE_IDENTITY_を取得できず、場合によっては値がNULLになることがあります。

私の状況では、テーブルのINSERT安全性のみを処理する必要があるので、MERGEを次のように使用する方がよいかどうかを判断しようとしています。

_SET NOCOUNT, XACT_ABORT ON;

DECLARE @vValueId INT 
DECLARE @inserted AS TABLE (Id INT NOT NULL)

MERGE 
    dbo.NameLookup WITH (HOLDLOCK) AS f 
USING 
    (SELECT @vName AS val WHERE @vName IS NOT NULL AND LEN(@vName) > 0) AS new_item
        ON f.ItemName= new_item.val
WHEN MATCHED THEN
    UPDATE SET @vValueId = f.Id
WHEN NOT MATCHED BY TARGET THEN
    INSERT
      (ItemName)
    VALUES
      (@vName)
OUTPUT inserted.Id AS Id INTO @inserted;
SELECT @vValueId = s.Id FROM @inserted AS s
_

条件付きのMERGEに続けてINSERTを付けてSELECTを使用してこれを実行することはできません。この2番目のアプローチは読者にはより明確であると思いますが、 「より良い」実践

_SET NOCOUNT, XACT_ABORT ON;

INSERT INTO 
    dbo.NameLookup (ItemName)
SELECT
    @vName
WHERE
    NOT EXISTS (SELECT * FROM dbo.NameLookup AS t WHERE @vName IS NOT NULL AND LEN(@vName) > 0 AND t.ItemName = @vName)

DECLARE @vValueId int;
SELECT @vValueId = i.Id FROM dbo.NameLookup AS i WHERE i.ItemName = @vName
_

または、私が考慮していない別のより良い方法があるかもしれません

他の質問を検索して参照しました。これは https://stackoverflow.com/questions/5288283/sql-server-insert-if-not-exists-best-practice は私が見つけることができる最も適切なものですが、あまり思われません私のユースケースに適用できます。 IF NOT EXISTS() THENアプローチに対する他の質問は、私には受け入れられないと思います。

12
Matthew

シーケンスを使用しているため、同じ__ NEXT VALUE FOR 関数-Id主キーフィールドのデフォルト制約にすでにある-を使用して、新しいId値を事前に生成できます。 。最初に値を生成するということは、SCOPE_IDENTITYがないことを心配する必要がないことを意味します。つまり、新しい値を取得するためにOUTPUT句または追加のSELECTを実行する必要がないことを意味します。 INSERTを実行する前に値が得られ、SET IDENTITY INSERT ON / OFFをいじる必要もありません:-)

これで全体的な状況の一部が処理されます。もう1つの部分は、2つのプロセスの同時実行の問題をまったく同時に処理し、まったく同じ文字列の既存の行を検出せず、INSERTを続行します。問題は、発生する可能性のある一意制約違反を回避することです。

これらの種類の同時実行性の問題を処理する1つの方法は、この特定の操作を強制的にシングルスレッド化することです。これを行う方法は、アプリケーションロック(セッション間で機能する)を使用することです。効果的ではありますが、衝突の頻度がおそらくかなり低いこのような状況では、これらは少し強引になる可能性があります。

衝突に対処するもう1つの方法は、衝突を回避しようとするのではなく、時々発生することを受け入れて処理することです。 SELECTにあるために現在存在していることがわかっているため、TRY...CATCH構文を使用して、特定のエラー(この場合は「一意の制約違反」、メッセージ2601)を効果的にトラップし、Idを再実行してCATCH値を取得できます。その特定のエラーでブロックします。その他のエラーは、一般的なRAISERROR/RETURNまたはTHROWの方法で処理できます。

テスト設定:シーケンス、テーブル、および一意のインデックス

USE [tempdb];

CREATE SEQUENCE dbo.MagicNumber
  AS INT
  START WITH 1
  INCREMENT BY 1;

CREATE TABLE dbo.NameLookup
(
  [Id] INT NOT NULL
         CONSTRAINT [PK_NameLookup] PRIMARY KEY CLUSTERED
        CONSTRAINT [DF_NameLookup_Id] DEFAULT (NEXT VALUE FOR dbo.MagicNumber),
  [ItemName] NVARCHAR(50) NOT NULL         
);

CREATE UNIQUE NONCLUSTERED INDEX [UIX_NameLookup_ItemName]
  ON dbo.NameLookup ([ItemName]);
GO

テストセットアップ:ストアドプロシージャ

CREATE PROCEDURE dbo.GetOrInsertName
(
  @SomeName NVARCHAR(50),
  @ID INT OUTPUT,
  @TestRaceCondition BIT = 0
)
AS
SET NOCOUNT ON;

BEGIN TRY
  SELECT @ID = nl.[Id]
  FROM   dbo.NameLookup nl
  WHERE  nl.[ItemName] = @SomeName
  AND    @TestRaceCondition = 0;

  IF (@ID IS NULL)
  BEGIN
    SET @ID = NEXT VALUE FOR dbo.MagicNumber;

    INSERT INTO dbo.NameLookup ([Id], [ItemName])
    VALUES (@ID, @SomeName);
  END;
END TRY
BEGIN CATCH
  IF (ERROR_NUMBER() = 2601) -- "Cannot insert duplicate key row in object"
  BEGIN
    SELECT @ID = nl.[Id]
    FROM   dbo.NameLookup nl
    WHERE  nl.[ItemName] = @SomeName;
  END;
  ELSE
  BEGIN
    ;THROW; -- SQL Server 2012 or newer
    /*
    DECLARE @ErrorNumber INT = ERROR_NUMBER(),
            @ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE();

    RAISERROR(N'Msg %d: %s', 16, 1, @ErrorNumber, @ErrorMessage);
    RETURN;
    */
  END;

END CATCH;
GO

テスト

DECLARE @ItemID INT;
EXEC dbo.GetOrInsertName
  @SomeName = N'test1',
  @ID = @ItemID OUTPUT;
SELECT @ItemID AS [ItemID];
GO

DECLARE @ItemID INT;
EXEC dbo.GetOrInsertName
  @SomeName = N'test1',
  @ID = @ItemID OUTPUT,
  @TestRaceCondition = 1;
SELECT @ItemID AS [ItemID];
GO

O.P。からの質問

これがMERGEより優れているのはなぜですか? WHERE NOT EXISTS句を使用して、TRYなしで同じ機能を取得できませんか?

MERGEにはさまざまな「問題」があります(@SqlZimの回答にいくつかの参照がリンクされているため、ここでその情報を複製する必要はありません)。また、このアプローチには追加のロックはありません(競合が少ない)ため、同時実行性の方が優れています。このアプローチでは、HOLDLOCKなどがなければ、一意制約違反が発生することは決してありません。動作することはほぼ保証されています。

このアプローチの背後にある理由は次のとおりです。

  1. この手順を十分に実行して、衝突を心配する必要がある場合は、次のことはしたくないでしょう。
    1. 必要以上のステップを踏んでください
    2. リソースのロックを必要以上に長く保持する
  2. 衝突は新しいエントリ(正確に同時にに送信された新しいエントリ)でのみ発生するため、最初にCATCHブロックに入る頻度はかなり低くなります。 1%の時間で実行されるコードではなく、99%の時間で実行されるコードを最適化する方が理にかなっています(両方を最適化するコストがない場合を除きます)。

@ SqlZimの回答からのコメント(強調を追加)

私は個人的に、それを回避するためにソリューションを試して調整することを好みます可能な場合。この場合、serializableからのロックの使用は強引なアプローチであるとは思われず、高い並行性を適切に処理できると確信しています。

「そして_ときに賢明」と述べるように修正されたなら、私はこの最初の文に同意します。技術的に可能であるからといって、その状況(つまり、意図されたユースケース)がその恩恵を受けるとは限りません。

このアプローチで私が目にする問題は、提案されている以上のロックをかけることです。 「シリアライズ可能」に関する引用されたドキュメントを再読することが重要です。具体的には次のとおりです(強調が追加されています)。

  • 他のトランザクションは、現在のトランザクションが完了するまで、現在のトランザクション内のステートメントによって読み取られるキーの範囲に該当するキー値を持つ新しい行を挿入できません。

さて、これがサンプルコードのコメントです:

SELECT [Id]
FROM   dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */

そこに術語には「範囲」があります。取得されるロックは、@vNameの値だけではなく、より正確には範囲starting atこの新しい値が移動する場所(つまり、新しい値のどちらかの側の既存のキー値の間)適合)、値自体ではありません。つまり、現在検索されている値に応じて、他のプロセスは新しい値の挿入をブロックされます。範囲の最上部でルックアップが実行されている場合、その同じ位置を占める可能性のあるものを挿入するとブロックされます。たとえば、値「a」、「b」、および「d」が存在する場合、1つのプロセスが「f」に対してSELECTを実行していると、値「g」または「e」(これらのいずれかが「d」の直後に来るため)。ただし、「c」の値は「予約済み」の範囲に配置されないため、挿入できます。

次の例は、この動作を示しています。

(クエリタブ(つまり、セッション)#1)

INSERT INTO dbo.NameLookup ([ItemName]) VALUES (N'test5');

BEGIN TRAN;

SELECT [Id]
FROM   dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
WHERE  ItemName = N'test8';

--ROLLBACK;

(クエリタブ(つまり、セッション)#2)

EXEC dbo.NameLookup_getset_byName @vName = N'test4';
-- works just fine

EXEC dbo.NameLookup_getset_byName @vName = N'test9';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1

EXEC dbo.NameLookup_getset_byName @vName = N'test7';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1

EXEC dbo.NameLookup_getset_byName @vName = N's';
-- works just fine

EXEC dbo.NameLookup_getset_byName @vName = N'u';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1

同様に、値「C」が存在し、値「A」が選択されている(したがってロックされている)場合、値「D」を挿入できますが、「B」の値は挿入できません。

(クエリタブ(つまり、セッション)#1)

INSERT INTO dbo.NameLookup ([ItemName]) VALUES (N'testC');

BEGIN TRAN

SELECT [Id]
FROM   dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
WHERE  ItemName = N'testA';

--ROLLBACK;

(クエリタブ(つまり、セッション)#2)

EXEC dbo.NameLookup_getset_byName @vName = N'testD';
-- works just fine

EXEC dbo.NameLookup_getset_byName @vName = N'testB';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1

公平を期すために、私の提案するアプローチでは、例外がある場合、この「シリアライズ可能なトランザクション」アプローチでは発生しないトランザクションログに4つのエントリがあります。しかし、前述したように、例外が1%(または5%)発生する場合、最初のSELECTが一時的にINSERT操作をブロックする可能性が非常に高い場合よりも、影響ははるかに小さくなります。

この「シリアライズ可能なトランザクション+ OUTPUT句」アプローチのもう1つの問題は、マイナーではありますが、OUTPUT句(現在の使用法)がデータを結果セットとして送り返すことです。結果セットには、単純なOUTPUTパラメーターよりも多くのオーバーヘッドが必要です(おそらく両側で、SQL Serverでは内部カーソルを管理し、アプリレイヤーではDataReaderオブジェクトを管理します)。単一のスカラー値のみを処理していること、および実行の頻度が高いことが前提であることを考えると、結果セットの余分なオーバーヘッドがおそらく追加されます。

OUTPUT句は、OUTPUTパラメータを返すような方法で使用できますが、一時テーブルまたはテーブル変数を作成し、その一時テーブル/テーブル変数からOUTPUTパラメータに値を選択する追加の手順が必要になります。 。

さらに明確化: 同時実行性とパフォーマンスに関する私のステートメントに対する@SqlZimの応答(元の回答)に対する@SqlZimの応答(更新された回答)への応答;-)

この部分が少し長い場合は申し訳ありませんが、現時点では2つのアプローチのニュアンスに達しています。

情報が提示される方法は、元の質問で提示されたシナリオでserializableを使用するときに発生する可能性があるロックの量について誤った仮定につながる可能性があると思います。

はい、私は偏見があることを認めますが、公平を期すためです。

  1. 人間が少なくともある程度は偏見を持たないようにすることは不可能であり、私はそれを最小限に抑えるよう努めていますが、
  2. 与えられた例は単純化されたものですが、それは過度に複雑化することなく動作を伝えるための説明を目的としたものです。過度の頻度を示唆することは意図されていませんでしたが、私は明示的に別の方法で述べていないことを理解しています。実際に存在する問題よりも大きな問題を示唆していると読むこともできます。以下でそれを明確にしようと思います。
  3. また、2つの既存のキー間の範囲をロックする例も含めました(「クエリタブ1」ブロックと「クエリタブ2」ブロックの2番目のセット)。
  4. 私はアプローチの「隠れたコスト」を見つけました(そしてボランティアしました)。これは、一意の制約違反のためにINSERTが失敗するたびに4つの追加のTran Logエントリになるためです。私は他の回答/投稿で言及されているのを見たことはありません。

@gbnの「JFDI」アプローチ、Michael J. Swartの「Ugly Pragmatism For The Win」の投稿、およびAaron BertrandのMichaelの投稿に対するコメント(パフォーマンスが低下したシナリオを示すテストについて)、および「Michael Jの適応に関するコメント」 。スチュワートの@gbnのTry Catch JFDIプロシージャの適応」と述べている:

既存の値を選択するよりも頻繁に新しい値を挿入する場合は、@ srutzkyのバージョンよりもパフォーマンスが向上する可能性があります。それ以外の場合は、このバージョンより@srutzkyのバージョンを優先します。

「JFDI」アプローチに関連するそのgbn/Michael/Aaronの議論に関して、私の提案をgbnの「JFDI」アプローチと同一視するのは正しくありません。 「取得または挿入」操作の性質上、既存のレコードのSELECT値を取得するには、IDを明示的に実行する必要があります。このSELECTはIF EXISTSチェックとして機能します。これにより、このアプローチはAaronのテストの「CheckTryCatch」のバリエーションとより同等になります。マイケルの書き換えられたコード(およびマイケルの適応の最終的な適応)には、最初に同じチェックを行うWHERE NOT EXISTSも含まれています。したがって、私の提案(マイケルの最終コードと彼の最終コードの適応に加えて)は、実際にはCATCHブロックをそれほど頻繁にヒットすることはありません。同じ存在しないItemNameが指定された2つのセッションがまったく同じ瞬間にINSERT...SELECTを実行して、両方のセッションがWHERE NOT EXISTSの「true」をまったく同じ瞬間に受け取り、両方がまったく同じ瞬間にINSERT。この非常に特殊なシナリオは、既存のItemNameを選択するか、他のプロセスが実行しようとしていないときに新しいItemNameを挿入するよりもはるかに少ない頻度で発生しますまったく同じ瞬間

上記のすべてを心に留めて:なぜ自分のアプローチを好むのですか?

最初に、「シリアライズ可能」アプローチでどのロックが行われるかを見てみましょう。上記のように、ロックされる「範囲」は、新しいキー値が適合する場所の両側の既存のキー値によって異なります。その方向に既存のキー値がない場合、範囲の開始または終了は、それぞれインデックスの開始または終了にすることもできます。次のインデックスとキーがあるとします(^はインデックスの始まりを表し、$はインデックスの終わりを表します)。

Range #:    |--- 1 ---|--- 2 ---|--- 3 ---|--- 4 ---|
Key Value:  ^         C         F         J         $

セッション55が次のキー値を挿入しようとした場合:

  • Aの場合、範囲#1(^からCまで)がロックされます。一意で有効な(まだ)場合でも、セッション56はBの値を挿入できません。ただし、セッション56は、DG、およびMの値を挿入できます。
  • Dの場合、範囲#2(CからFまで)がロックされます。セッション56はE(まだ)の値を挿入できません。ただし、セッション56は、AG、およびMの値を挿入できます。
  • Mの場合、範囲#4(Jから$まで)がロックされます。セッション56はXの値を挿入できません(まだ)。ただし、セッション56は、AD、およびGの値を挿入できます。

より多くのキー値が追加されると、キー値間の範囲が狭くなり、同じ範囲で競合する複数の値が同時に挿入される確率/頻度が減少します。確かに、これはmajorの問題ではありません。幸い、時間の経過とともに実際に減少する問題であるようです。

私のアプローチの問題については上記で説明しました。2つのセッションがsameキー値を同時に挿入しようとしたときにのみ発生します。この点で、発生する可能性がより高いものに帰着します。2つの異なるが、近いキー値が同時に試行されますか、または同じキー値が同時に試行されますか?答えは挿入を行うアプリの構造にあると思いますが、一般的に言えば、たまたま同じ範囲を共有する2つの異なる値が挿入される可能性が高いと思います。しかし、本当に知る唯一の方法は、O.P.sシステムで両方をテストすることです。

次に、2つのシナリオと、各アプローチがそれらをどのように処理するかを考えてみましょう。

  1. 一意のキー値に対するすべてのリクエスト:

    この場合、私の提案のCATCHブロックは決して入力されないため、「問題」はありません(つまり、4つのtranログエントリとその実行にかかる時間)。ただし、「シリアライズ可能」アプローチでは、すべての挿入が一意であっても、同じ範囲内の他の挿入をブロックする可能性が常にあります(ただし、非常に長くはありません)。

  2. 同時に同じキー値を要求する頻度が高い:

    この場合-存在しないキー値の着信リクエストに関して非常に低い一意性-私の提案のCATCHブロックは定期的に入力されます。この結果、失敗した挿入ごとに自動ロールバックが必要になり、4つのエントリがトランザクションログに書き込まれます。これは、毎回わずかなパフォーマンスヒットになります。しかし、全体的な操作が失敗することはありません(少なくともこれが原因ではありません)。

    (以前のバージョンの「更新された」アプローチには、デッドロックの影響を受ける問題がありました。これに対処するためにupdlockヒントが追加され、デッドロックが発生しなくなりました。) しかし、「シリアライズ可能」なアプローチでは(更新されて最適化されたバージョンでも)、操作がデッドロックします。どうして? serializableの動作は、読み取られてロックされた範囲のINSERT操作のみを防止するためです。その範囲でのSELECT操作を妨げません。

    この場合、serializableアプローチは追加のオーバーヘッドがないように思われ、私が提案しているものよりわずかにパフォーマンスが良いかもしれません。

パフォーマンスに関する多くの/ほとんどの議論と同様に、結果に影響を与える可能性のある非常に多くの要因があるため、何かがどのように実行されるかを実際に理解する唯一の方法は、それが実行されるターゲット環境で試してみることです。その時点では、それは意見の問題ではありません:)。

8
Solomon Rutzky