Nous rencontrons souvent la situation "S'il n'existe pas, insérez". le blog de Dan Guzman a une excellente enquête sur la façon de rendre ce processus threadsafe.
J'ai une table de base qui catalogue simplement une chaîne en un entier à partir d'un SEQUENCE
. Dans une procédure stockée, je dois obtenir la clé entière pour la valeur si elle existe, ou INSERT
puis obtenir la valeur résultante. Il y a une contrainte d'unicité sur la colonne dbo.NameLookup.ItemName
Donc l'intégrité des données n'est pas en danger mais je ne veux pas rencontrer les exceptions.
Ce n'est pas un IDENTITY
donc je ne peux pas obtenir SCOPE_IDENTITY
Et la valeur pourrait être NULL
dans certains cas.
Dans ma situation, je n'ai qu'à gérer INSERT
la sécurité sur la table, donc j'essaie de décider s'il est préférable d'utiliser MERGE
comme ceci:
SET NOCOUNT, XACT_ABORT ON;
DECLARE @vValueId INT
DECLARE @inserted AS TABLE (Id INT NOT NULL)
MERGE
dbo.NameLookup WITH (HOLDLOCK) AS f
USING
(SELECT @vName AS val WHERE @vName IS NOT NULL AND LEN(@vName) > 0) AS new_item
ON f.ItemName= new_item.val
WHEN MATCHED THEN
UPDATE SET @vValueId = f.Id
WHEN NOT MATCHED BY TARGET THEN
INSERT
(ItemName)
VALUES
(@vName)
OUTPUT inserted.Id AS Id INTO @inserted;
SELECT @vValueId = s.Id FROM @inserted AS s
Je pourrais le faire sans utiliser MERGE
avec juste un INSERT
conditionnel suivi d'un SELECT
Je pense que cette deuxième approche est plus claire pour le lecteur, mais je ne suis pas convaincu que ce soit "meilleure" pratique
SET NOCOUNT, XACT_ABORT ON;
INSERT INTO
dbo.NameLookup (ItemName)
SELECT
@vName
WHERE
NOT EXISTS (SELECT * FROM dbo.NameLookup AS t WHERE @vName IS NOT NULL AND LEN(@vName) > 0 AND t.ItemName = @vName)
DECLARE @vValueId int;
SELECT @vValueId = i.Id FROM dbo.NameLookup AS i WHERE i.ItemName = @vName
Ou peut-être y a-t-il une autre meilleure façon que je n'ai pas envisagée
J'ai cherché et référencé d'autres questions. Celui-ci: https://stackoverflow.com/questions/5288283/sql-server-insert-if-not-exists-best-practice est le plus approprié que j'ai pu trouver mais ne semble pas très applicable à mon cas d'utilisation. Autres questions sur l'approche IF NOT EXISTS() THEN
qui ne me semblent pas acceptables.
Parce que vous utilisez une séquence, vous pouvez utiliser la même fonction NEXT VALUE FOR - que vous avez déjà dans une contrainte par défaut sur le champ de clé primaire Id
- pour générer une nouvelle Id
valeur à l'avance. La génération de la valeur signifie d'abord que vous n'avez pas à vous soucier de ne pas avoir SCOPE_IDENTITY
, ce qui signifie que vous n'avez pas besoin de la clause OUTPUT
ou de faire un SELECT
supplémentaire pour obtenir la nouvelle valeur; vous aurez la valeur avant de faire le INSERT
, et vous n'avez même pas besoin de jouer avec SET IDENTITY INSERT ON / OFF
:-)
Cela prend donc en charge une partie de la situation globale. L'autre partie traite le problème de simultanéité de deux processus, en même temps, ne trouvant pas de ligne existante pour la même chaîne exacte et procédant à INSERT
. Le souci est d'éviter la violation de contrainte unique qui se produirait.
Une façon de gérer ces types de problèmes d'accès simultané consiste à forcer cette opération particulière à être monothread. Pour ce faire, utilisez des verrous d'application (qui fonctionnent sur plusieurs sessions). Bien qu'efficaces, ils peuvent être un peu lourds pour une situation comme celle-ci où la fréquence des collisions est probablement assez faible.
L'autre façon de gérer les collisions est d'accepter qu'elles se produisent parfois et de les gérer plutôt que d'essayer de les éviter. En utilisant le TRY...CATCH
construct, vous pouvez effectivement intercepter une erreur spécifique (dans ce cas: "violation de contrainte unique", Msg 2601) et réexécuter le SELECT
pour obtenir la valeur Id
car nous savons que il existe maintenant car il se trouve dans le bloc CATCH
avec cette erreur particulière. D'autres erreurs peuvent être gérées de la manière habituelle RAISERROR
/RETURN
ou THROW
.
Configuration du test: séquence, tableau et index unique
USE [tempdb];
CREATE SEQUENCE dbo.MagicNumber
AS INT
START WITH 1
INCREMENT BY 1;
CREATE TABLE dbo.NameLookup
(
[Id] INT NOT NULL
CONSTRAINT [PK_NameLookup] PRIMARY KEY CLUSTERED
CONSTRAINT [DF_NameLookup_Id] DEFAULT (NEXT VALUE FOR dbo.MagicNumber),
[ItemName] NVARCHAR(50) NOT NULL
);
CREATE UNIQUE NONCLUSTERED INDEX [UIX_NameLookup_ItemName]
ON dbo.NameLookup ([ItemName]);
GO
Configuration du test: procédure stockée
CREATE PROCEDURE dbo.GetOrInsertName
(
@SomeName NVARCHAR(50),
@ID INT OUTPUT,
@TestRaceCondition BIT = 0
)
AS
SET NOCOUNT ON;
BEGIN TRY
SELECT @ID = nl.[Id]
FROM dbo.NameLookup nl
WHERE nl.[ItemName] = @SomeName
AND @TestRaceCondition = 0;
IF (@ID IS NULL)
BEGIN
SET @ID = NEXT VALUE FOR dbo.MagicNumber;
INSERT INTO dbo.NameLookup ([Id], [ItemName])
VALUES (@ID, @SomeName);
END;
END TRY
BEGIN CATCH
IF (ERROR_NUMBER() = 2601) -- "Cannot insert duplicate key row in object"
BEGIN
SELECT @ID = nl.[Id]
FROM dbo.NameLookup nl
WHERE nl.[ItemName] = @SomeName;
END;
ELSE
BEGIN
;THROW; -- SQL Server 2012 or newer
/*
DECLARE @ErrorNumber INT = ERROR_NUMBER(),
@ErrorMessage NVARCHAR(4000) = ERROR_MESSAGE();
RAISERROR(N'Msg %d: %s', 16, 1, @ErrorNumber, @ErrorMessage);
RETURN;
*/
END;
END CATCH;
GO
Le test
DECLARE @ItemID INT;
EXEC dbo.GetOrInsertName
@SomeName = N'test1',
@ID = @ItemID OUTPUT;
SELECT @ItemID AS [ItemID];
GO
DECLARE @ItemID INT;
EXEC dbo.GetOrInsertName
@SomeName = N'test1',
@ID = @ItemID OUTPUT,
@TestRaceCondition = 1;
SELECT @ItemID AS [ItemID];
GO
Question d'O.P.
Pourquoi est-ce mieux que le
MERGE
? N'obtiendrai-je pas la même fonctionnalité sans leTRY
en utilisant leWHERE NOT EXISTS
clause?
MERGE
a divers "problèmes" (plusieurs références sont liées dans la réponse de @ SqlZim donc pas besoin de dupliquer cette information ici). Et, il n'y a pas de verrouillage supplémentaire dans cette approche (moins de conflits), il devrait donc être préférable en concurrence. Dans cette approche, vous n'obtiendrez jamais de violation de contrainte unique, le tout sans HOLDLOCK
, etc. Il est pratiquement garanti de fonctionner.
Le raisonnement derrière cette approche est le suivant:
CATCH
en premier lieu sera assez faible. Il est plus logique d'optimiser le code qui s'exécutera 99% du temps au lieu du code qui s'exécutera 1% du temps (à moins qu'il n'y ait aucun coût pour optimiser les deux, mais ce n'est pas le cas ici).Commentaire de la réponse de @ SqlZim (non souligné dans l'original)
Personnellement, je préfère essayer de personnaliser une solution pour éviter de le faire si possible. Dans ce cas, je ne pense pas que l'utilisation des verrous de
serializable
soit une approche lourde, et je serais convaincu qu'il gérerait bien une concurrence élevée.
Je serais d'accord avec cette première phrase si elle était modifiée pour indiquer "et _lorsqu'il est prudent". Ce n'est pas parce que quelque chose est techniquement possible que la situation (c'est-à-dire le cas d'utilisation prévu) en bénéficierait.
Le problème que je vois avec cette approche est qu'elle se verrouille plus que ce qui est suggéré. Il est important de relire la documentation citée sur "sérialisable", en particulier les suivantes (soulignement ajouté):
- Les autres transactions ne peuvent pas insérer de nouvelles lignes avec des valeurs de clé qui tomberaient dans la plage de clés lues par les instructions de la transaction en cours jusqu'à ce que la transaction en cours se termine.
Maintenant, voici le commentaire dans l'exemple de code:
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
Le mot opératoire est "plage". Le verrou pris ne se limite pas à la valeur dans @vName
, mais plus précisément une plage à partir de l'emplacement où cette nouvelle valeur doit aller (c'est-à-dire entre les valeurs de clé existantes de chaque côté de la place de la nouvelle valeur), mais pas la valeur elle-même . Cela signifie que d'autres processus ne pourront pas insérer de nouvelles valeurs, selon la ou les valeurs actuellement recherchées. Si la recherche est effectuée en haut de la plage, l'insertion de tout ce qui pourrait occuper cette même position sera bloquée. Par exemple, si les valeurs "a", "b" et "d" existent, alors si un processus fait le SELECT sur "f", alors il ne sera pas possible d'insérer les valeurs "g" ou même "e" ( puisque l'un d'eux viendra immédiatement après "d"). Mais, l'insertion d'une valeur de "c" sera possible car elle ne sera pas placée dans la plage "réservée".
L'exemple suivant doit illustrer ce comportement:
(Dans l'onglet de requête (c.-à-d. Session) # 1)
INSERT INTO dbo.NameLookup ([ItemName]) VALUES (N'test5');
BEGIN TRAN;
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
WHERE ItemName = N'test8';
--ROLLBACK;
(Dans l'onglet de requête (c.-à-d. Session) # 2)
EXEC dbo.NameLookup_getset_byName @vName = N'test4';
-- works just fine
EXEC dbo.NameLookup_getset_byName @vName = N'test9';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
EXEC dbo.NameLookup_getset_byName @vName = N'test7';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
EXEC dbo.NameLookup_getset_byName @vName = N's';
-- works just fine
EXEC dbo.NameLookup_getset_byName @vName = N'u';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
De même, si la valeur "C" existe et que la valeur "A" est sélectionnée (et donc verrouillée), vous pouvez insérer une valeur de "D", mais pas une valeur de "B":
(Dans l'onglet de requête (c.-à-d. Session) # 1)
INSERT INTO dbo.NameLookup ([ItemName]) VALUES (N'testC');
BEGIN TRAN
SELECT [Id]
FROM dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */
WHERE ItemName = N'testA';
--ROLLBACK;
(Dans l'onglet de requête (c.-à-d. Session) # 2)
EXEC dbo.NameLookup_getset_byName @vName = N'testD';
-- works just fine
EXEC dbo.NameLookup_getset_byName @vName = N'testB';
-- hangs until you either hit "cancel" in this query tab,
-- OR issue a COMMIT or ROLLBACK in query tab #1
Pour être juste, dans mon approche suggérée, lorsqu'il y a une exception, il y aura 4 entrées dans le journal des transactions qui ne se produiront pas dans cette approche de "transaction sérialisable". MAIS, comme je l'ai dit ci-dessus, si l'exception se produit 1% (ou même 5%) du temps, cela a beaucoup moins d'impact que le cas beaucoup plus probable du SELECT initial bloquant temporairement les opérations INSERT.
Un autre problème, quoique mineur, avec cette approche "transaction sérialisable + clause OUTPUT" est que la clause OUTPUT
(dans son utilisation actuelle) renvoie les données en tant que jeu de résultats. Un jeu de résultats nécessite plus de surcharge (probablement des deux côtés: dans SQL Server pour gérer le curseur interne et dans la couche d'application pour gérer l'objet DataReader) qu'un simple paramètre OUTPUT
. Étant donné que nous n'avons affaire qu'à une seule valeur scalaire et que l'hypothèse est une fréquence élevée d'exécutions, cette surcharge supplémentaire de l'ensemble de résultats s'additionne probablement.
Bien que la clause OUTPUT
puisse être utilisée de manière à renvoyer un paramètre OUTPUT
, cela nécessiterait des étapes supplémentaires pour créer une table ou une variable de table temporaire, puis pour sélectionner la valeur dans cette table/variable temporaire dans le paramètre OUTPUT
.
Précision supplémentaire: Réponse à la réponse de @ SqlZim (réponse mise à jour) à ma réponse à la réponse de @ SqlZim (dans la réponse d'origine) à ma déclaration concernant la concurrence et les performances ;-)
Désolé si cette partie est un peu longue, mais à ce stade, nous n'en sommes qu'aux nuances des deux approches.
Je crois que la façon dont les informations sont présentées pourrait conduire à de fausses hypothèses sur la quantité de verrouillage que l'on pourrait s'attendre à rencontrer lors de l'utilisation de
serializable
dans le scénario tel que présenté dans la question d'origine.
Oui, je dois admettre que je suis partial, mais pour être juste:
INSERT
échoue en raison d'une violation de contrainte unique. Je n'ai vu cela mentionné dans aucune des autres réponses/messages.Concernant l'approche "JFDI" de @ gbn, le post "Ugly Pragmatism For The Win" de Michael J. Swart, et le commentaire d'Aaron Bertrand sur le post de Michael (concernant ses tests montrant quels scénarios ont diminué les performances), et votre commentaire sur votre "adaptation de Michael J" . L'adaptation par Stewart de la procédure Try Catch JFDI de @ gbn indiquant:
Si vous insérez de nouvelles valeurs plus souvent que la sélection de valeurs existantes, cela peut être plus performant que la version de @ srutzky. Sinon, je préférerais la version de @ srutzky à celle-ci.
En ce qui concerne cette discussion gbn/Michael/Aaron relative à l'approche "JFDI", il serait incorrect d'assimiler ma suggestion à l'approche "JFDI" de gbn. En raison de la nature de l'opération "Get or Insert", il est explicitement nécessaire de faire SELECT
pour obtenir la valeur ID
pour les enregistrements existants. Ce SELECT agit comme IF EXISTS
check, ce qui rend cette approche encore plus équivalente à la variation "CheckTryCatch" des tests d'Aaron. Le code réécrit de Michael (et votre adaptation finale de l'adaptation de Michael) comprend également un WHERE NOT EXISTS
pour effectuer cette même vérification en premier. Par conséquent, ma suggestion (avec le code final de Michael et votre adaptation de son code final) ne frappera pas si souvent le bloc CATCH
. Il ne peut s'agir que de situations où deux sessions, étant donné le même ItemName
inexistant, et exécutant le INSERT...SELECT
au même moment, de sorte que les deux sessions reçoivent un "vrai" pour le WHERE NOT EXISTS
au même moment exact et donc les deux tentent de faire le INSERT
au même moment exact. Ce scénario très spécifique se produit beaucoup moins souvent que la sélection d'un ItemName
existant ou l'insertion d'un nouveau ItemName
quand aucun autre processus ne tente de le faire au même moment.
AVEC TOUT CE QUI CI-DESSUS DANS L'ESPRIT: Pourquoi je préfère mon approche?
Voyons d'abord ce qui se produit dans l'approche "sérialisable". Comme mentionné ci-dessus, la "plage" qui est verrouillée dépend des valeurs de clé existantes de chaque côté de l'endroit où la nouvelle valeur de clé s'insérerait. Le début ou la fin de la plage peut également être le début ou la fin de l'index, respectivement, s'il n'existe aucune valeur de clé existante dans cette direction. Supposons que nous ayons l'index et les clés suivants (^
représente le début de l'index tandis que $
en représente la fin):
Range #: |--- 1 ---|--- 2 ---|--- 3 ---|--- 4 ---|
Key Value: ^ C F J $
Si la session 55 tente d'insérer une valeur clé de:
A
, puis plage # 1 (de ^
à C
) est verrouillé: la session 56 ne peut pas insérer une valeur de B
, même si elle est unique et valide (pour le moment). Mais la session 56 peut insérer des valeurs de D
, G
et M
.D
, puis la plage # 2 (de C
à F
) est verrouillée: la session 56 ne peut pas insérer une valeur de E
(pour l'instant). Mais la session 56 peut insérer des valeurs de A
, G
et M
.M
, puis plage # 4 (de J
à $
) est verrouillé: la session 56 ne peut pas insérer une valeur de X
(pour l'instant). Mais la session 56 peut insérer des valeurs de A
, D
et G
.Au fur et à mesure que plus de valeurs clés sont ajoutées, les plages entre les valeurs clés deviennent plus étroites, réduisant ainsi la probabilité/fréquence d'insertion de plusieurs valeurs en même temps en se battant sur la même plage. Certes, ce n'est pas un problème majeur, et heureusement, il semble que ce soit un problème qui diminue avec le temps.
Le problème avec mon approche a été décrit ci-dessus: cela ne se produit que lorsque deux sessions tentent d'insérer la valeur de clé même en même temps. À cet égard, cela revient à ce qui a la plus forte probabilité de se produire: deux valeurs clés différentes, mais proches, sont tentées en même temps, ou la même valeur clé est tentée en même temps? Je suppose que la réponse réside dans la structure de l'application qui effectue les insertions, mais de manière générale, je suppose qu'il est plus probable que deux valeurs différentes qui se trouvent partager la même plage soient insérées. Mais la seule façon de vraiment savoir serait de tester les deux sur le système O.P.s.
Ensuite, considérons deux scénarios et comment chaque approche les gère:
Toutes les demandes concernent des valeurs de clé uniques:
Dans ce cas, le bloc CATCH
dans ma suggestion n'est jamais entré, donc pas de "problème" (c'est-à-dire 4 entrées de journal de transfert et le temps qu'il faut pour le faire). Mais, dans l'approche "sérialisable", même si tous les inserts sont uniques, il y aura toujours un certain potentiel de blocage d'autres inserts dans la même plage (quoique pas pour très longtemps).
Haute fréquence de demandes pour la même valeur de clé en même temps:
Dans ce cas - un très faible degré d'unicité en termes de demandes entrantes pour des valeurs de clé inexistantes - le bloc CATCH
dans ma suggestion sera régulièrement entré. Cela aura pour effet que chaque insertion échouée devra effectuer une restauration automatique et écrire les 4 entrées dans le journal des transactions, ce qui représente une légère baisse des performances à chaque fois. Mais l'opération globale ne devrait jamais échouer (du moins pas à cause de cela).
(Il y avait un problème avec la version précédente de l'approche "mise à jour" qui lui permettait de souffrir de blocages. Un indice updlock
a été ajouté pour résoudre ce problème et il ne reçoit plus de blocages.) MAIS, dans l'approche "sérialisable" (même la version mise à jour et optimisée), l'opération se bloquera. Pourquoi? Parce que le comportement serializable
empêche uniquement les opérations INSERT
dans la plage qui a été lue et donc verrouillée; cela n'empêche pas les opérations SELECT
sur cette plage.
L'approche serializable
, dans ce cas, ne semble pas avoir de surcharge supplémentaire et peut fonctionner légèrement mieux que ce que je suggère.
Comme pour beaucoup/la plupart des discussions concernant les performances, en raison de la multiplicité des facteurs pouvant affecter le résultat, la seule façon de vraiment avoir une idée de la façon dont quelque chose va fonctionner est de l'essayer dans l'environnement cible où il s'exécutera. À ce stade, ce ne sera plus une question d'opinion :).