シーケンスを使用しているので、同じNEXT VALUE FOR関数(Id
主キーフィールドの既定の制約に既にある)を使用して、Id
事前に新しい値を生成できます。最初に値を生成するということは、を持たないことを心配する必要がないことをSCOPE_IDENTITY
意味します。それは、新しい値を取得するためにOUTPUT
句や追加SELECT
を行う必要がないことを意味します。あなたがする前にあなたは価値を持つでしょうINSERT
SET IDENTITY INSERT ON / OFF
コードをいじる必要さえありません。
そのため、全体的な状況の一部を処理します。もう1つの部分は、まったく同じ文字列の既存の行を見つけずに、まったく同時に2つのプロセスの同時実行の問題を処理し、INSERT
ます。懸念事項は、発生するであろう一意の制約違反を回避することです。
これらのタイプの並行性の問題を処理する1つの方法は、この特定の操作を強制的にシングルスレッドにすることです。そのための方法は、アプリケーションロック(セッション間で機能する)を使用することです。効果的ではありますが、衝突の頻度がおそらくかなり低いこのような状況では、それらは少々手荒くなります。
衝突に対処する他の方法は、衝突を回避しようとするのではなく、衝突が時々発生することを受け入れて処理することです。TRY...CATCH
コンストラクトを使用すると、特定のエラー(この場合:「一意の制約違反」、メッセージ2601)を効果的にトラップし、その特定のブロックに存在するために値が存在することがわかっているSELECT
ため、再実行して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
OPからの質問
なぜこれが良いのMERGE
ですか?句TRY
を使用せずに同じ機能を取得できませんWHERE NOT EXISTS
か?
MERGE
さまざまな「問題」があります(いくつかの参照は@SqlZimの回答にリンクされているため、ここでその情報を複製する必要はありません)。そして、このアプローチには追加のロックはありません(競合が少ない)ので、並行性のほうが優れているはずです。このアプローチでは、ユニーク制約違反は発生しません。HOLDLOCK
。。動作することはほぼ保証されています。
このアプローチの背後にある理由は次のとおりです。
- 衝突を心配する必要があるほどこのプロシージャの実行が十分にある場合、次のことはしたくないでしょう。
- 必要以上のステップを踏む
- 必要以上に長くリソースをロックする
- 衝突は新しいエントリ(まったく同じ時間に送信された新しいエントリ)でのみ発生するため
CATCH
、そもそもブロックに陥る頻度はかなり低くなります。1%の時間を実行するコードではなく、99%の時間を実行するコードを最適化する方が理にかなっています(両方を最適化する費用がない場合を除き、ここではそうではありません)。
@SqlZimの回答からのコメント(強調を追加)
私は個人的には解決策を試して調整し、可能なときにそれを避けることを好みます。この場合、ロックを使用することは手間serializable
がかかるとは思わず、高い並行性をうまく処理できると確信しています。
「そして_慎重な」と述べるように修正された場合、この最初の文に同意します。技術的に可能だからといって、その状況(つまり、意図したユースケース)がその恩恵を受けることを意味するわけではありません。
このアプローチで見られる問題は、提案されている以上にロックすることです。「serializable」、特に以下の強調されたドキュメントを再読することが重要です:
- 他のトランザクションは、現在のトランザクションが完了するまで、現在のトランザクションのステートメントによって読み取られたキーの範囲に収まるキー値を持つ新しい行を挿入できません。
次に、サンプルコードのコメントを示します。
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
そこに有効な言葉は「範囲」です。取得されるロックは、の値だけ@vName
でなく、より正確には範囲です、この新しい値が移動する場所(つまり、新しい値が収まる場所の両側にある既存のキー値の間)。ただし、値自体ではありません。つまり、現在検索されている値に応じて、他のプロセスは新しい値を挿入できなくなります。ルックアップが範囲の最上部で行われている場合、同じ位置を占める可能性のあるものを挿入することはブロックされます。たとえば、値 "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
、元の質問で示したシナリオで使用するときに発生する可能性のあるロックの量に関する誤った仮定につながる可能性があると思います。
はい、公平であるとはいえ、偏見があることは認めます。
- 人間が偏らないようにすることは、少なくともある程度は不可能であり、私はそれを最小限に抑えようとしていますが、
- 与えられた例は単純化されていますが、それは説明を目的としており、動作を過度に複雑にすることなく伝えています。過度の頻度を意味することは意図していませんでしたが、私は明示的に別のことを述べておらず、実際に存在するよりも大きな問題を暗示するように読めることを理解しています。以下でそれを明確にしようとします。
- また、2つの既存のキー(「クエリタブ1」ブロックと「クエリタブ2」ブロックの2番目のセット)間の範囲をロックする例を含めました。
- 私は、アプローチの「隠れたコスト」を見つけました(そしてボランティアしました)
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 /マイケル/アーロンの議論に関して、GBNの「JFDI」アプローチと私の提案を同一視するのは間違っています。「取得または挿入」操作の性質により、既存のレコードの値SELECT
を取得するために明示的に行う必要がありID
ます。このSELECTはIF EXISTS
チェックとして機能するため、このアプローチは、Aaronのテストの「CheckTryCatch」バリエーションと同等になります。Michaelの書き直されたコード(およびMichaelの適応の最終的な適応)には、WHERE NOT EXISTS
最初に同じチェックを行うことも含まれています。したがって、私の提案(Michaelの最終コードと彼の最終コードの適応とともに)は、実際にはそれほどCATCH
頻繁にはブロックにヒットしません。2つのセッション、ItemName
INSERT...SELECT
まったく同じ瞬間に、両方のセッションがまったく同じ瞬間に「true」を受信するため、両方WHERE NOT EXISTS
がまったく同じ瞬間に実行しようとしますINSERT
。その非常に特定のシナリオは、他のプロセスがまったく同じ瞬間にそうしようとしていないとき、既存を選択するItemName
か新しいを挿入するよりはるかに少ない頻度で起こります。ItemName
上記のすべての場合:なぜ自分のアプローチを好むのですか?
まず、「シリアライズ可能」アプローチでロックが行われることを見てみましょう。前述のように、ロックされる「範囲」は、新しいキー値が収まる場所の両側の既存のキー値によって異なります。その方向に既存のキー値がない場合、範囲の開始または終了は、それぞれインデックスの開始または終了にもなります。次のインデックスとキーがあると仮定します(インデックス^
の始まりを$
表し、インデックスの終わりを表します)。
Range #: |--- 1 ---|--- 2 ---|--- 3 ---|--- 4 ---|
Key Value: ^ C F J $
セッション55が次のキー値を挿入しようとした場合:
A
、その後、範囲#1(^
〜C
)がロックされます:セッション56はB
、一意で有効な(まだ)場合でも、値を挿入できません。ただし、セッション56 D
では、、、G
およびの値を挿入できますM
。
D
、次いで(#2からの範囲C
にはF
ロックされている):セッション56の値を挿入することができないE
(がまだ)。ただし、セッション56 A
では、、、G
およびの値を挿入できますM
。
M
、次いで(から#4の範囲J
には$
ロックされている):セッション56の値を挿入することができないX
(がまだ)。ただし、セッション56 A
では、、、D
およびの値を挿入できますG
。
キー値が追加されると、キー値間の範囲が狭くなるため、同じ範囲で複数の値が同時に挿入される確率/頻度が低下します。確かに、これは大きな問題ではなく、幸いなことに、時間の経過とともに実際に減少する問題のようです。
私のアプローチの問題は上記のとおりです。2つのセッションが同じキー値を同時に挿入しようとした場合にのみ発生します。この点で、発生する可能性が高いものに帰着します。2つの異なる、しかし近いキー値が同時に試行されるか、同じキー値が同時に試行されますか?答えは挿入を行うアプリの構造にあると思いますが、一般的に言えば、たまたま同じ範囲を共有する2つの異なる値が挿入される可能性が高いと想定します。しかし、実際に知る唯一の方法は、OPシステムで両方をテストすることです。
次に、2つのシナリオと、各アプローチがそれらをどのように処理するかを考えてみましょう。
一意のキー値に対するすべてのリクエスト:
この場合、CATCH
私の提案のブロックは入力されないため、「問題」は発生しません(つまり、4つのtranログエントリとその実行にかかる時間)。しかし、「シリアライズ可能」アプローチでは、すべての挿入が一意であっても、同じ範囲内の他の挿入をブロックする可能性が常にあります(非常に長い間ではありませんが)。
同時に同じキー値を要求する頻度が高い:
この場合-存在しないキー値に対する着信要求に関して非常に低い一意性- CATCH
私の提案のブロックは定期的に入力されます。この結果、挿入に失敗するたびに、自動ロールバックが必要になり、4つのエントリがトランザクションログに書き込まれます。これは、毎回わずかなパフォーマンスヒットになります。ただし、操作全体が失敗することはありません(少なくともこれが原因ではありません)。
(以前のバージョンの「更新された」アプローチには、デッドロックに悩まされる問題がありました。updlock
これに対処するためのヒントが追加され、デッドロックが発生しなくなりました。)しかし、「シリアライズ可能な」アプローチでは(更新され、最適化されたバージョンでも)、操作はデッドロックします。どうして?このserializable
動作INSERT
は、読み取られてロックされた範囲内の操作のみを防止するためです。SELECT
その範囲での操作を妨げません。
serializable
この場合のアプローチは、追加のオーバーヘッドがないように思われ、私が提案しているよりもわずかに優れたパフォーマンスを発揮する可能性があります。
パフォーマンスに関する多くの/ほとんどの議論と同様に、結果に影響を与える可能性のある要因が非常に多いため、何かがどのように実行されるかを実際に把握する唯一の方法は、実行されるターゲット環境でそれを試すことです。その時点では意見の問題ではありません:)。