web-dev-qa-db-fra.com

Un MERGE avec OUTPUT est-il une meilleure pratique qu'un INSERT et SELECT conditionnel?

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.

12
Matthew

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 le TRY en utilisant le 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, le tout sans HOLDLOCK, etc. Il est pratiquement garanti de fonctionner.

Le raisonnement derrière cette approche est le suivant:

  1. 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:
    1. prendre plus de mesures que nécessaire
    2. verrouiller toutes les ressources plus longtemps que nécessaire
  2. Étant donné que les collisions ne peuvent se produire que sur de nouvelles entrées (nouvelles entrées soumises à exactement en même temps), la fréquence de tomber dans le bloc 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:

  1. Il est impossible pour un humain de ne pas être biaisé, au moins dans une certaine mesure, et j'essaie de le garder au minimum,
  2. 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.
  3. 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").
  4. J'ai trouvé (et proposé) le "coût caché" de mon approche, à savoir les quatre entrées supplémentaires du journal Tran chaque fois que le 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:

  1. 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).

  2. 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 :).

8
Solomon Rutzky