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


32

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に格納された値が必要なため、クエリプロセッサがクラスター化インデックスを使用していると仮定します。とにかく、このインデックスは実際の実行計画で使用されます:

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 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;

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


RCSIをサポートするようにデータベースを構成した方法は関係ありません。意図的にSERIALIZABLEここにエスカレートしています。
アーロンバートランド

はい、すべての関連情報を追加したかっただけです。それが無関係であることを確認してくれてうれしいです!
マックスヴァーノン

sp_getapplockをデッドロックの犠牲にすることは非常に簡単ですが、トランザクションを開始する場合は、sp_getapplockを1回呼び出して排他ロックを取得し、変更を進めてください。
AK

1
IDNameは一意ですか?次に、「一意の非クラスター化インデックスを作成する」ことをお勧めします。ただし、null値が必要な場合は、インデックスもフィルタする必要があります。
crokusek

回答:


15

まず、すべての値についてデータベースへの往復を避けます。たとえば、アプリケーションで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を見るか実行計画を見るだけで、T-SQLがデッドロックから安全かどうかを判断する良い方法はないと思います。IMOは、コードがデッドロックを起こしやすいかどうかを判断する唯一の方法は、コードを高い同時実行性にさらすことです。

デッドロックを排除して頑張ってください!システムにデッドロックはまったくありません。これはワークライフバランスに最適です。


2
sp_getapplockとしての+1は、あまり知られていない便利なツールです。「分解するのに時間がかかるかもしれない恐ろしい混乱を考えると、デッドロックしているプロセスをシリアル化するのは便利なトリックです。しかし、簡単に理解でき、標準のロックメカニズムで対処できる(おそらくそうすべき)このような場合の最初の選択でしょうか?
マークストーリースミス

2
@ MarkStorey-Smith調査とストレステストを一度だけ行ったため、これが最初の選択肢です。あらゆる状況で再利用できます-シリアル化は既に行われているため、sp_getapplockの後に発生するすべてが結果に影響を与えることはありません。標準のロックメカニズムでは、これほど確実ではありません。インデックスを追加したり、別の実行プランを取得しただけでは、デッドロックが発生する可能性がありました。どうやって知っているか聞いてください。
AK

私は明らかなものを見逃していると思いますが、どのように使用UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;するとデッドロックが防止されますか?
デール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;

他の2、3の亜種を返します(それに負けない場合!)。


一方でXLOCKあなたは必要はありません、複数の接続から更新されてから、既存のカウンターを防ぐことができますTABLOCKX同じ新しいカウンタを追加することから、複数の接続を防ぐために?
デールK

1
@DaleBurrellいいえ、IDNameにPKまたは一意の制約があります。
マークストーリースミス

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

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

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で続行されます。それは面倒になります。
マークストーリースミス

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
これはデッドロックの影響を受けないはずですが、トランザクションを省略すると、挿入時に競合状態が発生しやすくなります。
マークストーリースミス

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;

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


私たちが持っているほとんどのテーブルはを使用していIDENTITYます。この表は、MS Accessで記述された一部のレガシーコードをサポートします。このSET @NewID=行は、指定されたIDのテーブルに格納されている値を単純にインクリメントします(ただし、すでに知っています)。私がどのように使用できるROW_NUMBER()かについて詳しく説明してもらえますか?
マックスヴァーノン

@MaxVernonはLastID、モデルで実際に何を意味するのかを知らないわけではありません。その目的は何ですか?名前は一目瞭然ではありません。Accessはどのように使用しますか?
アーロンバートランド

Accessの関数は、IDENTITYを持たない特定のテーブルに行を追加しようとしています。最初のAccessはGetNextID('WhatevertheIDFieldIsCalled')、使用する次のIDを取得するために呼び出し、必要なデータとともに新しい行に挿入します。
マックスヴァーノン

変更を実装します。「less is more」の純粋なケース!
マックスヴァーノン

1
固定されたデッドロックが再出現する場合があります。2番目のパターンも脆弱です。sqlblog.com / blogs / alexander_kuznetsov / archive / 2010/01/12 / デッドロックを解消するには、sp_getapplockを使用します。数百人のユーザーが混在する負荷システムにデッドロックがない場合があります。
AK
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.