web-dev-qa-db-ja.com

SQL Serverでのデッドロックなしのキーテーブルへの同時アクセスの処理

他のさまざまなテーブルの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,
);

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

ストアドプロシージャの実行例:

EXEC GetNextID 'SomeTestID';

NewID
2

EXEC GetNextID 'SomeTestID';

NewID
3

EXEC GetNextID 'SomeOtherTestID';

NewID
2

編集:

既存のインデックスIX_tblIDs_NameがSPで使用されていないため、新しいインデックスを追加しました。 LastIDに格納されている値が必要なため、クエリプロセッサはクラスター化インデックスを使用していると思います。とにかく、このインデックスIS実際の実行プランで使用されます:

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;

私たちのコードはLastIDの0でこのテーブルにレコードを追加することはないため、@ NewIDが1の場合、リストに新しいIDを追加することを想定できます。そうでない場合、既存の行を更新します。リスト。

32
Max Vernon

まず、すべての値についてデータベースへの往復を行わないようにします。たとえば、アプリケーションで20の新しいIDが必要であることがわかっている場合は、20往復しないでください。ストアドプロシージャ呼び出しを1つだけ行い、カウンタを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

次の2つの手順は、デッドロックに陥る可能性が非常に高いです。

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未満の場合は、反復回数を増やします。

1つのタブで、これを実行します。

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

1行のテーブルを使用してデッドロックを解消する

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を使用して他のリソースへのアクセスをシリアル化できます。そのため、デッドロックを排除するために使用できます。

もちろん、これにより変更が大幅に遅くなる可能性があります。これに対処するには、排他ロックに適切な粒度を選択する必要があり、可能な場合は常に、個々の行ではなくセットを操作します。

このアプローチを使用する前に、自分でストレステストを行う必要があります。最初に、元のアプローチで少なくとも数十のデッドロックを確実に取得する必要があります。次に、変更されたストアドプロシージャを使用して同じ再現スクリプトを再実行しても、デッドロックは発生しません。

一般的に、T-SQLがデッドロックから安全であるかどうかを、それを見たり実行計画を見たりするだけで判断する良い方法はないと思います。 IMOがコードにデッドロックが発生しやすいかどうかを判断する唯一の方法は、コードを高い同時実行性に公開することです。

デッドロックを解消して頑張ってください!システムにはデッドロックがまったくないため、ワークライフバランスに最適です。

15
A-K

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;

他のいくつかのバリアントで復帰します(それに負けない場合)。

9

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

(完全を期すために、ストアドプロシージャに関連付けられたテーブルを次に示します)

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

これは最新バージョンの実行計画です。

enter image description here

そして、これは元のバージョン(デッドロックの影響を受けやすい)の実行計画です。

enter image description here

明らかに、新しいバージョンが勝ちます!

比較のために、(XLOCK)などを使用した中間バージョンでは、次の計画が作成されます。

enter image description here

私はそれが勝利だと思います!みんなの助けてくれてありがとう!

7
Max Vernon

Mark Storey-Smithの雷を盗むわけではありませんが、彼は上の投稿(偶然に最も多くの賛成票を受け取っている)で何かに夢中になっています。マックスに与えたアドバイスは、私が本当にクールだと思う "UPDATE set @variable = column = column + value"コンストラクトを中心としたものですが、ドキュメント化されていない可能性があります(サポートされている必要があります。 TCPベンチマーク)。

ここにマークの答えのバリエーションがあります-新しい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
6
Mike DeFehr

これを変更することで、昨年システムで同様のデッドロックを修正しました。

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)その習慣は、他の場合に出血する可能性がありますwillははるかにコストがかかります(これらの場合、IF NOT EXISTSを使用しますIF COUNT() = 0)の代わりに、(b)追加のスキャンは完全に不要です。 UPDATEは基本的に同じチェックを実行します。

また、これは私には深刻なコードのにおいのように見えます:

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

ここのポイントは何ですか?なぜ、ID列を使用するか、クエリ時にROW_NUMBER()を使用してそのシーケンスを導出しないのですか?

4
Aaron Bertrand