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 Id
campo Clave primaria, para generar un nuevo Id
valor antes de tiempo. Generar el valor primero significa que no necesita preocuparse por no tenerlo SCOPE_IDENTITY
, lo que significa que no necesita la OUTPUT
cláusula ni hacer un adicional SELECT
para 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...CATCH
construcción, puede atrapar efectivamente un error específico (en este caso: "violación de restricción única", Msg 2601) y volver a ejecutar el SELECT
para obtener el Id
valor ya que sabemos que ahora existe debido a estar en el CATCH
bloque con ese particular error. Otros errores pueden ser manejados en el típico RAISERROR
/ RETURN
o THROW
forma.
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 TRY
usar la WHERE NOT EXISTS
cláusula?
MERGE
tiene 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:
- Si tiene suficientes ejecuciones de este procedimiento como para tener que preocuparse por las colisiones, entonces no desea:
- tomar más medidas de las necesarias
- mantener bloqueados los recursos por más tiempo del necesario
- Dado que las colisiones solo pueden ocurrir en nuevas entradas (nuevas entradas enviadas exactamente al mismo tiempo ), la frecuencia de caer en el
CATCH
bloque 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 serializable
sea 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 OUTPUT
clá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 OUTPUT
pará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 OUTPUT
cláusula podría usarse de tal manera que devuelva un OUTPUT
pará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 OUTPUT
pará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 serializable
en el escenario como se presenta en la pregunta original.
Sí, admitiré que soy parcial, aunque para ser justos:
- Es imposible que un humano no sea parcial, al menos en un pequeño grado, y trato de mantenerlo al mínimo,
- 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.
- 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").
- Encontré (y fui voluntario) el "costo oculto" de mi enfoque, que son las cuatro entradas adicionales del Registro Tran cada vez que
INSERT
falla 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 SELECT
de obtener el ID
valor de los registros existentes. Este SELECT actúa como IF EXISTS
verificació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 EXISTS
primero 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 CATCH
bloque con tanta frecuencia. Solo podrían ser situaciones donde dos sesiones,ItemName
INSERT...SELECT
en el mismo momento exacto, de modo que ambas sesiones reciban un "verdadero" para el WHERE NOT EXISTS
mismo momento exacto y, por lo tanto, ambos intenten hacerlo INSERT
exactamente en el mismo momento. Esa situación muy específica ocurre con mucha menos frecuencia que seleccionar una existente ItemName
o insertar una nueva ItemName
cuando 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
, G
y M
.
D
, luego el rango # 2 (de C
a 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
, G
y M
.
M
, luego el rango # 4 (de J
a $
) 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
, D
y 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:
Todas las solicitudes corresponden a valores clave únicos:
En este caso, el CATCH
bloque 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).
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 CATCH
bloque 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 updlock
agregó 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 serializable
comportamiento solo impide INSERT
operaciones en el rango que ha sido leído y, por lo tanto, bloqueado; no impide SELECT
operaciones en ese rango.
El serializable
enfoque, 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 :).