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.
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.
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
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.
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
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.
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.
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!).
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:
Et voici le plan d'exécution de la version originale (susceptible de blocage):
De toute évidence, la nouvelle version gagne!
A titre de comparaison, la version intermédiaire avec le (XLOCK)
etc, produit le plan suivant:
Je dirais que c'est une victoire! Merci pour l'aide de tout le monde!
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
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?