¿Es una MERGE con OUTPUT una mejor práctica que un INSERT y SELECT condicional?

12

A menudo nos encontramos con la situación "Si no existe, inserte". El blog de Dan Guzman tiene una excelente investigación sobre cómo hacer que este proceso sea seguro.

Tengo una tabla básica que simplemente cataloga una cadena a un entero desde a SEQUENCE. En un procedimiento almacenado, necesito obtener la clave entera del valor si existe, o INSERTbien, y luego obtener el valor resultante. Hay una restricción de unicidad en la dbo.NameLookup.ItemNamecolumna, por lo que la integridad de los datos no está en riesgo, pero no quiero encontrar las excepciones.

No es IDENTITYasí, no puedo obtenerlo SCOPE_IDENTITYy el valor podría ser NULLen ciertos casos.

En mi situación, solo tengo que lidiar con la INSERTseguridad en la mesa, así que estoy tratando de decidir si es una mejor práctica usar MERGEasí:

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

Podría hacer esto sin usar MERGEsolo un condicional INSERTseguido de un SELECT Creo que este segundo enfoque es más claro para el lector, pero no estoy convencido de que sea una práctica "mejor"

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

O tal vez hay otra mejor manera que no he considerado

Busqué y hice referencia a otras preguntas. Este: /programming/5288283/sql-server-insert-if-not-exists-best-practice es lo más apropiado que pude encontrar, pero no parece muy aplicable a mi caso de uso. Otras preguntas sobre el IF NOT EXISTS() THENenfoque que no creo que sean aceptables.

Mateo
fuente
¿Ha intentado experimentar con tablas más grandes que su búfer? He tenido experiencias en las que el rendimiento de fusión disminuye una vez que la tabla alcanza un cierto tamaño.
Pacreely

Respuestas:

8

Debido a que está usando una secuencia, puede usar la misma función SIGUIENTE VALOR PARA , que ya tiene en una restricción predeterminada en el Idcampo Clave primaria, para generar un nuevo Idvalor antes de tiempo. Generar el valor primero significa que no necesita preocuparse por no tenerlo SCOPE_IDENTITY, lo que significa que no necesita la OUTPUTcláusula ni hacer un adicional SELECTpara obtener el nuevo valor; tendrá el valor antes de hacer el INSERT, y ni siquiera necesita meterse con SET IDENTITY INSERT ON / OFF:-)

Eso se encarga de parte de la situación general. La otra parte es manejar el problema de concurrencia de dos procesos, al mismo tiempo, no encontrar una fila existente para la misma cadena y continuar con el INSERT. La preocupación es evitar la violación de la restricción única que ocurriría.

Una forma de manejar estos tipos de problemas de concurrencia es forzar a esta operación en particular a ser de un solo subproceso. La forma de hacerlo es mediante el uso de bloqueos de aplicaciones (que funcionan en sesiones). Si bien son efectivos, pueden ser un poco pesados ​​para una situación como esta donde la frecuencia de colisiones es probablemente bastante baja.

La otra forma de lidiar con las colisiones es aceptar que a veces ocurrirán y manejarlas en lugar de tratar de evitarlas. Usando la TRY...CATCHconstrucción, puede atrapar efectivamente un error específico (en este caso: "violación de restricción única", Msg 2601) y volver a ejecutar el SELECTpara obtener el Idvalor ya que sabemos que ahora existe debido a estar en el CATCHbloque con ese particular error. Otros errores pueden ser manejados en el típico RAISERROR/ RETURNo THROWforma.

Configuración de prueba: secuencia, tabla e índice único

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

Configuración de prueba: procedimiento almacenado

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

La prueba

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

Pregunta de OP

¿Por qué es esto mejor que el MERGE? ¿No obtendré la misma funcionalidad sin TRYusar la WHERE NOT EXISTScláusula?

MERGEtiene varios "problemas" (varias referencias están vinculadas en la respuesta de @ SqlZim, por lo que no es necesario duplicar esa información aquí). Y, no hay bloqueo adicional en este enfoque (menos contención), por lo que debería ser mejor en concurrencia. En este enfoque, nunca obtendrá una violación de restricción única, todo sin ninguna HOLDLOCK, etc. Es prácticamente seguro que funcione.

El razonamiento detrás de este enfoque es:

  1. Si tiene suficientes ejecuciones de este procedimiento como para tener que preocuparse por las colisiones, entonces no desea:
    1. tomar más medidas de las necesarias
    2. mantener bloqueados los recursos por más tiempo del necesario
  2. Dado que las colisiones solo pueden ocurrir en nuevas entradas (nuevas entradas enviadas exactamente al mismo tiempo ), la frecuencia de caer en el CATCHbloque en primer lugar será bastante baja. Tiene más sentido optimizar el código que se ejecutará el 99% del tiempo en lugar del código que se ejecutará el 1% del tiempo (a menos que no haya ningún costo para optimizar ambos, pero ese no es el caso aquí).

Comentario de la respuesta de @ SqlZim (énfasis agregado)

Personalmente prefiero probar y adaptar una solución para evitar hacerlo cuando sea posible . En este caso, no creo que usar los bloqueos serializablesea ​​un enfoque pesado, y estaría seguro de que manejaría bien la alta concurrencia.

Estaría de acuerdo con esta primera oración si fuera enmendada para indicar "y _cuando sea prudente". El hecho de que algo sea técnicamente posible no significa que la situación (es decir, el caso de uso previsto) se beneficiaría de ello.

El problema que veo con este enfoque es que bloquea más de lo que se sugiere. Es importante volver a leer la documentación citada en "serializable", específicamente lo siguiente (énfasis agregado):

  • Otras transacciones no pueden insertar nuevas filas con valores de clave que caerían en el rango de claves leídas por cualquier declaración en la transacción actual hasta que se complete la transacción actual.

Ahora, aquí está el comentario en el código de ejemplo:

SELECT [Id]
FROM   dbo.NameLookup WITH (SERIALIZABLE) /* hold that key range for @vName */

La palabra operativa allí es "rango". El bloqueo que se está tomando no solo se basa en el valor @vName, sino que es más exactamente un rango que comienza enla ubicación donde debe ir este nuevo valor (es decir, entre los valores clave existentes a cada lado de donde encaja el nuevo valor), pero no el valor en sí. Es decir, se bloqueará la inserción de nuevos procesos en otros procesos, dependiendo de los valores que se estén buscando actualmente. Si la búsqueda se realiza en la parte superior del rango, se bloqueará la inserción de cualquier cosa que pueda ocupar esa misma posición. Por ejemplo, si existen los valores "a", "b" y "d", entonces si un proceso está haciendo SELECT en "f", entonces no será posible insertar valores "g" o incluso "e" ( ya que cualquiera de esos vendrá inmediatamente después de "d"). Pero, será posible insertar un valor de "c" ya que no se colocaría en el rango "reservado".

El siguiente ejemplo debería ilustrar este comportamiento:

(En la pestaña de consulta (es decir, Sesión) # 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;

(En la pestaña de consulta (es decir, Sesión) # 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

Del mismo modo, si existe el valor "C" y se está seleccionando el valor "A" (y por lo tanto bloqueado), puede insertar un valor de "D", pero no un valor de "B":

(En la pestaña de consulta (es decir, Sesión) # 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;

(En la pestaña de consulta (es decir, Sesión) # 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

Para ser justos, en mi enfoque sugerido, cuando hay una excepción, habrá 4 entradas en el Registro de transacciones que no sucederán en este enfoque de "transacción serializable". PERO, como dije anteriormente, si la excepción ocurre el 1% (o incluso el 5%) del tiempo, eso es mucho menos impactante que el caso mucho más probable de que la SELECCIÓN inicial bloquee temporalmente las operaciones INSERTAR.

Otro problema, aunque menor, con este enfoque de "transacción serializable + cláusula de SALIDA" es que la OUTPUTcláusula (en su uso actual) envía los datos de vuelta como un conjunto de resultados. Un conjunto de resultados requiere más sobrecarga (probablemente en ambos lados: en SQL Server para administrar el cursor interno y en la capa de la aplicación para administrar el objeto DataReader) que un OUTPUTparámetro simple . Dado que solo estamos tratando con un solo valor escalar, y que la suposición es una alta frecuencia de ejecuciones, esa sobrecarga adicional del conjunto de resultados probablemente se suma.

Si bien la OUTPUTcláusula podría usarse de tal manera que devuelva un OUTPUTparámetro, eso requeriría pasos adicionales para crear una tabla o variable de tabla temporal, y luego seleccionar el valor de esa variable de tabla / tabla temporal en el OUTPUTparámetro.

Aclaración adicional: Respuesta a la Respuesta de @ SqlZim (respuesta actualizada) a mi Respuesta a la Respuesta de @ SqlZim (en la respuesta original) a mi declaración sobre concurrencia y desempeño ;-)

Lo siento si esta parte es un poquito larga, pero en este punto solo tenemos los matices de los dos enfoques.

Creo que la forma en que se presenta la información podría conducir a suposiciones falsas sobre la cantidad de bloqueo que uno podría esperar encontrar al usar serializableen el escenario como se presenta en la pregunta original.

Sí, admitiré que soy parcial, aunque para ser justos:

  1. Es imposible que un humano no sea parcial, al menos en un pequeño grado, y trato de mantenerlo al mínimo,
  2. El ejemplo dado fue simplista, pero con fines ilustrativos para transmitir el comportamiento sin complicarlo demasiado. No se pretendía implicar una frecuencia excesiva, aunque entiendo que tampoco dije explícitamente lo contrario y podría leerse que implica un problema mayor que el que realmente existe. Trataré de aclarar eso a continuación.
  3. También incluí un ejemplo de bloqueo de un rango entre dos teclas existentes (el segundo conjunto de bloques "Consulta pestaña 1" y "Consulta pestaña 2").
  4. Encontré (y fui voluntario) el "costo oculto" de mi enfoque, que son las cuatro entradas adicionales del Registro Tran cada vez que INSERTfalla debido a una violación de Restricción Única. No he visto eso mencionado en ninguna de las otras respuestas / publicaciones.

Con respecto al enfoque "JFDI" de @ gbn, la publicación "Pragmatismo feo para la victoria" de Michael J. Swart, y el comentario de Aaron Bertrand sobre la publicación de Michael (con respecto a sus pruebas que muestran qué escenarios han disminuido el rendimiento), y su comentario sobre su "adaptación de Michael J La adaptación de Stewart del procedimiento Try Catch JFDI de @ gbn "indicando:

Si está insertando valores nuevos con más frecuencia que seleccionando valores existentes, esto puede ser más eficaz que la versión de @ srutzky. De lo contrario, preferiría la versión de @ srutzky sobre esta.

Con respecto a esa discusión de gbn / Michael / Aaron relacionada con el enfoque "JFDI", sería incorrecto equiparar mi sugerencia al enfoque "JFDI" de gbn. Debido a la naturaleza de la operación "Obtener o insertar", existe una necesidad explícita SELECTde obtener el IDvalor de los registros existentes. Este SELECT actúa como IF EXISTSverificación, lo que hace que este enfoque sea más equiparable a la variación "Check TryCatch" de las pruebas de Aaron. El código reescrito de Michael (y su adaptación final de la adaptación de Michael) también incluye WHERE NOT EXISTSprimero hacer esa misma verificación. Por lo tanto, mi sugerencia (junto con el código final de Michael y su adaptación de su código final) en realidad no llegará al CATCHbloque con tanta frecuencia. Solo podrían ser situaciones donde dos sesiones,ItemNameINSERT...SELECTen el mismo momento exacto, de modo que ambas sesiones reciban un "verdadero" para el WHERE NOT EXISTSmismo momento exacto y, por lo tanto, ambos intenten hacerlo INSERTexactamente en el mismo momento. Esa situación muy específica ocurre con mucha menos frecuencia que seleccionar una existente ItemNameo insertar una nueva ItemNamecuando ningún otro proceso intenta hacerlo en el mismo momento exacto .

CON TODO LO ANTERIOR EN MENTE: ¿Por qué prefiero mi enfoque?

Primero, veamos qué bloqueo tiene lugar en el enfoque "serializable". Como se mencionó anteriormente, el "rango" que se bloquea depende de los valores clave existentes a cada lado de donde encajaría el nuevo valor clave. El comienzo o el final del rango también podría ser el comienzo o el final del índice, respectivamente, si no hay un valor clave existente en esa dirección. Supongamos que tenemos el siguiente índice y claves ( ^representa el comienzo del índice mientras que $representa el final):

Range #:    |--- 1 ---|--- 2 ---|--- 3 ---|--- 4 ---|
Key Value:  ^         C         F         J         $

Si la sesión 55 intenta insertar un valor clave de:

  • A, el rango n. ° 1 (de ^a C) está bloqueado: la sesión 56 no puede insertar un valor de B, incluso si es único y válido (todavía). Pero la sesión 56 se puede insertar valores de D, Gy M.
  • D, luego el rango # 2 (de Ca F) está bloqueado: la sesión 56 no puede insertar un valor de E(todavía). Pero la sesión 56 se puede insertar valores de A, Gy M.
  • M, luego el rango # 4 (de Ja $) está bloqueado: la sesión 56 no puede insertar un valor de X(todavía). Pero la sesión 56 se puede insertar valores de A, Dy G.

A medida que se agregan más valores clave, los rangos entre los valores clave se vuelven más estrechos, lo que reduce la probabilidad / frecuencia de que se inserten múltiples valores al mismo tiempo que luchan en el mismo rango. Es cierto que este no es un problema importante , y afortunadamente parece ser un problema que en realidad disminuye con el tiempo.

El problema con mi enfoque se describió anteriormente: solo ocurre cuando dos sesiones intentan insertar el mismo valor clave al mismo tiempo. A este respecto, todo se reduce a lo que tiene la mayor probabilidad de que suceda: ¿se intentan dos valores clave diferentes pero cercanos al mismo tiempo, o se intenta el mismo valor clave al mismo tiempo? Supongo que la respuesta radica en la estructura de la aplicación que realiza las inserciones, pero en general, supondría que es más probable que se inserten dos valores diferentes que comparten el mismo rango. Pero la única forma de saber realmente sería probar ambos en el sistema operativo.

A continuación, consideremos dos escenarios y cómo cada enfoque los maneja:

  1. Todas las solicitudes corresponden a valores clave únicos:

    En este caso, el CATCHbloque en mi sugerencia nunca se ingresa, por lo tanto, no hay "problema" (es decir, 4 entradas de registro de tran y el tiempo que lleva hacer eso). Pero, en el enfoque "serializable", incluso con todas las inserciones únicas, siempre habrá algún potencial para bloquear otras inserciones en el mismo rango (aunque no por mucho tiempo).

  2. Alta frecuencia de solicitudes para el mismo valor clave al mismo tiempo:

    En este caso, un grado muy bajo de unicidad en términos de solicitudes entrantes de valores clave inexistentes, el CATCHbloque en mi sugerencia se ingresará regularmente. El efecto de esto será que cada inserción fallida necesitará revertir automáticamente y escribir las 4 entradas en el Registro de transacciones, que es un pequeño golpe de rendimiento cada vez. Pero la operación general nunca debería fallar (al menos no debido a esto).

    (Hubo un problema con la versión anterior del enfoque "actualizado" que le permitía sufrir puntos muertos. Se updlockagregó una pista para solucionar esto y ya no tiene puntos muertos).PERO, en el enfoque "serializable" (incluso la versión actualizada y optimizada), la operación se estancará. ¿Por qué? Debido a que el serializablecomportamiento solo impide INSERToperaciones en el rango que ha sido leído y, por lo tanto, bloqueado; no impide SELECToperaciones en ese rango.

    El serializableenfoque, en este caso, parecería no tener una sobrecarga adicional, y podría funcionar un poco mejor de lo que estoy sugiriendo.

Al igual que con muchas / la mayoría de las discusiones sobre el rendimiento, debido a que hay tantos factores que pueden afectar el resultado, la única forma de tener una idea real de cómo funcionará algo es probarlo en el entorno objetivo donde se ejecutará. En ese momento no será una cuestión de opinión :).

Solomon Rutzky
fuente
7

Respuesta actualizada


Respuesta a @srutzky

Otro problema, aunque menor, con este enfoque de "transacción serializable + cláusula OUTPUT" es que la cláusula OUTPUT (en su uso actual) devuelve los datos como un conjunto de resultados. Un conjunto de resultados requiere más sobrecarga (probablemente en ambos lados: en SQL Server para administrar el cursor interno y en la capa de la aplicación para administrar el objeto DataReader) que un simple parámetro OUTPUT. Dado que solo estamos tratando con un solo valor escalar, y que la suposición es una alta frecuencia de ejecuciones, esa sobrecarga adicional del conjunto de resultados probablemente se suma.

Estoy de acuerdo, y por esas mismas razones, uso parámetros de salida cuando soy prudente . Fue mi error no utilizar un parámetro de salida en mi respuesta inicial, estaba siendo flojo.

Aquí hay un procedimiento revisado que utiliza un parámetro de salida, optimizaciones adicionales, junto con lo next value forque @srutzky explica en su respuesta :

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;

Nota de actualización : Incluyendo updlockcon la selección tomará los bloqueos adecuados en este escenario. Gracias a @srutzky, quien señaló que esto podría causar puntos muertos cuando solo se usa serializableen el select.

Nota: Este podría no ser el caso, pero si es posible, se llamará al procedimiento con un valor para @vValueId, incluir set @vValueId = null;después set xact_abort on;, de lo contrario se puede eliminar.


Con respecto a los ejemplos de @ srutzky del comportamiento de bloqueo de rango clave:

@srutzky solo usa un valor en su tabla, y bloquea la tecla "siguiente" / "infinito" para sus pruebas para ilustrar el bloqueo del rango de teclas. Si bien sus pruebas ilustran lo que sucede en esas situaciones, creo que la forma en que se presenta la información podría conducir a suposiciones falsas sobre la cantidad de bloqueo que uno podría esperar encontrar cuando se usa serializableen el escenario como se presenta en la pregunta original.

Aunque percibo un sesgo (tal vez falsamente) en la forma en que presenta su explicación y ejemplos de bloqueo de rango de teclas, siguen siendo correctos.


Después de más investigación, encontré un artículo de blog particularmente pertinente de 2011 de Michael J. Swart: Mythbusting: Soluciones concurrentes de actualización / inserción . En él, prueba múltiples métodos de precisión y concurrencia. Método 4: el aumento del aislamiento + los bloqueos de ajuste fino se basan en el patrón de inserción o actualización posterior de Sam Saffron para SQL Server , y el único método en la prueba original para cumplir con sus expectativas (se unió más tarde merge with (holdlock)).

En febrero de 2016, Michael J. Swart publicó Pragmatismo feo para la victoria . En esa publicación, cubre algunos ajustes adicionales que realizó en sus procedimientos de inserción de Saffron para reducir el bloqueo (que incluí en el procedimiento anterior).

Después de hacer esos cambios, Michael no estaba contento de que su procedimiento comenzara a parecer más complicado y consultó con una universidad llamada Chris. Chris leyó toda la publicación original de Mythbusters y leyó todos los comentarios y preguntó sobre el patrón TRY CATCH JFDI de @ gbn . Este patrón es similar a la respuesta de @ srutzky, y es la solución que Michael terminó usando en esa instancia.

Michael J Swart:

Ayer cambié de opinión sobre la mejor manera de hacer concurrencia. Describo varios métodos en Mythbusting: Soluciones concurrentes de actualización / inserción. Mi método preferido es aumentar el nivel de aislamiento y ajustar los bloqueos.

Al menos esa era mi preferencia. Recientemente cambié mi enfoque para usar un método que gbn sugirió en los comentarios. Describe su método como el "patrón TRY CATCH JFDI". Normalmente evito soluciones como esa. Hay una regla general que dice que los desarrolladores no deben confiar en la captura de errores o excepciones para el flujo de control. Pero ayer rompí esa regla de oro.

Por cierto, me encanta la descripción de gbn para el patrón "JFDI". Me recuerda al video motivacional de Shia Labeouf.


En mi opinión, ambas soluciones son viables. Si bien todavía prefiero aumentar el nivel de aislamiento y los bloqueos de ajuste fino, la respuesta de @ srutzky también es válida y puede o no ser más eficaz en su situación específica.

Quizás en el futuro yo también llegue a la misma conclusión que Michael J. Swart, pero todavía no estoy allí.


No es mi preferencia, pero así es como se vería mi adaptación de la adaptación de Michael J. Stewart del procedimiento 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 está insertando valores nuevos con más frecuencia que seleccionando valores existentes, esto puede ser más eficaz que la versión de @ srutzky . De lo contrario, preferiría la versión de @ srutzky sobre esta.

Los comentarios de Aaron Bertrand sobre la publicación de Michael J Swart enlazan con las pruebas relevantes que ha realizado y condujeron a este intercambio. Extracto de la sección de comentarios sobre Pragmatismo feo para la victoria :

Sin embargo, a veces, JFDI conduce a un peor rendimiento general, dependiendo de qué porcentaje de llamadas fallan. El aumento de excepciones tiene gastos generales sustanciales. Mostré esto en un par de publicaciones:

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/

Comentario de Aaron Bertrand - 11 de febrero de 2016 a las 11:49 a.m.

y la respuesta de:

Tienes razón, Aaron, y lo probamos.

Resulta que en nuestro caso, el porcentaje de llamadas que fallaron fue 0 (cuando se redondeó al porcentaje más cercano).

Creo que usted ilustra el punto de que, en la medida de lo posible, evalúe las cosas caso por caso sobre las siguientes reglas generales.

También es por eso que agregamos la cláusula WHERE NOT EXISTS no estrictamente necesaria.

Comentario de Michael J. Swart - 11 de febrero de 2016 a las 11:57 a.m.


Nuevos enlaces:


Respuesta original


Todavía prefiero el enfoque upsert de Sam Saffron frente al uso merge, especialmente cuando se trata de una sola fila.

Adaptaría ese método upsert a esta situación como esta:

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;

Sería coherente con su nombre, y como serializablees lo mismo holdlock, elija uno y sea coherente en su uso. Tiendo a usarlo serializableporque es el mismo nombre que cuando se especifica set transaction isolation level serializable.

Al usar serializableo holdlockse toma un bloqueo de rango en función del valor del @vNamecual hace que cualquier otra operación espere si seleccionan o insertan valores dbo.NameLookupque incluyen el valor en la wherecláusula.

Para que el bloqueo de rango funcione correctamente, debe haber un índice en la ItemNamecolumna que se aplica cuando se usa mergetambién.


Así es como se vería el procedimiento principalmente siguiendo los documentos técnicos de Erland Sommarskog para el manejo de errores , utilizando throw. Si throwno es así como está generando sus errores, cámbielo para que sea consistente con el resto de sus procedimientos:

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

Para resumir lo que está sucediendo en el procedimiento anterior: set nocount on; set xact_abort on;como siempre lo haces , entonces si nuestra variable de entrada is nullo vacía, select id = cast(null as int)como resultado. Si no es nulo o está vacío, obtenga el valor Idde nuestra variable mientras mantiene ese lugar en caso de que no esté allí. Si Idestá allí, envíalo. Si no está allí, insértelo y envíe ese nuevo Id.

Mientras tanto, otras llamadas a este procedimiento que intentan encontrar el Id para el mismo valor esperarán hasta que se realice la primera transacción y luego la seleccionarán y devolverán. Otras llamadas a este procedimiento u otras declaraciones en busca de otros valores continuarán porque esta no está en el camino.

Si bien estoy de acuerdo con @srutzky en que puede manejar las colisiones y tragar las excepciones para este tipo de problema, personalmente prefiero tratar de adaptar una solución para evitar hacerlo cuando sea posible. En este caso, no creo que usar los bloqueos serializablesea ​​un enfoque pesado, y estaría seguro de que manejaría bien la alta concurrencia.

Cita de la documentación del servidor SQL en las sugerencias de la tabla serializable/holdlock :

SERIALIZABLE

Es equivalente a HOLDLOCK. Hace que los bloqueos compartidos sean más restrictivos manteniéndolos hasta que se complete una transacción, en lugar de liberar el bloqueo compartido tan pronto como la tabla o página de datos requerida ya no sea necesaria, ya sea que la transacción se haya completado o no. La exploración se realiza con la misma semántica que una transacción que se ejecuta en el nivel de aislamiento SERIALIZABLE. Para obtener más información acerca de los niveles de aislamiento, vea SET TRANSACTION ISOLATION LEVEL (Transact-SQL).

Cita de la documentación del servidor SQL sobre el nivel de aislamiento de transaccionesserializable

SERIALIZABLE Especifica lo siguiente:

  • Las declaraciones no pueden leer datos que han sido modificados pero que otras transacciones aún no han confirmado.

  • Ninguna otra transacción puede modificar los datos que ha leído la transacción actual hasta que se complete la transacción actual.

  • Otras transacciones no pueden insertar nuevas filas con valores de clave que caerían en el rango de claves leídas por cualquier declaración en la transacción actual hasta que se complete la transacción actual.


Enlaces relacionados con la solución anterior:

MERGEtiene un historial irregular, y parece que se necesita más hurgar para asegurarse de que el código se comporta como lo desea bajo toda esa sintaxis. mergeArtículos relevantes :

Un último enlace, Kendra Little hizo una comparación aproximada de mergevsinsert with left join , con la advertencia donde dice "No hice pruebas de carga exhaustivas sobre esto", pero aún es una buena lectura.

SqlZim
fuente