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

12

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 de a SEQUENCE. Dans une procédure stockée, je dois soit obtenir la clé entière de la valeur si elle existe, soit la INSERTrécupérer, puis obtenir la valeur résultante. Il y a une contrainte d'unicité sur la dbo.NameLookup.ItemNamecolonne afin que l'intégrité des données ne soit pas en danger mais je ne veux pas rencontrer les exceptions.

Ce n'est pas un IDENTITYdonc je ne peux pas obtenir SCOPE_IDENTITYet la valeur pourrait être NULLdans certains cas.

Dans ma situation, je n'ai qu'à gérer la INSERTsécurité sur la table, donc j'essaie de décider s'il est préférable de l'utiliser MERGEcomme 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 MERGEavec juste un conditionnel INSERTsuivi d'un SELECT Je pense que cette deuxième approche est plus claire pour le lecteur, mais je ne suis pas convaincu que c'est une "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: /programming/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. D'autres questions sur l' IF NOT EXISTS() THENapproche que je ne pense pas acceptables.

Matthieu
la source
Avez-vous essayé d'expérimenter avec des tables plus grandes que votre tampon, j'ai eu des expériences où les performances de fusion diminuent une fois que la table atteint une certaine taille.
paisiblement

Réponses:

8

É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 Idchamp Clé primaire - pour générer une nouvelle Idvaleur à 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 OUTPUTclause ou de faire un complément SELECTpour 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...CATCHconstruction, vous pouvez effectivement intercepter une erreur spécifique (dans ce cas: "violation de contrainte unique", Msg 2601) et réexécuter SELECTpour obtenir la Idvaleur car nous savons qu'elle existe maintenant en raison du fait qu'elle se trouve dans le CATCHbloc avec cette donnée particulière Erreur. D'autres erreurs peuvent être traitées de manière typique RAISERROR/ RETURNou 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' TRYaide de la WHERE NOT EXISTSclause?

MERGEa 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:

  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. maintenir les verrous sur 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 en même temps ), la fréquence de tomber dans le CATCHbloc 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 serializablesoit 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 OUTPUTclause (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 OUTPUTparamè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 OUTPUTclause puisse être utilisée de manière à renvoyer un OUTPUTparamè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 OUTPUTparamè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 serializabledans le scénario 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é, du 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 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 SELECTpour obtenir la IDvaleur des enregistrements existants. Ce SELECT agit comme une IF EXISTSvé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 EXISTSpour 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 CATCHbloc si souvent. Ce ne peut être que des situations où deux sessions,ItemNameINSERT...SELECTau même moment exact de telle sorte que les deux sessions reçoivent un "vrai" pour le WHERE NOT EXISTSmême moment exact et donc toutes deux tentent de le faire INSERTau même moment exact. Ce scénario très spécifique se produit beaucoup moins souvent que la sélection d'un existant ItemNameou l'insertion d'un nouveau ItemNamelorsqu'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, Get 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, Get 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, Det 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:

  1. Toutes les demandes concernent des valeurs clés uniques:

    Dans ce cas, le CATCHbloc 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. 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 CATCHbloc 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 updlockindice 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 serializablecomportement empêche uniquement les INSERTopérations dans la plage qui a été lue et donc verrouillée; cela n'empêche pas les SELECTopérations sur cette plage.

    L' serializableapproche, 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 :).

Solomon Rutzky
la source
7

Réponse mise à jour


Réponse à @srutzky

Un autre problème, bien que mineur, avec cette approche "transaction sérialisable + clause OUTPUT" est que la clause OUTPUT (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 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.

Je suis d'accord, et pour ces mêmes raisons, j'utilise des paramètres de sortie lorsque prudent . C'était mon erreur de ne pas utiliser de paramètre de sortie sur ma réponse initiale, j'étais paresseux.

Voici une procédure révisée utilisant un paramètre de sortie, des optimisations supplémentaires, ainsi next value forque @srutzky explique dans sa réponse :

create procedure dbo.NameLookup_getset_byName (@vName nvarchar(50), @vValueId int output) as
begin
  set nocount on;
  set xact_abort on;
  set @vValueId = null;
  if nullif(@vName,'') is null                                 
    return;                                        /* if @vName is empty, return early */
  select  @vValueId = Id                                              /* go get the Id */
    from  dbo.NameLookup
    where ItemName = @vName;
  if @vValueId is not null                                 /* if we got the id, return */
    return;
  begin try;                                  /* if it is not there, then get the lock */
    begin tran;
      select  @vValueId = Id
        from  dbo.NameLookup with (updlock, serializable) /* hold key range for @vName */
        where ItemName = @vName;
      if @@rowcount = 0                    /* if we still do not have an Id for @vName */
      begin;                                         /* get a new Id and insert @vName */
        set @vValueId = next value for dbo.IdSequence;      /* get next sequence value */
        insert into dbo.NameLookup (ItemName, Id)
          values (@vName, @vValueId);
      end;
    commit tran;
  end try
  begin catch;
    if @@trancount > 0 
      begin;
        rollback transaction;
        throw;
      end;
  end catch;
end;

mise à jour note : Y compris updlockavec la sélection saisira les verrous appropriés dans ce scénario. Merci à @srutzky, qui a souligné que cela pouvait provoquer des blocages lors de l'utilisation uniquement serializablesur le select.

Remarque: Ce n'est peut-être pas le cas, mais si cela est possible, la procédure sera appelée avec une valeur pour @vValueId, inclure set @vValueId = null;après set xact_abort on;, sinon elle peut être supprimée.


Concernant les exemples @ srutzky de comportement de verrouillage de plage de clés:

@srutzky n'utilise qu'une seule valeur dans sa table et verrouille la clé "suivant" / "infini" pour ses tests pour illustrer le verrouillage de la plage de clés. Bien que ses tests illustrent ce qui se passe dans ces situations, je pense 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 peut s'attendre à rencontrer lors de l'utilisation serializabledans le scénario présenté dans la question d'origine.

Même si je perçois un biais (peut-être à tort) dans la façon dont il présente ses explications et ses exemples de verrouillage de plage de touches, ils sont toujours corrects.


Après plus de recherches, j'ai trouvé un article de blog particulièrement pertinent de 2011 par Michael J. Swart: Mythbusting: Concurrent Update / Insert Solutions . Dans ce document, il teste plusieurs méthodes pour la précision et la simultanéité. Méthode 4: Isolation accrue + verrous de réglage fin est basé sur le modèle d'insertion ou de mise à jour de Sam Saffron pour SQL Server , et la seule méthode du test d'origine pour répondre à ses attentes (rejointe plus tard par merge with (holdlock)).

En février 2016, Michael J. Swart a publié Ugly Pragmatism For The Win . Dans ce post, il couvre certains ajustements supplémentaires qu'il a faits à ses procédures upsert Saffron pour réduire le verrouillage (que j'ai inclus dans la procédure ci-dessus).

Après avoir apporté ces modifications, Michael n'était pas content que sa procédure commence à paraître plus compliquée et a consulté un collègue nommé Chris. Chris a lu tous les articles Mythbusters originaux et a lu tous les commentaires et a posé des questions sur le modèle TRY CATCH JFDI de @ gbn . Ce modèle est similaire à la réponse de @ srutzky et est la solution que Michael a fini par utiliser dans ce cas.

Michael J Swart:

Hier, j'ai changé d'avis sur la meilleure façon de faire de la concurrence. Je décris plusieurs méthodes dans Mythbusting: Solutions de mise à jour / insertion simultanées. Ma méthode préférée consiste à augmenter le niveau d'isolement et à affiner les verrous.

C'était du moins ma préférence. J'ai récemment changé mon approche pour utiliser une méthode suggérée par gbn dans les commentaires. Il décrit sa méthode comme le «modèle TRY CATCH JFDI». Normalement, j'évite des solutions comme ça. Il existe une règle d'or qui dit que les développeurs ne doivent pas compter sur la capture d'erreurs ou d'exceptions pour le flux de contrôle. Mais j'ai enfreint cette règle empirique hier.

Soit dit en passant, j'adore la description du gbn pour le motif «JFDI». Cela me rappelle la vidéo de motivation de Shia Labeouf.


À mon avis, les deux solutions sont viables. Bien que je préfère toujours augmenter le niveau d'isolement et les verrous de réglage fin, la réponse de @ srutzky est également valide et peut ou non être plus performante dans votre situation spécifique.

Peut-être qu'à l'avenir j'arriverai moi aussi à la même conclusion que Michael J. Swart, mais je n'y suis tout simplement pas encore.


Ce n'est pas ma préférence, mais voici à quoi ressemblerait mon adaptation de l'adaptation de Michael J. Stewart à la procédure Try Catch JFDI de @ gbn :

create procedure dbo.NameLookup_JFDI (
    @vName nvarchar(50)
  , @vValueId int output
  ) as
begin
  set nocount on;
  set xact_abort on;
  set @vValueId = null;
  if nullif(@vName,'') is null                                 
    return;                     /* if @vName is empty, return early */
  begin try                                                 /* JFDI */
    insert into dbo.NameLookup (ItemName)
      select @vName
      where not exists (
        select 1
          from dbo.NameLookup
          where ItemName = @vName);
  end try
  begin catch        /* ignore duplicate key errors, throw the rest */
    if error_number() not in (2601, 2627) throw;
  end catch
  select  @vValueId = Id                              /* get the Id */
    from  dbo.NameLookup
    where ItemName = @vName
  end;

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.

Les commentaires d'Aaron Bertrand sur le post de Michael J Swart renvoient aux tests pertinents qu'il a effectués et ont conduit à cet échange. Extrait de la section des commentaires sur le pragmatisme laid pour la victoire :

Parfois, cependant, JFDI entraîne des performances globales moins bonnes, selon le pourcentage d'appels qui échouent. La levée des exceptions a des frais généraux substantiels. Je l'ai montré dans quelques articles:

http://sqlperformance.com/2012/08/t-sql-queries/error-handling

https://www.mssqltips.com/sqlservertip/2632/checking-for-potential-constraint-violations-before-entering-sql-server-try-and-catch-logic/

Commentaire d'Aaron Bertrand - 11 février 2016 à 11h49

et la réponse de:

Tu as raison Aaron, et nous l'avons testé.

Il s'avère que dans notre cas, le pourcentage d'appels qui ont échoué était 0 (lorsqu'il est arrondi au pourcentage le plus proche).

Je pense que vous illustrez le fait que, dans la mesure du possible, évaluez les choses au cas par cas par rapport aux règles générales.

C'est aussi pourquoi nous avons ajouté la clause WHERE NOT EXISTS, qui n'est pas strictement nécessaire.

Commentaire de Michael J. Swart - 11 février 2016 à 11h57


Nouveaux liens:


Réponse originale


Je préfère toujours l' approche upsert de Sam Saffron par rapport à l'utilisation merge, en particulier lorsqu'il s'agit d'une seule ligne.

J'adapterais cette méthode upsert à cette situation comme ceci:

declare @vName nvarchar(50) = 'Invader';
declare @vValueId int       = null;

if nullif(@vName,'') is not null /* this gets your where condition taken care of before we start doing anything */
begin tran;
  select @vValueId = Id
    from dbo.NameLookup with (serializable) 
    where ItemName = @vName;
  if @@rowcount > 0 
    begin;
      select @vValueId as id;
    end;
    else
    begin;
      insert into dbo.NameLookup (ItemName)
        output inserted.id
          values (@vName);
      end;
commit tran;

Je serais cohérent avec votre nom, et comme serializablec'est la même chose que holdlock, choisissez-en un et soyez cohérent dans son utilisation. J'ai tendance à utiliser serializablecar c'est le même nom que celui utilisé lors de la spécification set transaction isolation level serializable.

En utilisant serializableou holdlockun verrou de plage est pris en fonction de la valeur @vNamequi fait attendre toute autre opération si elle sélectionne ou insère des valeurs dbo.NameLookupqui incluent la valeur dans la whereclause.

Pour que le verrouillage de plage fonctionne correctement, il doit y avoir un index sur la ItemNamecolonne qui s'applique également lors de l'utilisation merge.


Voici ce que la procédure ressemblerait la plupart du temps suivant les livres blancs de Erland Sommarskog pour le traitement des erreurs , en utilisant throw. Si ce thrown'est pas la façon dont vous générez vos erreurs, modifiez-la pour qu'elle soit cohérente avec le reste de vos procédures:

create procedure dbo.NameLookup_getset_byName (@vName nvarchar(50) ) as
begin
  set nocount on;
  set xact_abort on;
  declare @vValueId int;
  if nullif(@vName,'') is null /* if @vName is null or empty, select Id as null */
    begin
      select Id = cast(null as int);
    end 
    else                       /* else go get the Id */
    begin try;
      begin tran;
        select @vValueId = Id
          from dbo.NameLookup with (serializable) /* hold key range for @vName */
          where ItemName = @vName;
        if @@rowcount > 0      /* if we have an Id for @vName select @vValueId */
          begin;
            select @vValueId as Id; 
          end;
          else                     /* else insert @vName and output the new Id */
          begin;
            insert into dbo.NameLookup (ItemName)
              output inserted.Id
                values (@vName);
            end;
      commit tran;
    end try
    begin catch;
      if @@trancount > 0 
        begin;
          rollback transaction;
          throw;
        end;
    end catch;
  end;
go

Pour résumer ce qui se passe dans la procédure ci-dessus: set nocount on; set xact_abort on;comme vous le faites toujours , alors si notre variable d'entrée is nullou vide, select id = cast(null as int)comme résultat. S'il n'est pas nul ou vide, récupérez la Idpour notre variable tout en maintenant cet endroit au cas où il ne serait pas là. Si le Idest là, envoyez-le. S'il n'est pas là, insérez-le et envoyez ce nouveau Id.

Pendant ce temps, les autres appels à cette procédure essayant de trouver l'ID pour la même valeur attendront que la première transaction soit effectuée, puis la sélectionne et la renvoie. D'autres appels à cette procédure ou d'autres instructions à la recherche d'autres valeurs continueront car celle-ci n'est pas gênante.

Bien que je convienne avec @srutzky que vous pouvez gérer les collisions et avaler les exceptions pour ce type de problème, je préfère personnellement essayer de personnaliser une solution pour éviter de le faire lorsque cela est possible. Dans ce cas, je ne pense pas que l'utilisation des verrous serializablesoit une approche lourde, et je serais convaincu qu'il gérerait bien la concurrence élevée.

Citation de la documentation du serveur SQL sur les conseils de table serializable/holdlock :

SÉRIALISABLE

Est équivalent à HOLDLOCK. Rend les verrous partagés plus restrictifs en les maintenant jusqu'à ce qu'une transaction soit terminée, au lieu de libérer le verrou partagé dès que la table ou la page de données requise n'est plus nécessaire, que la transaction soit terminée ou non. L'analyse est effectuée avec la même sémantique qu'une transaction s'exécutant au niveau d'isolement SERIALIZABLE. Pour plus d'informations sur les niveaux d'isolement, consultez SET TRANSACTION ISOLATION LEVEL (Transact-SQL).

Citation de la documentation du serveur SQL sur le niveau d'isolement des transactionsserializable

SERIALIZABLE Spécifie les éléments suivants:

  • Les relevés ne peuvent pas lire les données qui ont été modifiées mais pas encore validées par d'autres transactions.

  • Aucune autre transaction ne peut modifier les données qui ont été lues par la transaction en cours tant que la transaction en cours n'est pas terminée.

  • 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.


Liens relatifs à la solution ci-dessus:

MERGEa une histoire inégale, et il semble prendre plus de temps pour s'assurer que le code se comporte comme vous le souhaitez dans toute cette syntaxe. mergeArticles pertinents :

Un dernier lien, Kendra Little a fait une comparaison approximative de mergevsinsert with left join , avec la mise en garde où elle dit "Je n'ai pas fait de test de charge approfondi sur cela", mais c'est toujours une bonne lecture.

SqlZim
la source