2009-07-09 16 views
5

Estoy tratando de implementar su funcionalidad UPSERT básica, pero con un giro: a veces no quiero actualizar realmente una fila existente.¿Cómo implementar un procedimiento almacenado condicional Upsert?

Básicamente, estoy tratando de sincronizar algunos datos entre repositorios diferentes, y una función Upsert me pareció el camino a seguir. Basándome en gran medida en Sam Saffron's answer to this question, así como en otras investigaciones y lecturas, se me ocurrió este procedimiento almacenado:

(nota: estoy usando MS SQL Server 2005, entonces la instrucción MERGE no es una opción)

CREATE PROCEDURE [dbo].[usp_UpsertItem] 
    -- Add the parameters for the stored procedure here 
    @pContentID varchar(30) = null, 
    @pTitle varchar(255) = null, 
    @pTeaser varchar(255) = null 
AS 
BEGIN 
    -- SET NOCOUNT ON added to prevent extra result sets from 
    -- interfering with SELECT statements. 
    SET NOCOUNT ON; 

    BEGIN TRANSACTION 

     UPDATE dbo.Item WITH (SERIALIZABLE) 
     SET Title = @pTitle, 
      Teaser = @pTeaser 
     WHERE ContentID = @pContentID 

     IF @@rowcount = 0 
      INSERT INTO dbo.Item (ContentID, Title, Teaser) 
      VALUES (@pContentID, @pTitle, @pTeaser) 

    COMMIT TRANSACTION 
END 

Me siento cómodo con esto para un Upsert básico, pero me gustaría hacer la actualización real condicional en el valor de otra columna. Piense en ello como "bloquear" una fila para que el procedimiento Upsert no pueda realizar más actualizaciones. Podría cambiar la instrucción UPDATE, así:

UPDATE dbo.Item WITH (SERIALIZABLE) 
SET Title = @pTitle, 
    Teaser = @pTeaser 
WHERE ContentID = @pContentID 
AND RowLocked = false 

Pero entonces la inserción posterior fallaría con una violación de restricción única (para el campo Content ID) cuando se intenta insertar una fila que ya existe pero no se actualiza porque Estaba bloqueado".

¿Significa esto que ya no tengo un Upsert clásico, es decir, que tendré que seleccionar la fila cada vez para determinar si se puede actualizar o insertar? Apuesto a que ese es el caso, así que supongo que lo que realmente estoy pidiendo es ayudar a que el nivel de aislamiento de la transacción sea correcto para que el procedimiento se ejecute de manera segura.

+0

¿Qué es RowLocked in (AND RowLocked = false)? ¿Es una columna en tu mesa? –

+0

@AlexKuznetsov - Sí, se supone que RowLocked es una columna de tabla; en realidad, hay un par de columnas que dictan si una fila debe estar "bloqueada" (es decir, no actualizada mediante este procedimiento), pero simplifiqué mi SQL para tratar de aclarar mi pregunta; aunque tiene un poco de descuido con la sintaxis, debería, por supuesto, ser "AND RowLocked = 0" y debería haber mencionado que es un poco de columna. – Matt

Respuesta

0

Puede cambiar el orden de la actualización/inserción. Entonces haces la inserción dentro de un try/catch y si obtienes una violación de restricción, entonces haz la actualización. Aunque se siente un poco sucio.

+0

Siempre pensé que no debías depender de los manejadores de errores para el procesamiento "normal", es decir, si sé que un caso de uso típico generará una excepción, entonces debería verificar esa condición y manejarla antes de que se presente una excepción .Así que estoy de acuerdo, se siente un poco sucio;) Si puedo obtener el nivel de aislamiento correcto (todavía estoy leyendo), entonces la lógica es bastante sencilla, pero pierdo la ventaja original del upsert (es decir, no se lee DB extra) . – Matt

2

Arreglé el siguiente script para probar este truco que utilicé en años pasados. Si lo usa, deberá modificarlo para adaptarlo a sus propósitos. Comentarios seguir:

/* 
CREATE TABLE Item 
(
    Title  varchar(255) not null 
    ,Teaser  varchar(255) not null 
    ,ContentId varchar(30) not null 
    ,RowLocked bit not null 
) 


UPDATE item 
set RowLocked = 1 
where ContentId = 'Test01' 

*/ 


DECLARE 
    @Check varchar(30) 
,@pContentID varchar(30) 
,@pTitle varchar(255) 
,@pTeaser varchar(255) 

set @pContentID = 'Test01' 
set @pTitle  = 'TestingTitle' 
set @pTeaser = 'TestingTeasier' 

set @check = null 

UPDATE dbo.Item 
set 
    @Check = ContentId 
    ,Title = @pTitle 
    ,Teaser = @pTeaser 
where ContentID = @pContentID 
    and RowLocked = 0 

print isnull(@check, '<check is null>') 

IF @Check is null 
    INSERT dbo.Item (ContentID, Title, Teaser, RowLocked) 
    values (@pContentID, @pTitle, @pTeaser, 0) 

select * from Item 

El truco aquí es que puede establecer valores en variables locales dentro de una instrucción de actualización. Arriba, el valor de "bandera" se establece solo si la actualización funciona (es decir, se cumplen los criterios de actualización); de lo contrario, no se cambiará (aquí, izquierda en nulo), puede verificarlo y procesar en consecuencia.

En cuanto a la transacción y hacerla serializable, me gustaría saber más sobre lo que debe ser encapsulado dentro de la transacción antes de sugerir cómo proceder.

- Adenda, el seguimiento del segundo comentario más abajo -----------

las ideas del señor azafrán se están definidas de manera completa y sólida de la implementación de esta rutina, ya que sus claves primarias afuera y pasé a la base de datos (es decir, no está usando columnas de identidad, estoy de acuerdo, a menudo se usan en exceso).

Realicé algunas pruebas más (agregué una restricción de clave primaria en la columna ContentId, envolví UPDATE e INSERT en una transacción, agregué la sugerencia serializable a la actualización) y sí, eso debería hacer todo lo que quisiera. La actualización fallida aplica un bloqueo de rango en esa parte del índice, y eso bloqueará cualquier intento simultáneo de insertar ese nuevo valor en la columna. Por supuesto, si las solicitudes N se envían simultáneamente, la "primera" creará la fila, y se actualizará inmediatamente por la segunda, tercera, etc., a menos que establezca el "bloqueo" en algún punto de la línea. ¡Buen truco!

(Tenga en cuenta que sin el índice en la columna de clave, bloquearía toda la tabla. Además, el bloqueo de rango puede bloquear las filas en "ambos lados" del nuevo valor, o tal vez no, No lo probé. No debería importar, ya que la duración de la operación debería ser [?] En milisegundos de un solo dígito.)

+0

Para mencionar, en el código de ejemplo original actualiza el elemento de la tabla, pero se inserta en la tabla MailItem; ¿No se supone que los postres se aplican contra la misma mesa? –

+0

Los nombres de tablas no coincidentes son un error tipográfico (ahora corregido). Sabía que podía establecer la variable local con un SELECCIONAR, pero nunca lo había intentado con una ACTUALIZACIÓN, por lo que podría hacer el truco. Con respecto a la transacción serializable, mi entendimiento (ciertamente imperfecto) es que si no usas algún tipo de bloqueo, puedes obtener infracciones de restricción de clave únicas y que "ACTUALIZAR con (serializable)" hace esto sin interbloqueo. Estoy trabajando con el ejemplo en la pregunta vinculada (arriba) y sigo leyendo/tratando de asegurarme de entender exactamente qué hace eso. – Matt

+0

Actualicé mi respuesta con comentarios sobre el comentario anterior. –

8

Un problema muy común. Algunos enfoques no se sostienen en alta concurrencia. Descrito y el estrés probado aquí:

Stress testing UPSERTs

Defensive database programming: eliminating IF statements.

En tales casos no es suficiente con solo escribir código, es necesario exponerlo de alta concurrencia. Por ejemplo, no estoy seguro de haber entendido lo que recomienda CptSkippy , pero a continuación se muestra cómo realizar una prueba de esfuerzo. Establecer una mesa y un procedimiento:

CREATE TABLE [dbo].[TwoINTs](
     [ID] [int] NOT NULL, 
     [i1] [int] NOT NULL, 
     [i2] [int] NOT NULL, 
     [i3] [int] NOT NULL 
); 
CREATE PROCEDURE dbo.SaveTwoINTs(@ID INT, @i1 INT, @i2 INT) 
AS 
BEGIN 
     SET NOCOUNT ON; 
     SET XACT_ABORT OFF; 
     SET TRANSACTION ISOLATION LEVEL READ COMMITTED; 
     DECLARE @ret INT; 
     SET @ret=0; 
     BEGIN TRAN; 
IF EXISTS(SELECT 1 FROM dbo.TwoINTs WHERE [email protected]) BEGIN 
     UPDATE dbo.TwoINTs WITH (SERIALIZABLE) 
     SET [email protected], [email protected] WHERE [email protected]; 
     SET @[email protected]@ERROR; 
END ELSE BEGIN 
    INSERT INTO dbo.TwoINTs(ID, i1, i2, i3)VALUES(@ID, @i1, @i2, @i1); 
     SET @[email protected]@ERROR; 
END; 
COMMIT; 
RETURN @ret; 
END 
GO 

la creación de dos bucles que ejecutan este procedimiento:

CREATE PROCEDURE Testers.UpsertLoop1 
AS 
BEGIN 
DECLARE @ID INT, @i1 INT, @i2 INT, @count INT, @ret INT; 
SET @count = 0; 
WHILE @count<50000 BEGIN 
     SELECT @ID = COALESCE(MAX(ID),0) + 1 FROM dbo.TwoInts; 
    EXEC @ret=dbo.SaveTwoINTs @ID, 1, 0; 
     SET @count = @count + 1; 
END; 
END; 
GO 
CREATE PROCEDURE Testers.UpsertLoop2 
AS 
BEGIN 
DECLARE @ID INT, @i1 INT, @i2 INT, @count INT, @ret INT; 
SET @count = 0; 
WHILE @count<50000 BEGIN 
     SELECT @ID = COALESCE(MAX(ID),0) + 1 FROM dbo.TwoInts; 
    EXEC @ret=dbo.SaveTwoINTs @ID, 0, 1; 
     SET @count = @count + 1; 
END; 
END; 

ejecutar estos procedimientos en dos pestañas y ver por sí mismo que se obtiene una gran cantidad de errores:

Testers.UpsertLoop1 --run in one tab 
Testers.UpsertLoop1 --run in one tab 

Msg 2601, Level 14, State 1, Procedure SaveTwoINTs, Line 15 
Cannot insert duplicate key row in object 'dbo.TwoINTs' with unique index 'UNQ_TwoInts_ID'. 
The statement has been terminated. 

Siga los enlaces que proporcioné para ver los enfoques que realmente funcionan bajo concurrencia.

+0

@Alex +1 para los enlaces y consejos sobre cómo hacer una prueba de esfuerzo; Definitivamente voy a intentarlo. – Matt

0

CREAR PROCEDIMIENTO [dbo] [usp_UpsertItem] -. Añadir los parámetros para el procedimiento almacenado aquí varchar @pContentID (30) = null, varchar @pTitle (255) = null, @pTeaser varchar (255) = null AS COMIENZO - SET NOCOUNT ON ON agregado para evitar conjuntos de resultados extra de - interfiriendo con las instrucciones SELECT. SET NOCOUNT ON;

BEGIN TRANSACTION 
    IF EXISTS (SELECT 1 FROM dbo.Item WHERE ContentID = @pContentID 
      AND RowLocked = false) 
     UPDATE dbo.Item 
     SET Title = @pTitle, Teaser = @pTeaser 
     WHERE ContentID = @pContentID 
      AND RowLocked = false 
    ELSE IF NOT EXISTS (SELECT 1 FROM dbo.Item WHERE ContentID = @pContentID) 
      INSERT INTO dbo.Item (ContentID, Title, Teaser) 
      VALUES (@pContentID, @pTitle, @pTeaser) 

COMMIT TRANSACTION 

FIN

+2

Horrible código horrible! No solo está ejecutando una consulta dos veces para la misma operación condicional, sino que está usando "else if no exists" cuando lo haría un simple "else". Vea la respuesta de CptSkippy para un mejor ejemplo. – Chris

+0

Estoy de acuerdo en que la otra solución es más limpia, pero horrible, horrible ... Estaba apuntando en la dirección correcta, ¿no? – JNappi

1
BEGIN TRANSACTION 

IF EXISTS(SELECT 1 FROM dbo.Item WHERE ContentID = @pContentID) 
    UPDATE dbo.Item WITH (SERIALIZABLE) 
    SET Title = @pTitle, Teaser = @pTeaser 
    WHERE ContentID = @pContentID 
    AND RowLocked = false 
ELSE 
    INSERT INTO dbo.Item 
      (ContentID, Title, Teaser) 
    VALUES 
      (@pContentID, @pTitle, @pTeaser) 

COMMIT TRANSACTION 
+0

¿Qué está RowLocked in (AND RowLocked = false)? ¿Es una columna en tu mesa? –

+1

Pongo énfasis en probar lo que entendí como su enfoque, y no se mantiene en alta concurrencia. –

-2

me dejaría caer la transacción.

Más el @@ rowcount probablemente funcionaría, pero el uso de variables globales como una verificación condicional dará lugar a errores.

Just do an Exists() check. Tienes que pasar por la mesa de todos modos, así que la velocidad no es el problema.

No hay necesidad de la transacción por lo que puedo ver.

+1

El patrón de actualización/inserción con rowcount solo es seguro porque usa serializable para bloquear hasta el inserto. De lo contrario, la inserción podría entrar en conflicto con un intento concurrente de actualización que también no coincide con ninguna fila y, a continuación, la doble inserción provoca filas duplicadas o un error de clave duplicado si tiene claves únicas. –

Cuestiones relacionadas