「存在しない場合は挿入する」という状況によく遭遇します。 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
アプローチに対する他の質問は、私には受け入れられないと思います。
シーケンスを使用しているため、同じ__ 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
などがなければ、一意制約違反が発生することは決してありません。動作することはほぼ保証されています。
このアプローチの背後にある理由は次のとおりです。
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
を使用するときに発生する可能性があるロックの量について誤った仮定につながる可能性があると思います。
はい、私は偏見があることを認めますが、公平を期すためです。
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は、D
、G
、およびM
の値を挿入できます。D
の場合、範囲#2(C
からF
まで)がロックされます。セッション56はE
(まだ)の値を挿入できません。ただし、セッション56は、A
、G
、およびM
の値を挿入できます。M
の場合、範囲#4(J
から$
まで)がロックされます。セッション56はX
の値を挿入できません(まだ)。ただし、セッション56は、A
、D
、およびG
の値を挿入できます。より多くのキー値が追加されると、キー値間の範囲が狭くなり、同じ範囲で競合する複数の値が同時に挿入される確率/頻度が減少します。確かに、これはmajorの問題ではありません。幸い、時間の経過とともに実際に減少する問題であるようです。
私のアプローチの問題については上記で説明しました。2つのセッションがsameキー値を同時に挿入しようとしたときにのみ発生します。この点で、発生する可能性がより高いものに帰着します。2つの異なるが、近いキー値が同時に試行されますか、または同じキー値が同時に試行されますか?答えは挿入を行うアプリの構造にあると思いますが、一般的に言えば、たまたま同じ範囲を共有する2つの異なる値が挿入される可能性が高いと思います。しかし、本当に知る唯一の方法は、O.P.sシステムで両方をテストすることです。
次に、2つのシナリオと、各アプローチがそれらをどのように処理するかを考えてみましょう。
一意のキー値に対するすべてのリクエスト:
この場合、私の提案のCATCH
ブロックは決して入力されないため、「問題」はありません(つまり、4つのtranログエントリとその実行にかかる時間)。ただし、「シリアライズ可能」アプローチでは、すべての挿入が一意であっても、同じ範囲内の他の挿入をブロックする可能性が常にあります(ただし、非常に長くはありません)。
同時に同じキー値を要求する頻度が高い:
この場合-存在しないキー値の着信リクエストに関して非常に低い一意性-私の提案のCATCH
ブロックは定期的に入力されます。この結果、失敗した挿入ごとに自動ロールバックが必要になり、4つのエントリがトランザクションログに書き込まれます。これは、毎回わずかなパフォーマンスヒットになります。しかし、全体的な操作が失敗することはありません(少なくともこれが原因ではありません)。
(以前のバージョンの「更新された」アプローチには、デッドロックの影響を受ける問題がありました。これに対処するためにupdlock
ヒントが追加され、デッドロックが発生しなくなりました。) しかし、「シリアライズ可能」なアプローチでは(更新されて最適化されたバージョンでも)、操作がデッドロックします。どうして? serializable
の動作は、読み取られてロックされた範囲のINSERT
操作のみを防止するためです。その範囲でのSELECT
操作を妨げません。
この場合、serializable
アプローチは追加のオーバーヘッドがないように思われ、私が提案しているものよりわずかにパフォーマンスが良いかもしれません。
パフォーマンスに関する多くの/ほとんどの議論と同様に、結果に影響を与える可能性のある非常に多くの要因があるため、何かがどのように実行されるかを実際に理解する唯一の方法は、それが実行されるターゲット環境で試してみることです。その時点では、それは意見の問題ではありません:)。