在 SQL Server 中处理对密钥表的并发访问而不会出现死锁

Han*_*non 33 sql-server deadlock

我有一个旧应用程序用作替代IDENTITY各种其他表中的字段的表。

表中的每一行都存储了 中LastID命名的字段的最后使用的 ID IDName

有时,存储过程会出现死锁——我相信我已经构建了一个合适的错误处理程序;但是我很想知道这种方法是否像我认为的那样有效,或者我是否在这里吠错了树。

我相当肯定应该有一种方法可以访问这个表,而不会出现任何死锁。

数据库本身配置了READ_COMMITTED_SNAPSHOT = 1.

首先,这是表:

CREATE TABLE [dbo].[tblIDs](
    [IDListID] [int] NOT NULL 
        CONSTRAINT PK_tblIDs 
        PRIMARY KEY CLUSTERED 
        IDENTITY(1,1) ,
    [IDName] [nvarchar](255) NULL,
    [LastID] [int] NULL,
);
Run Code Online (Sandbox Code Playgroud)

以及该IDName字段上的非聚集索引:

CREATE NONCLUSTERED INDEX [IX_tblIDs_IDName] 
ON [dbo].[tblIDs]
(
    [IDName] ASC
) 
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)

一些示例数据:

INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeTestID', 1);
INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeOtherTestID', 1);
GO
Run Code Online (Sandbox Code Playgroud)

用于更新存储在表中的值的存储过程,并返回下一个 ID:

CREATE PROCEDURE [dbo].[GetNextID](
    @IDName nvarchar(255)
)
AS
BEGIN
    /*
        Description:    Increments and returns the LastID value from tblIDs
        for a given IDName
        Author:         Hannah Vernon
        Date:           2012-07-19
    */

    DECLARE @Retry int;
    DECLARE @EN int, @ES int, @ET int;
    SET @Retry = 5;
    DECLARE @NewID int;
    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    SET NOCOUNT ON;
    WHILE @Retry > 0
    BEGIN
        BEGIN TRY
            BEGIN TRANSACTION;
            SET @NewID = COALESCE((SELECT LastID 
                FROM tblIDs 
                WHERE IDName = @IDName),0)+1;
            IF (SELECT COUNT(IDName) 
                FROM tblIDs 
                WHERE IDName = @IDName) = 0 
                    INSERT INTO tblIDs (IDName, LastID) 
                    VALUES (@IDName, @NewID)
            ELSE
                UPDATE tblIDs 
                SET LastID = @NewID 
                WHERE IDName = @IDName;
            COMMIT TRANSACTION;
            SET @Retry = -2; /* no need to retry since the operation completed */
        END TRY
        BEGIN CATCH
            IF (ERROR_NUMBER() = 1205) /* DEADLOCK */
                SET @Retry = @Retry - 1;
            ELSE
                BEGIN
                SET @Retry = -1;
                SET @EN = ERROR_NUMBER();
                SET @ES = ERROR_SEVERITY();
                SET @ET = ERROR_STATE()
                RAISERROR (@EN,@ES,@ET);
                END
            ROLLBACK TRANSACTION;
        END CATCH
    END
    IF @Retry = 0 /* must have deadlock'd 5 times. */
    BEGIN
        SET @EN = 1205;
        SET @ES = 13;
        SET @ET = 1
        RAISERROR (@EN,@ES,@ET);
    END
    ELSE
        SELECT @NewID AS NewID;
END
GO
Run Code Online (Sandbox Code Playgroud)

存储过程的示例执行:

EXEC GetNextID 'SomeTestID';

NewID
2

EXEC GetNextID 'SomeTestID';

NewID
3

EXEC GetNextID 'SomeOtherTestID';

NewID
2
Run Code Online (Sandbox Code Playgroud)

编辑:

我添加了一个新索引,因为现有索引 IX_tblIDs_Name 没有被 SP 使用;我假设查询处理器正在使用聚集索引,因为它需要存储在 LastID 中的值。无论如何,这个索引被实际的执行计划使用:

CREATE NONCLUSTERED INDEX IX_tblIDs_IDName_LastID 
ON dbo.tblIDs
(
    IDName ASC
) 
INCLUDE
(
    LastID
)
WITH (FILLFACTOR = 100
    , ONLINE=ON
    , ALLOW_ROW_LOCKS = ON
    , ALLOW_PAGE_LOCKS = ON);
Run Code Online (Sandbox Code Playgroud)

编辑#2:

我采纳了@AaronBertrand 给出的建议并对其进行了略微修改。这里的总体思路是细化语句以消除不必要的锁定,并总体上使 SP 更高效。

下面的代码将上面的代码从BEGIN TRANSACTIONto替换为END TRANSACTION

BEGIN TRANSACTION;
SET @NewID = COALESCE((SELECT LastID 
        FROM dbo.tblIDs 
        WHERE IDName = @IDName), 0) + 1;

IF @NewID = 1
    INSERT INTO tblIDs (IDName, LastID) 
    VALUES (@IDName, @NewID);
ELSE
    UPDATE dbo.tblIDs 
    SET LastID = @NewID 
    WHERE IDName = @IDName;

COMMIT TRANSACTION;
Run Code Online (Sandbox Code Playgroud)

由于我们的代码从不向此表中添加 0 的记录,LastID我们可以假设如果 @NewID 为 1,则意图是将新 ID 附加到列表中,否则我们将更新列表中的现有行。

A-K*_*A-K 15

首先,我会避免为每个值都访问数据库。例如,如果您的应用程序知道它需要 20 个新 ID,则不要进行 20 次往返。只调用一次存储过程,并将计数器增加 20。此外,最好将表拆分为多个表。

完全可以避免死锁。我的系统中根本没有死锁。有几种方法可以实现这一点。我将展示如何使用 sp_getapplock 消除死锁。我不知道这是否适合你,因为 SQL Server 是封闭源代码,所以我看不到源代码,因此我不知道我是否已经测试了所有可能的情况。

以下描述了对我有用的内容。天啊。

首先,让我们从一个总是出现大量死锁的场景开始。其次,我们将使用 sp_getapplock 消除它们。这里最重要的一点是对您的解决方案进行压力测试。您的解决方案可能有所不同,但您需要将其暴露在高并发下,我将在后面演示。

先决条件

让我们建立一个包含一些测试数据的表格:

CREATE TABLE dbo.Numbers(n INT NOT NULL PRIMARY KEY); 
GO 

INSERT INTO dbo.Numbers 
    ( n ) 
        VALUES  ( 1 ); 
GO 
DECLARE @i INT; 
    SET @i=0; 
WHILE @i<21  
    BEGIN 
    INSERT INTO dbo.Numbers 
        ( n ) 
        SELECT n + POWER(2, @i) 
        FROM dbo.Numbers; 
    SET @i = @i + 1; 
    END;  
GO

SELECT n AS ID, n AS Key1, n AS Key2, 0 AS Counter1, 0 AS Counter2
INTO dbo.DeadlockTest FROM dbo.Numbers
GO

ALTER TABLE dbo.DeadlockTest ADD CONSTRAINT PK_DeadlockTest PRIMARY KEY(ID);
GO

CREATE INDEX DeadlockTestKey1 ON dbo.DeadlockTest(Key1);
GO

CREATE INDEX DeadlockTestKey2 ON dbo.DeadlockTest(Key2);
GO
Run Code Online (Sandbox Code Playgroud)

以下两个过程很可能陷入僵局:

CREATE PROCEDURE dbo.UpdateCounter1 @Key1 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
SET @Key1=@Key1-10000;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
COMMIT;
GO

CREATE PROCEDURE dbo.UpdateCounter2 @Key2 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
SET @Key2=@Key2-10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
SET @Key2=@Key2+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
COMMIT;
GO
Run Code Online (Sandbox Code Playgroud)

重现死锁

每次运行以下循环时,它们应该会重现 20 多个死锁。如果小于 20,请增加迭代次数。

在一个选项卡中,运行这个;

DECLARE @i INT, @DeadlockCount INT;
SELECT @i=0, @DeadlockCount=0;

WHILE @i<5000 BEGIN ;
  BEGIN TRY 
    EXEC dbo.UpdateCounter1 @Key1=123456;
  END TRY
  BEGIN CATCH
    SET @DeadlockCount = @DeadlockCount + 1;
    ROLLBACK;
  END CATCH ;
  SET @i = @i + 1;
END;
SELECT 'Deadlocks caught: ', @DeadlockCount ;
Run Code Online (Sandbox Code Playgroud)

在另一个选项卡中,运行此脚本。

DECLARE @i INT, @DeadlockCount INT;
SELECT @i=0, @DeadlockCount=0;

WHILE @i<5000 BEGIN ;
  BEGIN TRY 
    EXEC dbo.UpdateCounter2 @Key2=123456;
  END TRY
  BEGIN CATCH
    SET @DeadlockCount = @DeadlockCount + 1;
    ROLLBACK;
  END CATCH ;
  SET @i = @i + 1;
END;
SELECT 'Deadlocks caught: ', @DeadlockCount ;
Run Code Online (Sandbox Code Playgroud)

确保在几秒钟内启动两者。

使用 sp_getapplock 消除死锁

更改这两个过程,重新运行循环,并看到您不再有死锁:

ALTER PROCEDURE dbo.UpdateCounter1 @Key1 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
SET @Key1=@Key1-10000;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
COMMIT;
GO

ALTER PROCEDURE dbo.UpdateCounter2 @Key2 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';
SET @Key2=@Key2-10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
SET @Key2=@Key2+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
COMMIT;
GO
Run Code Online (Sandbox Code Playgroud)

使用单行表消除死锁

我们可以修改下表,而不是调用 sp_getapplock:

CREATE TABLE dbo.DeadlockTestMutex(
ID INT NOT NULL,
CONSTRAINT PK_DeadlockTestMutex PRIMARY KEY(ID),
Toggle INT NOT NULL);
GO

INSERT INTO dbo.DeadlockTestMutex(ID, Toggle)
VALUES(1,0);
Run Code Online (Sandbox Code Playgroud)

创建并填充此表后,我们可以替换以下行

EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';
Run Code Online (Sandbox Code Playgroud)

有了这个,在两个程序中:

UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;
Run Code Online (Sandbox Code Playgroud)

您可以重新运行压力测试,并亲眼看看我们没有死锁。

结论

正如我们所见,sp_getapplock 可用于序列化对其他资源的访问。因此,它可以用来消除死锁。

当然,这会显着减慢修改速度。为了解决这个问题,我们需要为排他锁选择​​正确的粒度,并且尽可能使用集合而不是单个行。

在使用这种方法之前,您需要自己进行压力测试。首先,您需要确保您的原始方法至少遇到了几十个死锁。其次,当您使用修改后的存储过程重新运行相同的重现脚本时,应该不会出现死锁。

一般来说,我不认为有什么好方法可以仅通过查看或查看执行计划来确定您的 T-SQL 是否可以避免死锁。IMO 确定您的代码是否容易死锁的唯一方法是将其暴露于高并发。

祝你好运消除僵局!我们的系统根本没有任何僵局,这对我们的工作与生活平衡非常有用。

  • +1 因为 sp_getapplock 是一个有用的工具,但并不为人所知。考虑到可能需要时间才能拆散的“糟糕的混乱”,这是一个将陷入僵局的进程序列化的便捷技巧。但是,对于这种易于理解并且可以(也许应该)通过标准锁定机制处理的情况,它是否应该成为首选? (2认同)
  • @MarkStorey-Smith 这是我的第一选择,因为我只对它进行了一次研究和压力测试,我可以在任何情况下重用它——序列化已经发生,所以在 sp_getapplock 之后发生的一切都不会影响结果。使用标准锁定机制,我永远无法确定 - 添加索引或仅获取另一个执行计划可能会导致死锁,而之前没有死锁。问我怎么知道。 (2认同)

Mar*_*ith 9

XLOCK在您的SELECT方法或以下方法中使用提示UPDATE应该可以避免这种类型的死锁:

DECLARE @Output TABLE ([NewId] INT);
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN TRANSACTION;

UPDATE
    dbo.tblIDs WITH (XLOCK)
SET 
    LastID = LastID + 1
OUTPUT
    INSERTED.[LastId] INTO @Output
WHERE
    IDName = @IDName;

IF(@@ROWCOUNT = 1)
BEGIN
    SELECT @NewId = [NewId] FROM @Output;
END
ELSE
BEGIN
    SET @NewId = 1;

    INSERT dbo.tblIDs
        (IDName, LastID)
    VALUES
        (@IDName, @NewId);
END

SELECT [NewId] = @NewId ;

COMMIT TRANSACTION;
Run Code Online (Sandbox Code Playgroud)

将返回几个其他变体(如果没有被打败!)。


Han*_*non 7

Mike Defehr 向我展示了一种以非常轻量级的方式完成此任务的优雅方式:

ALTER PROCEDURE [dbo].[GetNextID](
    @IDName nvarchar(255)
)
AS
BEGIN
    /*
        Description:    Increments and returns the LastID value from tblIDs for a given IDName
        Author:         Hannah Vernon / Mike Defehr
        Date:           2012-07-19

    */

    DECLARE @Retry int;
    DECLARE @EN int, @ES int, @ET int;
    SET @Retry = 5;
    DECLARE @NewID int;
    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    SET NOCOUNT ON;
    WHILE @Retry > 0
    BEGIN
        BEGIN TRY
            UPDATE dbo.tblIDs 
            SET @NewID = LastID = LastID + 1 
            WHERE IDName = @IDName;

            IF @NewID IS NULL
            BEGIN
                SET @NewID = 1;
                INSERT INTO tblIDs (IDName, LastID) VALUES (@IDName, @NewID);
            END
            SET @Retry = -2; /* no need to retry since the operation completed */
        END TRY
        BEGIN CATCH
            IF (ERROR_NUMBER() = 1205) /* DEADLOCK */
                SET @Retry = @Retry - 1;
            ELSE
                BEGIN
                SET @Retry = -1;
                SET @EN = ERROR_NUMBER();
                SET @ES = ERROR_SEVERITY();
                SET @ET = ERROR_STATE()
                RAISERROR (@EN,@ES,@ET);
                END
        END CATCH
    END
    IF @Retry = 0 /* must have deadlock'd 5 times. */
    BEGIN
        SET @EN = 1205;
        SET @ES = 13;
        SET @ET = 1
        RAISERROR (@EN,@ES,@ET);
    END
    ELSE
        SELECT @NewID AS NewID;
END
GO
Run Code Online (Sandbox Code Playgroud)

(为了完整起见,这里是与存储过程关联的表)

CREATE TABLE [dbo].[tblIDs]
(
    IDName nvarchar(255) NOT NULL,
    LastID int NULL,
    CONSTRAINT [PK_tblIDs] PRIMARY KEY CLUSTERED 
    (
        [IDName] ASC
    ) WITH 
    (
        PAD_INDEX = OFF
        , STATISTICS_NORECOMPUTE = OFF
        , IGNORE_DUP_KEY = OFF
        , ALLOW_ROW_LOCKS = ON
        , ALLOW_PAGE_LOCKS = ON
        , FILLFACTOR = 100
    ) 
);
GO
Run Code Online (Sandbox Code Playgroud)

这是最新版本的执行计划:

在此处输入图片说明

这是原始版本的执行计划(容易出现死锁):

在此处输入图片说明

显然,新版本获胜!

为了比较,与(XLOCK)etc的中间版本,产生以下计划:

在此处输入图片说明

我会说这是一场胜利!感谢大家的帮助!

  • 确实应该有效,但您在不适用的情况下使用了 SERIALIZABLE。幻影行不能在这里存在,那么为什么要使用存在的隔离级别来防止它们呢?此外,如果有人从另一个或从启动外部事务的连接调用您的过程,则他们启动的任何进一步操作都将在 SERIALIZABLE 处进行。那会变得很乱。 (2认同)
  • `SERIALIZABLE` 不存在以防止幻象。它的存在是为了提供 [serializable 隔离语义](http://sqlperformance.com/2014/04/t-sql-queries/the-serializable-isolation-level),即对数据库的相同持久影响*好像*所涉及的交易以某种未指定的顺序连续执行。 (2认同)

小智 6

不是为了抢走 Mark Storey-Smith 的风头,但他在上面的帖子中做了一些事情(顺便说一句,这获得了最多的赞成票)。我给 Hannah 的建议集中在“UPDATE set @variable = column = column + value”构造上,我觉得它真的很酷,但我认为可能没有记录(必须支持它,尽管因为它专门用于 TCP基准)。

这是 Mark 答案的一个变体 - 因为您将新的 ID 值作为记录集返回,所以您可以完全取消标量变量,也不需要显式事务,我同意没有必要搞乱隔离级别以及。结果非常干净,非常光滑......

ALTER PROC [dbo].[GetNextID]
  @IDName nvarchar(255)
  AS
BEGIN
SET NOCOUNT ON;

DECLARE @Output TABLE ([NewID] INT);

UPDATE dbo.tblIDs SET LastID = LastID + 1
OUTPUT inserted.[LastId] INTO @Output
WHERE IDName = @IDName;

IF(@@ROWCOUNT = 1)
    SELECT [NewID] FROM @Output;
ELSE
    INSERT dbo.tblIDs (IDName, LastID)
    OUTPUT INSERTED.LastID AS [NewID]
    VALUES (@IDName,1);
END
Run Code Online (Sandbox Code Playgroud)

  • 同意这应该不受死锁的影响,但如果您省略事务,它很容易在插入时出现竞争条件。 (3认同)