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 INSERT
bien, y luego obtener el valor resultante. Hay una restricción de unicidad en la dbo.NameLookup.ItemName
columna, por lo que la integridad de los datos no está en riesgo, pero no quiero encontrar las excepciones.
No es IDENTITY
así, no puedo obtenerlo SCOPE_IDENTITY
y el valor podría ser NULL
en ciertos casos.
En mi situación, solo tengo que lidiar con la INSERT
seguridad en la mesa, así que estoy tratando de decidir si es una mejor práctica usar MERGE
así:
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 MERGE
solo un condicional INSERT
seguido 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() THEN
enfoque que no creo que sean aceptables.
Respuestas:
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 nuevoId
valor antes de tiempo. Generar el valor primero significa que no necesita preocuparse por no tenerloSCOPE_IDENTITY
, lo que significa que no necesita laOUTPUT
cláusula ni hacer un adicionalSELECT
para obtener el nuevo valor; tendrá el valor antes de hacer elINSERT
, y ni siquiera necesita meterse conSET 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 elSELECT
para obtener elId
valor ya que sabemos que ahora existe debido a estar en elCATCH
bloque con ese particular error. Otros errores pueden ser manejados en el típicoRAISERROR
/RETURN
oTHROW
forma.Configuración de prueba: secuencia, tabla e índice único
Configuración de prueba: procedimiento almacenado
La prueba
Pregunta de OP
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 ningunaHOLDLOCK
, etc. Es prácticamente seguro que funcione.El razonamiento detrás de este enfoque es:
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)
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):
Ahora, aquí está el comentario en el código de ejemplo:
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)
(En la pestaña de consulta (es decir, Sesión) # 2)
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)
(En la pestaña de consulta (es decir, Sesión) # 2)
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 unOUTPUT
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 unOUTPUT
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 elOUTPUT
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.
Sí, admitiré que soy parcial, aunque para ser justos:
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:
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 elID
valor de los registros existentes. Este SELECT actúa comoIF 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 incluyeWHERE 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á alCATCH
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 elWHERE NOT EXISTS
mismo momento exacto y, por lo tanto, ambos intenten hacerloINSERT
exactamente en el mismo momento. Esa situación muy específica ocurre con mucha menos frecuencia que seleccionar una existenteItemName
o insertar una nuevaItemName
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):Si la sesión 55 intenta insertar un valor clave de:
A
, el rango n. ° 1 (de^
aC
) está bloqueado: la sesión 56 no puede insertar un valor deB
, incluso si es único y válido (todavía). Pero la sesión 56 se puede insertar valores deD
,G
yM
.D
, luego el rango # 2 (deC
aF
) está bloqueado: la sesión 56 no puede insertar un valor deE
(todavía). Pero la sesión 56 se puede insertar valores deA
,G
yM
.M
, luego el rango # 4 (deJ
a$
) está bloqueado: la sesión 56 no puede insertar un valor deX
(todavía). Pero la sesión 56 se puede insertar valores deA
,D
yG
.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 elserializable
comportamiento solo impideINSERT
operaciones en el rango que ha sido leído y, por lo tanto, bloqueado; no impideSELECT
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 :).
fuente
Respuesta actualizada
Respuesta a @srutzky
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 for
que @srutzky explica en su respuesta :Nota de actualización : Incluyendo
updlock
con 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 usaserializable
en elselect
.Nota: Este podría no ser el caso, pero si es posible, se llamará al procedimiento con un valor para
@vValueId
, incluirset @vValueId = null;
despuésset 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
serializable
en 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:
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 :
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 :
y la respuesta de:
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:
Sería coherente con su nombre, y como
serializable
es lo mismoholdlock
, elija uno y sea coherente en su uso. Tiendo a usarloserializable
porque es el mismo nombre que cuando se especificaset transaction isolation level serializable
.Al usar
serializable
oholdlock
se toma un bloqueo de rango en función del valor del@vName
cual hace que cualquier otra operación espere si seleccionan o insertan valoresdbo.NameLookup
que incluyen el valor en lawhere
cláusula.Para que el bloqueo de rango funcione correctamente, debe haber un índice en la
ItemName
columna que se aplica cuando se usamerge
tambié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
. Sithrow
no es así como está generando sus errores, cámbielo para que sea consistente con el resto de sus procedimientos: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 entradais null
o vacía,select id = cast(null as int)
como resultado. Si no es nulo o está vacío, obtenga el valorId
de nuestra variable mientras mantiene ese lugar en caso de que no esté allí. SiId
está allí, envíalo. Si no está allí, insértelo y envíe ese nuevoId
.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
serializable
sea 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
:Cita de la documentación del servidor SQL sobre el nivel de aislamiento de transacciones
serializable
Enlaces relacionados con la solución anterior:
Insertar o actualizar patrón para SQL Server - Sam Saffron
Documentación sobre sugerencias de tabla serializables y otras: MSDN
Manejo de errores y transacciones en SQL Server Parte uno - Manejo de errores Jumpstart - Erland Sommarskog
El consejo de Erland Sommarskog con respecto a @@ rowcount , (que no seguí en este caso).
MERGE
tiene 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.merge
Artículos relevantes :Un interesante error MERGE - Paul White
Condición de carrera UPSERT con combinación - sqlteam
Tenga precaución con la declaración MERGE de SQL Server - Aaron Bertrand
¿Puedo optimizar esta declaración de fusión? Aaron Bertrand
Si está utilizando vistas indexadas y MERGE, ¡lea esto! - Aaron Bertrand
Un último enlace, Kendra Little hizo una comparación aproximada de
merge
vsinsert with left join
, con la advertencia donde dice "No hice pruebas de carga exhaustivas sobre esto", pero aún es una buena lectura.fuente