web-dev-qa-db-fra.com

Gestion de l'accès simultané à une table de clés sans blocages dans SQL Server

J'ai une table qui est utilisée par une application héritée comme substitut aux champs IDENTITY dans diverses autres tables.

Chaque ligne du tableau stocke le dernier ID utilisé LastID pour le champ nommé dans IDName.

Parfois, le proc stocké se trouve dans une impasse - je crois avoir créé un gestionnaire d'erreurs approprié; Cependant, je suis intéressé de voir si cette méthodologie fonctionne comme je le pense, ou si j'aboie le mauvais arbre ici.

Je suis assez certain qu'il devrait y avoir un moyen d'accéder à cette table sans aucun blocage.

La base de données elle-même est configurée avec READ_COMMITTED_SNAPSHOT = 1.

Tout d'abord, voici le tableau:

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

Et l'index non cluster sur le champ 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

Quelques exemples de données:

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

La procédure stockée utilisée pour mettre à jour les valeurs stockées dans la table et renvoyer l'ID suivant:

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

Exemples d'exécutions du proc stocké:

EXEC GetNextID 'SomeTestID';

NewID
2

EXEC GetNextID 'SomeTestID';

NewID
3

EXEC GetNextID 'SomeOtherTestID';

NewID
2

ÉDITER:

J'ai ajouté un nouvel index, puisque l'index existant IX_tblIDs_Name n'est pas utilisé par le SP; Je suppose que le processeur de requêtes utilise l'index clusterisé car il a besoin de la valeur stockée dans LastID. Quoi qu'il en soit, cet index IS utilisé par le plan d'exécution réel:

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

EDIT # 2:

J'ai suivi les conseils de @AaronBertrand et l'ai légèrement modifié. L'idée générale ici est d'affiner l'instruction pour éliminer le verrouillage inutile, et globalement pour rendre le SP plus efficace.

Le code ci-dessous remplace le code ci-dessus de BEGIN TRANSACTION à 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;

Étant donné que notre code n'ajoute jamais d'enregistrement à cette table avec 0 dans LastID, nous pouvons faire l'hypothèse que si @NewID est 1, l'intention est d'ajouter un nouvel ID à la liste, sinon nous mettons à jour une ligne existante dans la liste.

32
Max Vernon

Tout d'abord, j'éviterais de faire un aller-retour vers la base de données pour chaque valeur. Par exemple, si votre application sait qu'elle a besoin de 20 nouveaux identifiants, ne faites pas 20 allers-retours. Effectuez un seul appel de procédure stockée et incrémentez le compteur de 20. Il peut également être préférable de diviser votre table en plusieurs.

Il est possible d'éviter complètement les blocages. Je n'ai aucun blocage dans mon système. Il existe plusieurs façons d'y parvenir. Je vais montrer comment j'utiliserais sp_getapplock pour éliminer les blocages. Je ne sais pas si cela fonctionnera pour vous, car SQL Server est une source fermée, donc je ne peux pas voir le code source, et en tant que tel, je ne sais pas si j'ai testé tous les cas possibles.

Ce qui suit décrit ce qui fonctionne pour moi. YMMV.

Tout d'abord, commençons par un scénario où nous obtenons toujours une quantité considérable de blocages. Deuxièmement, nous utiliserons sp_getapplock pour les éliminer. Le point le plus important ici est de tester votre solution sous contrainte. Votre solution peut être différente, mais vous devez l'exposer à une concurrence élevée, comme je le démontrerai plus tard.

Conditions préalables

Créons un tableau avec quelques données de test:

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

Les deux procédures suivantes sont susceptibles d'embrasser dans une impasse:

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

Reproduction des blocages

Les boucles suivantes devraient reproduire plus de 20 interblocages chaque fois que vous les exécutez. Si vous obtenez moins de 20, augmentez le nombre d'itérations.

Dans un onglet, exécutez ceci;

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 ;

Dans un autre onglet, exécutez ce script.

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 ;

Assurez-vous de démarrer les deux en quelques secondes.

Utilisation de sp_getapplock pour éliminer les blocages

Modifiez les deux procédures, réexécutez la boucle et vérifiez que vous n'avez plus de blocages:

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

Utilisation d'une table avec une ligne pour éliminer les blocages

Au lieu d'invoquer sp_getapplock, nous pouvons modifier le tableau suivant:

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

Une fois que ce tableau est créé et rempli, nous pouvons remplacer la ligne suivante

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

avec celui-ci, dans les deux procédures:

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

Vous pouvez relancer le test de résistance et constater par vous-même que nous n'avons pas de blocages.

Conclusion

Comme nous l'avons vu, sp_getapplock peut être utilisé pour sérialiser l'accès à d'autres ressources. En tant que tel, il peut être utilisé pour éliminer les blocages.

Bien sûr, cela peut ralentir considérablement les modifications. Pour résoudre ce problème, nous devons choisir la bonne granularité pour le verrou exclusif et, dans la mesure du possible, travailler avec des ensembles plutôt qu'avec des lignes individuelles.

Avant d'utiliser cette approche, vous devez la tester vous-même. Tout d'abord, vous devez vous assurer d'obtenir au moins quelques dizaines de blocages avec votre approche d'origine. Deuxièmement, vous ne devriez obtenir aucun blocage lorsque vous réexécutez le même script de repro à l'aide de la procédure stockée modifiée.

En général, je ne pense pas qu'il existe un bon moyen de déterminer si votre T-SQL est à l'abri des blocages simplement en le regardant ou en regardant le plan d'exécution. OMI, la seule façon de déterminer si votre code est sujet aux blocages est de l'exposer à une concurrence élevée.

Bonne chance pour éliminer les blocages! Nous n'avons aucun blocage dans notre système, ce qui est excellent pour notre équilibre entre vie professionnelle et vie privée.

15
A-K

L'utilisation de l'indicateur XLOCK sur votre approche SELECT ou sur le UPDATE suivant devrait être à l'abri de ce type de blocage:

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;

Je reviendrai avec quelques autres variantes (si ce n'est pas battu!).

9
Mark Storey-Smith

Mike Defehr m'a montré une manière élégante d'accomplir cela de manière très légère:

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

(Pour être complet, voici le tableau associé au proc stocké)

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

Voici le plan d'exécution de la dernière version:

enter image description here

Et voici le plan d'exécution de la version originale (susceptible de blocage):

enter image description here

De toute évidence, la nouvelle version gagne!

A titre de comparaison, la version intermédiaire avec le (XLOCK) etc, produit le plan suivant:

enter image description here

Je dirais que c'est une victoire! Merci pour l'aide de tout le monde!

7
Max Vernon

Pas pour voler le tonnerre de Mark Storey-Smith, mais il est sur quelque chose avec son poste ci-dessus (qui a d'ailleurs reçu le plus de votes positifs). Le conseil que j'ai donné à Max était centré sur la construction "UPDATE set @variable = column = column + value" que je trouve vraiment cool, mais je pense qu'il peut ne pas être documenté (il doit être pris en charge, car il est là spécifiquement pour le = TCP benchmarks).

Voici une variante de la réponse de Mark - parce que vous renvoyez la nouvelle valeur d'ID sous la forme d'un jeu d'enregistrements, vous pouvez supprimer complètement la variable scalaire, aucune transaction explicite ne devrait être nécessaire non plus, et je conviens qu'il n'est pas nécessaire de jouer avec les niveaux d'isolement ainsi que. Le résultat est très propre et assez lisse ...

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

J'ai corrigé un blocage similaire dans un système l'année dernière en modifiant ceci:

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;

Pour ça:

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

En général, sélectionner un COUNT juste pour déterminer la présence ou l'absence est un gaspillage. Dans ce cas, puisqu'il s'agit de 0 ou de 1, ce n'est pas comme si c'était beaucoup de travail, mais (a) cette habitude peut saigner dans d'autres cas où elle sera beaucoup plus coûteuse (dans ces cas, utilisez IF NOT EXISTS Au lieu de IF COUNT() = 0), et (b) l'analyse supplémentaire n'est absolument pas nécessaire. UPDATE effectue essentiellement la même vérification.

En outre, cela ressemble à une odeur de code sérieuse pour moi:

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

Quel est le point ici? Pourquoi ne pas simplement utiliser une colonne d'identité ou dériver cette séquence à l'aide de ROW_NUMBER() au moment de la requête?

4
Aaron Bertrand