web-dev-qa-db-ja.com

同じテーブルでの2つのINSERTと1つのCHECK CONSTRAINTによって引き起こされる非クラスター化キーのSQLデッドロック

INSERT中にテーブルのデッドロックに苦しんでいます。これはマルチテナントデータベースであり、Read Committed Snapshot Isolation(RCSI)が有効になっています。

Dedlock graph

INSERTにチェック制約があり、スカラー値関数を実行して結果1をチェックする予約(イベントに関係なくsmalldatetimeによる)が重複しないようにします。この制約は、チェックするREADCOMMITTEDLOCKヒントで同じテーブルを検索します。 ID(PK /クラスター化インデックス)が新しく挿入された行のIDと等しくない場合のロジック違反。

READCOMMITTEDLOCKヒントが使用されたのは、RCSIが有効になっていて、行をスキップしないようにする必要があるためです。これにより、予約が重複する可能性があります。

制約は、デッドロックの原因となるインデックスidx_report_foobarに対してINDEX SEEKを実行します。

どんな援助でも大歓迎です。

XMLは次のとおりです(データベースにあるテーブルフィールドのロジックと名前の一部を削除するように調整されています)。

<deadlock>
 <victim-list>
  <victimProcess id="process91591c108" />
 </victim-list>
 <process-list>
  <process id="process91591c108" taskpriority="0" logused="1328" waitresource="KEY: 9:72057594095861760 (c2e966d5eb6a)" waittime="3046" ownerId="2628292921" transactionname="user_transaction" lasttranstarted="2018-03-09T14:24:13.820" XDES="0x708a80d80" lockMode="S" schedulerid="10" kpid="8964" status="suspended" spid="119" sbid="2" ecid="0" priority="0" trancount="2" lastbatchstarted="2018-03-09T14:24:13.823" lastbatchcompleted="2018-03-09T14:24:13.820" lastattention="1900-01-01T00:00:00.820" clientapp=".Net SqlClient Data Provider" hostname="SERVERNAMEHERE" hostpid="33672" loginname="DOMAIN\USERHERE" isolationlevel="read committed (2)" xactid="2628292921" currentdb="9" lockTimeout="4294967295" clientoption1="671088672" clientoption2="128056">
   <executionStack>
    <frame procname="mydb.dbo.CheckForDoubleBookings" line="12" stmtstart="920" stmtend="3200" sqlhandle="0x0300090018ef9b72531bea009ea8000000000000000000000000000000000000000000000000000000000000">
IF EXISTS (SELECT * 
                 FROM   dbo.bookings a WITH (READCOMMITTEDLOCK)
                 WHERE  a.id &lt;&gt; @id 
                        AND a.userID = @userID 
                        AND @bookingStart &lt; a.bookingEnd 
                        AND a.bookingStart &lt; @bookingEnd
                        AND a.eventID = @eventID
    </frame>
    <frame procname="adhoc" line="1" stmtstart="288" stmtend="922" sqlhandle="0x020000005ed9af11c02db2af69df1d5fb6d1adb0e4812afb0000000000000000000000000000000000000000">
unknown    </frame>
    <frame procname="unknown" line="1" sqlhandle="0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000">
unknown    </frame>
   </executionStack>
   <inputbuf>
(@0 datetime2(7),@1 datetime2(7),@2 int,@3 int,@4 int,@5 int,@6 int,@7 nvarchar(4000),@8 datetime2(7),@9 nvarchar(50),@10 int,@11 nvarchar(255))INSERT [dbo].[bookings]([bookingStart], [bookingEnd], [userID], [eventID], [TypeId], [Notes], [Timestamp], [AddedById])
VALUES (@0, @1, @2, @3, @4, @5, @6, @7, @8, NULL, @9, @10, @11, NULL, NULL)
SELECT [Id]
FROM [dbo].[bookings]
WHERE @@ROWCOUNT &gt; 0 AND [Id] = scope_identity()   </inputbuf>
  </process>
  <process id="processca27768c8" taskpriority="0" logused="1328" waitresource="KEY: 9:72057594095861760 (3ba50d420e66)" waittime="3048" ownerId="2628280537" transactionname="user_transaction" lasttranstarted="2018-03-09T14:24:04.063" XDES="0xa555403b0" lockMode="S" schedulerid="6" kpid="12776" status="suspended" spid="124" sbid="2" ecid="0" priority="0" trancount="2" lastbatchstarted="2018-03-09T14:24:04.070" lastbatchcompleted="2018-03-09T14:24:04.063" lastattention="1900-01-01T00:00:00.063" clientapp=".Net SqlClient Data Provider" hostname="SERVERNAMEHERE" hostpid="33672" loginname="DOMAIN\USERHERE" isolationlevel="read committed (2)" xactid="2628280537" currentdb="9" lockTimeout="4294967295" clientoption1="671088672" clientoption2="128056">
   <executionStack>
    <frame procname="mydb.dbo.CheckForDoubleBookings" line="12" stmtstart="920" stmtend="3200" sqlhandle="0x0300090018ef9b72531bea009ea8000000000000000000000000000000000000000000000000000000000000">
IF EXISTS (SELECT * 
                 FROM   dbo.bookings a WITH (READCOMMITTEDLOCK)
                 WHERE  a.id &lt;&gt; @id 
                        AND a.userID = @userID 
                        AND @bookingStart &lt; a.bookingEnd 
                        AND a.bookingStart &lt; @bookingEnd
                        AND a.eventID = @eventID
    </frame>
    <frame procname="adhoc" line="1" stmtstart="288" stmtend="922" sqlhandle="0x020000005ed9af11c02db2af69df1d5fb6d1adb0e4812afb0000000000000000000000000000000000000000">
unknown    </frame>
    <frame procname="unknown" line="1" sqlhandle="0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000">
unknown    </frame>
   </executionStack>
   <inputbuf>
(@0 datetime2(7),@1 datetime2(7),@2 int,@3 int,@4 int,@5 int,@6 int,@7 nvarchar(4000),@8 datetime2(7),@9 nvarchar(50),@10 int,@11 nvarchar(255))INSERT [dbo].[bookings]([bookingStart], [bookingEnd], [userID], [eventID], [TypeId], [Notes], [Timestamp], [AddedById])
VALUES (@0, @1, @2, @3, @4, @5, @6, @7, @8, NULL, @9, @10, @11, NULL, NULL)
SELECT [Id]
FROM [dbo].[bookings]
WHERE @@ROWCOUNT &gt; 0 AND [Id] = scope_identity()   </inputbuf>
  </process>
 </process-list>
 <resource-list>
  <keylock hobtid="72057594095861760" dbid="9" objectname="mydb.dbo.bookings" indexname="idx_report_foobar" id="locke83fdbe80" mode="X" associatedObjectId="72057594095861760">
   <owner-list>
    <owner id="processca27768c8" mode="X" />
   </owner-list>
   <waiter-list>
    <waiter id="process91591c108" mode="S" requestType="wait" />
   </waiter-list>
  </keylock>
  <keylock hobtid="72057594095861760" dbid="9" objectname="mydb.dbo.bookings" indexname="idx_report_foobar" id="lock7fdb48480" mode="X" associatedObjectId="72057594095861760">
   <owner-list>
    <owner id="process91591c108" mode="X" />
   </owner-list>
   <waiter-list>
    <waiter id="processca27768c8" mode="S" requestType="wait" />
   </waiter-list>
  </keylock>
 </resource-list>
</deadlock>

インデックス:

CREATE NONCLUSTERED INDEX [idx_report_foobar] ON [dbo].[bookings]
(
    [eventID] ASC
)
INCLUDE (   [bookingStart],
    [bookingEnd],
    [userID]) WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, SORT_IN_TEMPDB = OFF, DROP_EXISTING = OFF, ONLINE = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON, FILLFACTOR = 80)
GO

スカラー関数は、ユーザーが二重予約できないようにするために使用されます(イベントIDに関係なく、日時は重複しません)。

BEGIN
  DECLARE @Valid bit = 1;
  IF EXISTS (SELECT *
    FROM dbo.bookings a WITH (READCOMMITTEDLOCK)
    WHERE a.id <> @id
    AND a.userID = @userID
    AND @bookingStart < a.bookingEnd
    AND a.bookingStart < @bookingEnd
    AND a.eventID = @eventID)
    SET @Valid = 0;
  RETURN @Valid;
END;
5
Marcus

dbo.CheckForDoubleBookingsスカラーUDFに渡されるパラメーター値が利用可能である場合、これを絞り込む方が簡単ですが、ここには十分な情報があり、経験に基づいた推測を行うことができます。

戦われている2つの異なる行があります。プロセス自体は次のことを行います。

  1. 行を挿入する
  2. CHECK CONSTRAINTが発生する:INSERTステートメントである明示的/暗黙的または自動コミットトランザクション内で、スカラーUDFを使用して条件を確認します。
  3. UDFが1を返す場合、暗黙的にROLLBACKおよび例外を発行します。
  4. COMMIT

したがって、2つの異なるセッションで同時に、同じuserIDに対して、ステップ1(上記)が発生し、行が挿入されますandは、その上で排他ロックを維持します行。次に、各セッションで、INSERTを完了する前に、ステップ2が実行され、一致する可能性のある行をチェックするチェック制約が処理されます(4つのプロパティは同じですが、idは異なります)。 「コミットされた」データのみを読み取っていますが、それぞれのチェック制約の検証を待っているため、新しい行はまだコミットされていません。

ここでの問題は、検証プロセスがワークフローの間違った時点/シーケンスで発生していることです。 CHECK CONSTRAINTを介して処理されているため、行は既に追加されています(コミットされていないだけ)。これにより、スカラーUDFに渡すためにidが割り当てられます。そして、プロセスは、一致するエントリが存在しないことが確実になるまでコミットしませんが、そのエントリも同じ理由でまだコミットされていないため、他のエントリを表示できません。

ここにいくつかのオプションがあります:

  1. 最も単純ですが、確かに理想的ではないオプションは、コミットされていない/ダーティなデータの読み取りに切り替えることです。この場合、現在のNOLOCKではなくREADCOMMITTEDLOCKヒントを使用するように切り替えます。ここでの問題は、ほとんどの場合は機能しますが、次のいずれかのシナリオになる可能性があることです。

    1. 両方のセッションがもう一方のセッションを見て、中止することを決定する、または
    2. 1つのセッションが別のセッションを認識し、中止することを決定しますが、最初のセッションでは、まったく異なる理由でINSERTが中止されます。


    どちらの場合も、どちらのエントリも保存されません。

  2. CHECK CONSTRAINTandを削除してみて、その非クラスタ化インデックスにIGNORE_DUP_KEY = ONオプションを追加してください。これにより、両方が同時に発生した場合、一方がコミットし、もう一方が静かに失敗し(警告が表示されます)、何も挿入されないという状況が発生します。後で@@ROWCOUNTをチェックするので、それは問題ないはずです。

    要件は、インデックスがUNIQUEである必要があることです。 eventID自体が一意である場合は、UNIQUEキーワードをインデックス定義に追加するだけです。 eventID自体が一意でない場合は、一意性を確立できるまで、現在INCLUDE列であるものをキー列に移動します。

  3. このロジックはINSERTステートメントの外で処理します。一致する行が存在するかどうかを事前にテストできます。何も見つからない場合は、INSERTを実行します。また、その「チェック」は、同じ値の別のINSERTとまったく同じタイミングで実行されると失敗する場合があるため、失敗します。したがって、これはINSERTTRY...CATCH構文でラップし、エラーが発生しても無視して処理されます。

    DECLARE @NewID INT;
    BEGIN TRY
      IF (NOT EXISTS(SELECT * FROM dbo.Table WHERE columns = @parameters))
      BEGIN
        INSERT INTO dbo.Table (columns) VALUES (@parameters);
    
        SET @NewID = SCOPE_IDENTITY();
      END;
    END TRY
    BEGIN CATCH
      -- Either return custom error, or handle in a different way, such as
      -- selecting and returning the `id` matching the same criteria that
      -- was the basis of the failed `INSERT`, such as:
      SELECT @NewID = [id]
      FROM   dbo.Table
      WHERE  columns = @parameters
    END CATCH;
    
  4. @userID, @eventIDの組み合わせandの「開始」と「終了」の日付のバリエーションを許可する必要がある場合、@userID, @eventIDの組み合わせはnot一意です(したがって、上記のオプション#2は機能しません)app_lockの使用を検討する必要がある場合があります。ここで、「リソース」は@userID, @eventIDの組み合わせまたはuserID。これにより、一度に複数のロックが防止されますその組み合わせの場合。ただし、app_lockは特定の文字列である「リソース」のみをロックし、それとは関係がないため、現在リクエストされている日付より前の過去の日付のテーブルにすでに存在する組み合わせは問題になりません。テーブル内のデータ(実際、「アプリロック」はテーブルを認識していません)。

    ここでの考え方は、最初に「アプリロック」を作成してシングルスレッドを強制するこれだけuserIDなので、プロセスはa)重複する日付が存在するかどうかを確認し、b )重複する日付が見つからない場合は挿入します。同じuserIDの他のセッションは、「アプリロック」が解除されるまでブロックされます。その時点で、セッションは(一度に1つユーザーIDごとに)、重複する日付が存在するかどうかを確認します。

    アプリロックの詳細については、以下の2つの回答を参照してください。ここでもDBA.SEにあります(どちらにも「アプリロック」ドキュメントへのリンクがあります)。

  5. (この回答のコメントで)それが明確にされたので:

    ユーザーは重複する予約を持つことはできません。

    予約はeventIDに関連付けられており、異なる日付のeventIDを持つ複数のイベントが同じ日に発生する可能性があります。

    userID, eventIDの組み合わせが一意であっても、オプション1、2、3はまったく機能しません。重複する日付についてここで本当にチェックされているのはuserIDだけだからです。オプション#4は残ります。これは、「リソース」が単にuserIDの場合に機能します。しかし、別の問題が提起されました:

    INSERTは、C#コードでEntity Frameworkによって生成されるため、独自に作成します。
    ...
    INSTEAD OF INSERTではなくCHECK CONSTRAINTトリガーを使用することで解決できるでしょうか?これにより、データベーステーブルを手動で変更した場合でも、検証に従う必要があります。

    確かに、このロジック(オプション3または4)をカプセル化できるようにロジックをストアドプロシージャに移動すると、すべてのコードパス(およびアドホック更新)がストアドプロシージャを使用した場合にのみ機能します。 INSTEAD OF INSERTトリガーは、ルールに従った哲学的または道徳的不一致のためにストアドプロシージャの使用を拒否する悪質なサポートスタッフや開発者によって開始されたイベントを処理しますが、既存の「チェック」を使用できません論理。このようなトリガーは「行がすでに存在するため、他のセッションをブロックします」という問題を回避しますが、オプション3と同じ問題が発生し、両方のセッションで一致する行を確認して検出できず、重複する日付を挿入できます。

    ただし、INSTEAD OF INSERTトリガーのロジックとして「アプリロック」を使用すると機能します。また、トリガーはトランザクション内にすでに存在するため、その部分を手動で処理する必要はありません。プロセス/トリガーは次のようになります。

    -- Either prevent multi-row inserts OR remove this "IF" block and wrap the rest
    -- of the logic in a cursor that will process 1 row at a time from "inserted".
    IF ((SELECT COUNT(*) FROM inserted) > 1)
    BEGIN
      ROLLBACK;
      RAISERROR(N'Slow your roll, yo! One event at a time, ya dig?', 16, 1);
    END;
    
    DECLARE @Resource NVARCHAR(150);
    SET @Resource = N'New Booking for: ' + CONVERT(NVARCHAR(20), @userID);
    
    EXEC sys.sp_getapplock @Resource, other options;
    
    IF (NOT EXISTS (SELECT *
      FROM dbo.bookings a WITH (READCOMMITTEDLOCK)
      WHERE a.id <> @id
      AND a.userID = @userID
      AND @bookingStart < a.bookingEnd
      AND a.bookingStart < @bookingEnd
      AND a.eventID = @eventID))
    BEGIN
      INSERT INTO dbo.bookings (columns) VALUES (@values);
    END;
    
    EXEC sys.sp_releaseapplock @Resource;
    

    この方法で「アプリロック」を行うと、notが他のINSERTsのuserIDsで競合を引き起こします。

5
Solomon Rutzky

Oracleでは、ロジックによってMutating Tableエラーが発生します。そこでの解決策はおそらく似ています。

ソリューション:すべてのDMLアクティビティをターゲットテーブルにシリアル化します。

推奨事項の詳細はRDBMS間で変更される可能性がありますが、疑似コードは変更されません。

疑似コード:

  1. シリアル化ロックを取得する

  2. 変更されたデータがまだ有効であることを表明する

  3. DMLを実行する

シリアライゼーションの実施

アプリケーションがテーブルに対して直接DMLアクティビティを実行することは許可されません。代わりに、一連のプロシージャを使用する必要があります。

使用手順によって、コードとロジックを再利用できるようになります。これは、異なる言語で記述されたアプリケーションが作成されるにつれて重要になります。

「ロックの取得」の実装

Oracle内のMutating Tableに対するほとんどのソリューションは、select ... for updateを介した行ロックを伴います。 @Solomonの回答に対するコメントは、MS-SQLがUPDLOCKを使用して同じことを達成していることを示しているようです。

可能であれば、取得する行ロックの数を最小限に抑える必要があります。状況によっては、外部キーによって参照される親行を行ロックすることでこれを実現できます。あなたの状況では、おそらく、イベントの日/月ごとに、別のテーブルで行ロックを取得する必要があります。

他のシリアル化の方法には、名前付きミューテックスDBMS_LOCK for Oracleの使用、またはテーブル全体のロックが含まれます。

MS-SQLの実際のコードを調整する必要があります。

1
Michael Kutz