同一个表上的两个 INSERT 和一个 CHECK CONSTRAINT 导致非聚集键上的 SQL 死锁

Mar*_*cus 5 performance index sql-server deadlock index-tuning

在 INSERT 期间一直在努力解决表上的死锁问题。它是一个多租户数据库,并启用了读提交快照隔离 (RCSI)。

死锁图

INSERT 时有一个 CHECK CONSTRAINT 以确保没有重叠的预订(通过 smalldatetime 与事件无关)执行标量值函数并检查结果为 1。此约束使用 READCOMMITTEDLOCK 提示查找同一个表以检查违反了 ID(PK/聚集索引)不等于新插入行的 ID 的逻辑。

由于启用了 RCSI 并希望确保我们不会跳过可能导致重叠预订的行,因此使用了 READCOMMITTEDLOCK 提示。

该约束对导致死锁的索引执行 INDEX SEEK:idx_report_foobar。

任何帮助将不胜感激。

这是 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>
Run Code Online (Sandbox Code Playgroud)

指数:

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
Run Code Online (Sandbox Code Playgroud)

标量函数用于确保用户不能有双重预订(日期时间不重叠,无论 eventID 是多少):

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;
Run Code Online (Sandbox Code Playgroud)

Sol*_*zky 5

如果传递到dbo.CheckForDoubleBookings标量 UDF的参数值可用,缩小范围会更容易,但我认为这里有足够的信息来提供有根据的猜测:

你有两个不同的行正在争夺。该过程本身执行以下操作:

  1. 插入一行
  2. CHECK CONSTRAINTfires:在作为INSERT语句的显式/隐式或自动提交事务中,通过标量 UDF 验证条件。
  3. 如果 UDF 返回 a 1,则隐式发出 a ROLLBACKand 异常。
  4. COMMIT

因此,在两个不同的会话中,同时,对于同一个userID,您将执行第 1 步(上面),它插入一行在该行上维护一个排他锁。然后,在每个会话中,在完成 之前,将执行INSERT步骤 2,该步骤处理检查潜在匹配行的检查约束(四个相同的属性,但具有不同的id)。您只是在读取“已提交”的数据,但新行尚未提交,因为每一行都在等待验证其各自的检查约束。

这里的问题是验证过程发生在工作流程中错误的时间点/顺序。因为它是通过 a 处理的CHECK CONSTRAINT,所以该行已经被添加(只是没有提交),这就是它如何被id分配以传递到标量 UDF。然后进程不想提交,直到确定不存在匹配的条目,但它看不到另一个条目,因为那个条目也由于同样的原因尚未提交。

以下是一些选项:

  1. 最简单但肯定不是理想的选择是切换到读取未提交/脏数据。为此,您将切换到使用NOLOCK提示而不是当前READCOMMITTEDLOCK. 这里的问题是,虽然它大部分时间都可以工作,但您可能会遇到以下情况:

    1. 两个会话都看到另一个并决定中止,或者
    2. 一个会话看到另一个,决定中止,但是第一个会话由于INSERT完全不同的原因被中止。

     
    在这两种情况下,都不会保存任何条目。

  2. 尝试删除该选项CHECK CONSTRAINT 并将其添加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;
    
    Run Code Online (Sandbox Code Playgroud)
  4. 如果您需要允许偏差在“开始”和“结束”的日期@userID, @eventID组合@userID, @eventID组合是不是唯一的(因此选项#2上面将无法正常工作),那么你可能需要考虑使用app_lock其中的“资源" 是一个自定义字符串,由@userID, @eventID组合或可能只是userID. 这将防止该组合一次锁定多于一个。但是,在当前请求的日期之前,表中已经存在的过去日期的组合不会成为问题,因为app_lock它只锁定“资源”,它是一个特定的字符串,与表中的数据无关(事实上,“应用程序锁”不会

    这里的想法是首先创建“应用程序锁”来强制单线程执行此操作,userID以便进程可以 a) 检查是否存在重叠日期,以及 b) 如果未找到重叠日期,则插入。其他会话userID将被阻止,直到“应用程序锁定”被释放,此时他们(每个用户 ID一次一个)将检查是否存在重叠日期。

    有关应用程序锁的更多信息,请参阅我的以下两个答案,也在 DBA.SE 上(两者都有指向“应用程序锁”文档的链接):

  5. 现在已经澄清(在对此答案的评论中):

    用户不能有重叠的预订。

    预订与 eventID 相关联,并且具有不同 eventID 的多个事件可以在同一日期发生。

    更清楚的是,选项 1、2 和 3 根本不起作用,即使userID, eventID组合是唯一的,因为只有userID在这里真正检查重叠日期。这就留下了选项 #4,如果“资源”只是userID. 但是又出现了一个问题:

    INSERT在C#代码由实体框架生成的,所以这是它自己。
    ...
    可以解决方案是使用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;
    
    Run Code Online (Sandbox Code Playgroud)

    以这种方式执行“应用程序锁定”不会导致INSERTs争用其他userIDs。