在SQL Server中处理并发访问键表而没有死锁


32

我有一个表,该表被旧版应用程序用来替代IDENTITY其他各种表中的字段。

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

有时,存储的proc会陷入死锁-我相信我已经构建了适当的错误处理程序;但是我很想知道这种方法是否像我认为的那样起作用,或者我是否在这里树错了树。

我相当确定应该有一种访问该表的方法,而没有任何死锁。

数据库本身配置为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,
);

以及该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

一些样本数据:

INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeTestID', 1);
INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeOtherTestID', 1);
GO

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

CREATE PROCEDURE [dbo].[GetNextID](
    @IDName nvarchar(255)
)
AS
BEGIN
    /*
        Description:    Increments and returns the LastID value from tblIDs
        for a given IDName
        Author:         Max 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

存储的proc的示例执行:

EXEC GetNextID 'SomeTestID';

NewID
2

EXEC GetNextID 'SomeTestID';

NewID
3

EXEC GetNextID 'SomeOtherTestID';

NewID
2

编辑:

我添加了一个新索引,因为SP没有使用现有的索引IX_tblIDs_Name。我假设查询处理器正在使用聚簇索引,因为它需要存储在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);

编辑#2:

我接受了@AaronBertrand的建议,并对其进行了一些修改。这里的总体思路是优化语句以消除不必要的锁定,并总体上提高SP的效率。

下面的代码替换了上面的代码,从BEGIN TRANSACTIONEND 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;

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


与如何配置数据库以支持RCSI无关。您有意升级到SERIALIZABLE此处。
亚伦·伯特兰

是的,我只想添加所有相关信息。我很高兴您确认这无关紧要!
Max Vernon

使sp_getapplock成为死锁受害者非常容易,但是如果您开始事务,则不要调用一次sp_getapplock以获得排他锁,然后继续进行修改。
AK

1
IDName是否唯一?然后建议“创建唯一的非聚集索引”。但是,如果您需要空值,那么索引也需要过滤
crokusek

Answers:


15

首先,我将避免针对每个值往返数据库。例如,如果您的应用程序知道它需要20个新ID,则不要进行20次往返。仅进行一个存储过程调用,并将计数器增加20。另外,将表拆分为多个可能更好。

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

下面介绍对我有用的方法。YMMV。

首先,让我们从一个总是出现大量死锁的场景开始。其次,我们将使用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

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

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

重现死锁

每次运行时,以下循环应重现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 ;

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

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 ;

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

使用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

使用具有一行的表来消除死锁

除了调用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);

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

EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';

在这两个过程中,都可以使用:

UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;

您可以重新运行压力测试,并亲自了解我们没有僵局。

结论

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

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

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

通常,我认为没有一种仅通过查看T-SQL或查看执行计划来确定T-SQL是否可以避免死锁的好方法。IMO确定代码是否容易出现死锁的唯一方法是将其暴露给高并发性。

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


2
+1作为sp_getapplock是一个鲜为人知的有用工具。给定一个“可怕的混乱,可能需要一些时间才能解决”,序列化陷入僵局的进程是一个方便的技巧。但是,对于这样一种容易理解并且可以(也许应该)通过标准锁定机制处理的情况,它是否应该是首选?
Mark Storey-Smith

2
@ MarkStorey-Smith这是我的首选,因为我仅对其进行了研究和压力测试,并且在任何情况下都可以重用它-序列化已经发生,因此sp_getapplock之后发生的所有事情都不会影响结果。使用标准的锁定机制,我永远不能确定-添加索引或仅获得另一个执行计划都可能导致死锁,而这在没有锁的情况下就已经存在。问我我怎么知道。
AK

我想我遗漏了一些明显的东西,但是如何使用UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;防止死锁呢?
Dale K

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;

将返回其他几个变体(如果不被击败!)。


虽然XLOCK可以防止多个连接更新现有的计数器,但是您是否需要TABLOCKX防止多个连接添加相同的新计数器?
Dale K

1
@DaleBurrell不,您将对IDName进行PK或唯一约束。
Mark Storey-Smith

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:         Max 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

(为完整性起见,这是与存储的proc相关的表)

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

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

在此处输入图片说明

这是原始版本(易受死锁影响)的执行计划:

在此处输入图片说明

显然,新版本胜出!

为了进行比较,带有(XLOCK)等等的中间版本会产生以下计划:

在此处输入图片说明

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


2
确实应该可以,但是在不适用的地方使用了SERIALIZABLE。幻像行在这里不存在,那么为什么要使用一个存在的隔离级别来防止它们呢?同样,如果有人从另一个或从外部事务已开始的连接中调用您的过程,则他们发起的任何其他操作将在SERIALIZABLE处进行。可能会变得混乱。
Mark Storey-Smith

2
SERIALIZABLE不存在以防止幻影。它的存在是为了提供可序列化的隔离语义,即对数据库的持久影响,就好像所涉及的事务以某种未指定的顺序串行执行一样。
保罗·怀特说GoFundMonica

6

不要偷走Mark Storey-Smith的风头,但是他的职位上面有东西(顺便说一句,票数最多)。我给Max的建议集中在“ 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

3
同意这应该可以避免死锁,但是如果您忽略该事务,则很容易在插件上出现竞争状态。
Mark Storey-Smith

4

去年,通过更改此设置,我修复了系统中的类似死锁:

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;

对此:

UPDATE tblIDs SET LastID = @NewID WHERE IDName = @IDName;
IF @@ROWCOUNT = 0
BEGIN
  INSERT ...
END

通常,选择COUNT正义来确定是否存在是非常浪费的。在这种情况下,因为它是0或1,所以它并不是很多工作,但是(a)这种习惯可能会渗入其他情况下,而后者的成本更高(在这些情况下,请使用IF NOT EXISTS代替IF COUNT() = 0),以及(b)完全不需要额外的扫描。在UPDATE本质上执行相同的检查。

另外,这对我来说似乎是一种严重的代码气味:

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

这里有什么意义?为什么不只使用标识列或ROW_NUMBER()在查询时使用该列来派生序列呢?


我们拥有的大多数表格都使用IDENTITY。该表支持一些用MS Access编写的旧代码,这些代码很可能需要进行改造。该SET @NewID=行仅增加表中存储的给定ID的值(但您已经知道这一点)。您可以扩展我的使用方式ROW_NUMBER()吗?
Max Vernon

@MaxVernon并非不知道LastID您的模型真正意味着什么。目的是什么?这个名字并不完全是不言自明的。Access如何使用它?
亚伦·伯特兰

Access中的一个函数希望将行添加到任何没有IDENTITY的给定表中。First Access调用GetNextID('WhatevertheIDFieldIsCalled')以获取下一个要使用的ID,然后将其与所需的任何数据一起插入新行。
Max Vernon

我将执行您的更改。一个纯粹的案例是“少即是多”!
Max Vernon

1
您固定的死锁可能会重新出现。您的第二种模式也很容易受到攻击:sqlblog.com/blogs/alexander_kuznetsov/archive/2010/01/12/… 为消除死锁,我将使用sp_getapplock。可能有数百个用户的混合负载系统没有死锁。
AK
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.