sql >> Base de Datos >  >> RDS >> Database

Deje de usar este antipatrón UPSERT

Creo que todo el mundo ya conoce mis opiniones sobre MERGE y por qué me mantengo alejado de eso. Pero aquí hay otro (anti-) patrón que veo por todas partes cuando la gente quiere realizar un upsert (actualice una fila si existe e insértela si no):

IF EXISTS (SELECT 1 FROM dbo.t WHERE [key] = @key)
BEGIN
  UPDATE dbo.t SET val = @val WHERE [key] = @key;
END
ELSE
BEGIN
  INSERT dbo.t([key], val) VALUES(@key, @val); 
END

Esto parece un flujo bastante lógico que refleja cómo pensamos sobre esto en la vida real:

  • ¿Ya existe una fila para esta clave?
    • :OK, actualice esa fila.
    • NO :OK, luego agréguelo.

Pero esto es un desperdicio.

Ubicar la fila para confirmar que existe, solo para tener que ubicarla nuevamente para actualizarla, es hacer el doble de trabajo para nada. Incluso si la clave está indexada (que espero que sea siempre el caso). Si pongo esta lógica en un diagrama de flujo y asocio, en cada paso, el tipo de operación que tendría que ocurrir dentro de la base de datos, tendría esto:

Observe que todas las rutas incurrirán en dos operaciones de índice.

Más importante aún, aparte del rendimiento, a menos que use una transacción explícita y eleve el nivel de aislamiento, varias cosas podrían salir mal cuando la fila aún no existe:

  • Si la clave existe y dos sesiones intentan actualizarse simultáneamente, ambas se actualizarán correctamente (uno "ganará"; el "perdedor" seguirá con el cambio que se mantiene, lo que lleva a una "actualización perdida"). Esto no es un problema en sí mismo, y es como deberíamos esperar que funcione un sistema con concurrencia. Paul White habla sobre la mecánica interna con mayor detalle aquí, y Martin Smith habla sobre algunos otros matices aquí.
  • Si la clave no existe, pero ambas sesiones pasan la verificación de existencia de la misma manera, podría pasar cualquier cosa cuando ambas intenten insertar:
    • punto muerto debido a bloqueos incompatibles;
    • generar errores clave de infracción eso no debería haber sucedido; o,
    • insertar valores clave duplicados si esa columna no está restringida correctamente.

Ese último es el peor, en mi humilde opinión, porque es el que potencialmente corrompe los datos . Los interbloqueos y las excepciones se pueden manejar fácilmente con cosas como el manejo de errores, XACT_ABORT y la lógica de reintento, según la frecuencia con la que espere colisiones. Pero si se deja llevar por la sensación de seguridad de que IF EXISTS check lo protege de duplicados (o violaciones de claves), que es una sorpresa esperando a suceder. Si espera que una columna actúe como una clave, hágalo oficial y agregue una restricción.

"Mucha gente dice..."

Dan Guzman habló sobre las condiciones de carrera hace más de una década en Condición de carrera INSERTAR/ACTUALIZAR condicional y más tarde en Condición de carrera "UPSERT" con MERGE.

Michael Swart también ha tratado este tema varias veces:

  • Destrucción de mitos:actualización simultánea/soluciones de inserción:donde reconoció que dejar la lógica inicial en su lugar y solo elevar el nivel de aislamiento solo cambió las infracciones clave a interbloqueos;
  • Tenga cuidado con la Declaración de fusión:donde comprobó su entusiasmo por MERGE; y,
  • Qué evitar si desea usar MERGE, donde confirmó una vez más que todavía hay muchas razones válidas para continuar evitando MERGE .

Asegúrate de leer también todos los comentarios en las tres publicaciones.

La solución

He solucionado muchos puntos muertos en mi carrera simplemente ajustándome al siguiente patrón (eliminar el cheque redundante, envolver la secuencia en una transacción y proteger el primer acceso a la tabla con el bloqueo adecuado):

BEGIN TRANSACTION;
 
UPDATE dbo.t WITH (UPDLOCK, SERIALIZABLE) SET val = @val WHERE [key] = @key;
 
IF @@ROWCOUNT = 0
BEGIN
  INSERT dbo.t([key], val) VALUES(@key, @val);
END
 
COMMIT TRANSACTION;

¿Por qué necesitamos dos pistas? No es UPDLOCK suficiente?

  • UPDLOCK se utiliza para proteger contra bloqueos de conversión en la declaración nivel (deje que otra sesión espere en lugar de animar a la víctima a que vuelva a intentarlo).
  • SERIALIZABLE se utiliza para proteger contra cambios en los datos subyacentes a lo largo de la transacción (asegúrese de que una fila que no existe siga sin existir).

Es un poco más de código, pero es 1000% más seguro, e incluso en el peor caso (la fila aún no existe), realiza lo mismo que el antipatrón. En el mejor de los casos, si está actualizando una fila que ya existe, será más eficiente ubicar esa fila solo una vez. Combinando esta lógica con las operaciones de alto nivel que tendrían que ocurrir en la base de datos, es un poco más simple:

En este caso, una ruta solo incurre en una sola operación de índice.

Pero de nuevo, aparte del rendimiento:

  • Si la clave existe y dos sesiones intentan actualizarla al mismo tiempo, ambas se alternarán y actualizarán la fila con éxito , como antes.
  • Si la clave no existe, una sesión "ganará" e insertará la fila . El otro tendrá que esperar hasta que se liberen los bloqueos para incluso verificar su existencia y se vean obligados a actualizar.

En ambos casos, el escritor que ganó la carrera pierde sus datos por cualquier cosa que el "perdedor" actualice después de ellos.

Tenga en cuenta que el rendimiento general en un sistema altamente concurrente podría sufrir, pero ese es un compromiso que deberías estar dispuesto a hacer. El hecho de que tenga muchas víctimas de interbloqueo o errores de violación de clave, pero que sucedan rápidamente, no es una buena métrica de rendimiento. A algunas personas les encantaría que se eliminaran todos los bloqueos de todos los escenarios, pero algunos de ellos son los bloqueos que absolutamente desea para la integridad de los datos.

¿Pero qué sucede si es menos probable una actualización?

Está claro que la solución anterior se optimiza para las actualizaciones y asume que una clave en la que está tratando de escribir ya existirá en la tabla al menos con tanta frecuencia como no. Si prefiere optimizar para las inserciones, sabiendo o adivinando que las inserciones serán más probables que las actualizaciones, puede cambiar la lógica y aún tener una operación upsert segura:

BEGIN TRANSACTION;
 
INSERT dbo.t([key], val) 
  SELECT @key, @val
  WHERE NOT EXISTS
  (
    SELECT 1 FROM dbo.t WITH (UPDLOCK, SERIALIZABLE)
      WHERE [key] = @key
  );
 
IF @@ROWCOUNT = 0
BEGIN
  UPDATE dbo.t SET val = @val WHERE [key] = @key;
END
 
COMMIT TRANSACTION;

También existe el enfoque de "simplemente hazlo", en el que insertas ciegamente y dejas que las colisiones generen excepciones para la persona que llama:

BEGIN TRANSACTION;
 
BEGIN TRY
  INSERT dbo.t([key], val) VALUES(@key, @val);
END TRY
BEGIN CATCH
  UPDATE dbo.t SET val = @val WHERE [key] = @key;
END CATCH
 
COMMIT TRANSACTION;

El costo de esas excepciones a menudo superará el costo de verificar primero; tendrá que intentarlo con una suposición aproximada de la tasa de aciertos/fallos. Escribí sobre esto aquí y aquí.

¿Qué hay de alterar varias filas?

Lo anterior trata sobre las decisiones de inserción/actualización de singleton, pero Justin Pealing preguntó qué hacer cuando se procesan varias filas sin saber cuáles de ellas ya existen.

Suponiendo que está enviando un conjunto de filas usando algo como un parámetro con valores de tabla, actualizaría usando una combinación y luego insertaría usando NO EXISTE, pero el patrón aún sería equivalente al primer enfoque anterior:

CREATE PROCEDURE dbo.UpsertTheThings
    @tvp dbo.TableType READONLY
AS
BEGIN
  SET NOCOUNT ON;
 
  BEGIN TRANSACTION;
 
  UPDATE t WITH (UPDLOCK, SERIALIZABLE) 
    SET val = tvp.val
  FROM dbo.t AS t
  INNER JOIN @tvp AS tvp
    ON t.[key] = tvp.[key];
 
  INSERT dbo.t([key], val)
    SELECT [key], val FROM @tvp AS tvp
    WHERE NOT EXISTS (SELECT 1 FROM dbo.t WHERE [key] = tvp.[key]);
 
  COMMIT TRANSACTION;
END

Si está juntando varias filas de alguna otra manera que no sea un TVP (XML, lista separada por comas, vudú), colóquelas primero en forma de tabla y únalas a lo que sea. Tenga cuidado de no optimizar las inserciones primero en este escenario, de lo contrario, podría actualizar algunas filas dos veces.

Conclusión

Estos patrones de inserción son superiores a los que veo con demasiada frecuencia, y espero que empieces a usarlos. Señalaré esta publicación cada vez que detecte el IF EXISTS patrón en la naturaleza. Y, oye, otro agradecimiento a Paul White (sql.kiwi | @SQK_Kiwi), porque es excelente para hacer que los conceptos difíciles sean fáciles de entender y, a su vez, explicar.

Y si sientes que tienes que usa MERGE , por favor no me @; o tienes una buena razón (tal vez necesites algo oscuro MERGE -solo funcionalidad), o no tomó en serio los enlaces anteriores.