Étant donné 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 Id
champ Clé primaire - pour générer une nouvelle Id
valeur à l'avance. Générer la valeur signifie d'abord que vous n'avez pas à vous soucier de ne pas l'avoir SCOPE_IDENTITY
, ce qui signifie ensuite que vous n'avez pas besoin de la OUTPUT
clause ou de faire un complément SELECT
pour obtenir la nouvelle valeur; vous aurez la valeur avant de le faire 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 trouve pas de ligne existante pour la même chaîne exacte et continue avec 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 de concurrence consiste à forcer cette opération particulière à être à thread unique. 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 la TRY...CATCH
construction, vous pouvez effectivement intercepter une erreur spécifique (dans ce cas: "violation de contrainte unique", Msg 2601) et réexécuter SELECT
pour obtenir la Id
valeur car nous savons qu'elle existe maintenant en raison du fait qu'elle se trouve dans le CATCH
bloc avec cette donnée particulière Erreur. D'autres erreurs peuvent être traitées de manière typique RAISERROR
/ RETURN
ou THROW
.
Configuration du test: séquence, table 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 de OP
Pourquoi est-ce mieux que le MERGE
? N'obtiendrai-je pas les mêmes fonctionnalités sans l' TRY
aide de la WHERE 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, sans aucune HOLDLOCK
, etc. Il est pratiquement garanti de fonctionner.
Le raisonnement derrière cette approche est:
- Si vous avez suffisamment d'exécutions de cette procédure de sorte que vous devez vous soucier des collisions, alors vous ne voulez pas:
- prendre plus de mesures que nécessaire
- maintenir les verrous sur toutes les ressources plus longtemps que nécessaire
- Étant donné que les collisions ne peuvent se produire que sur de nouvelles entrées (nouvelles entrées soumises en même temps ), la fréquence de tomber dans le
CATCH
bloc 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 d'adapter une solution pour éviter de le faire lorsque cela est possible . Dans ce cas, je ne pense pas que l'utilisation des verrous serializable
soit une approche lourde, et je serais convaincu qu'il gérerait bien la concurrence élevée.
Je serais d'accord avec cette première phrase si elle était modifiée pour indiquer "et _quand 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 clé est "plage". Le verrou pris n'est pas seulement sur la valeur en @vName
, mais plus précisément une plage commençant àl'emplacement où cette nouvelle valeur doit aller (c'est-à-dire entre les valeurs de clé existantes de chaque côté de l'endroit où la nouvelle valeur tient), 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" ( car 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 (ie 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 (ie 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 (ie 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 (ie 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, bien que mineur, avec cette approche "transaction sérialisable + clause OUTPUT" est que la OUTPUT
clause (dans son utilisation actuelle) renvoie les données sous la forme d'un ensemble 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 OUTPUT
paramètre. É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 OUTPUT
clause puisse être utilisée de manière à renvoyer un OUTPUT
paramètre, cela nécessiterait des étapes supplémentaires pour créer une table ou une variable de table temporaire, puis pour sélectionner la valeur de cette variable table / table temporaire dans le OUTPUT
paramètre.
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 le niveau de verrouillage que l'on pourrait s'attendre à rencontrer lors de l'utilisation serializable
dans le scénario présenté dans la question d'origine.
Oui, je dois admettre que je suis partial, mais pour être juste:
- Il est impossible pour un humain de ne pas être biaisé, du moins dans une certaine mesure, et j'essaie de le garder au minimum,
- L'exemple donné était simpliste, mais c'était à des fins d'illustration pour transmettre le comportement sans trop le compliquer. Impliquer une fréquence excessive n'était pas prévu, bien que je comprenne que je n'ai pas non plus explicitement déclaré le contraire et cela pourrait être interprété comme impliquant un problème plus important que celui qui existe réellement. Je vais essayer de clarifier cela ci-dessous.
- J'ai également inclus un exemple de verrouillage d'une plage entre deux clés existantes (le deuxième ensemble de blocs "Query tab 1" et "Query tab 2").
- J'ai trouvé (et fait du bénévolat) le "coût caché" de mon approche, à savoir les quatre entrées supplémentaires du journal Tran à chaque
INSERT
échec 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 le SELECT
pour obtenir la ID
valeur des enregistrements existants. Ce SELECT agit comme une IF EXISTS
vérification, ce qui rend cette approche 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 faire 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 le CATCH
bloc si souvent. Ce ne peut être que des situations où deux sessions,ItemName
INSERT...SELECT
au même moment exact de telle sorte que les deux sessions reçoivent un "vrai" pour le WHERE NOT EXISTS
même moment exact et donc toutes deux tentent de le faire INSERT
au même moment exact. Ce scénario très spécifique se produit beaucoup moins souvent que la sélection d'un existant ItemName
ou l'insertion d'un nouveau ItemName
lorsqu'aucun autre processus ne tente de le faire au même moment .
AVEC TOUT CE QUI PRÉCÈDE DANS L'ESPRIT: Pourquoi est-ce que 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'y a pas de valeur 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 $
représente la fin de celui-ci):
Range #: |--- 1 ---|--- 2 ---|--- 3 ---|--- 4 ---|
Key Value: ^ C F J $
Si la session 55 tente d'insérer une valeur clé de:
A
, alors la plage # 1 (de ^
à C
) est verrouillée: la session 56 ne peut pas insérer une valeur de B
, même si elle est unique et valide (encore). Mais la session 56 peut insérer des valeurs de D
, G
et M
.
D
, alors la plage # 2 (de C
à F
) est verrouillée: la session 56 ne peut pas insérer une valeur de E
(encore). Mais la session 56 peut insérer des valeurs de A
, G
et M
.
M
, alors la plage # 4 (de J
à $
) est verrouillée: la session 56 ne peut pas insérer une valeur de X
(encore). Mais la session 56 peut insérer des valeurs de A
, D
et G
.
Au fur et à mesure que davantage 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 même valeur de clé 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 OP.
Ensuite, considérons deux scénarios et comment chaque approche les gère:
Toutes les demandes concernent des valeurs clés uniques:
Dans ce cas, le CATCH
bloc 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).
Fréquence élevée de demandes de 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és inexistantes - le CATCH
bloc de 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 updlock
indice 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 serializable
comportement empêche uniquement les INSERT
opérations dans la plage qui a été lue et donc verrouillée; cela n'empêche pas les SELECT
opérations sur cette plage.
L' serializable
approche, dans ce cas, ne semblerait pas avoir de frais généraux supplémentaires et pourrait 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 susceptibles d'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 :).