Manejo del acceso concurrente a una tabla de claves sin puntos muertos en SQL Server

32

Tengo una tabla que es utilizada por una aplicación heredada como un sustituto de los IDENTITYcampos en varias otras tablas.

Cada fila de la tabla almacena la última ID utilizada LastIDpara el campo mencionado IDName.

Ocasionalmente, el proceso almacenado tiene un punto muerto: creo que he creado un controlador de errores apropiado; Sin embargo, estoy interesado en ver si esta metodología funciona como creo, o si estoy ladrando el árbol equivocado aquí.

Estoy bastante seguro de que debería haber una manera de acceder a esta tabla sin ningún punto muerto.

La base de datos en sí está configurada con READ_COMMITTED_SNAPSHOT = 1.

Primero, aquí está la tabla:

CREATE TABLE [dbo].[tblIDs](
    [IDListID] [int] NOT NULL 
        CONSTRAINT PK_tblIDs 
        PRIMARY KEY CLUSTERED 
        IDENTITY(1,1) ,
    [IDName] [nvarchar](255) NULL,
    [LastID] [int] NULL,
);

Y el índice no agrupado en el IDNamecampo:

CREATE NONCLUSTERED INDEX [IX_tblIDs_IDName] 
ON [dbo].[tblIDs]
(
    [IDName] ASC
) 
WITH (
    PAD_INDEX = OFF
    , STATISTICS_NORECOMPUTE = OFF
    , SORT_IN_TEMPDB = OFF
    , DROP_EXISTING = OFF
    , ONLINE = OFF
    , ALLOW_ROW_LOCKS = ON
    , ALLOW_PAGE_LOCKS = ON
    , FILLFACTOR = 80
);

GO

Algunos datos de muestra:

INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeTestID', 1);
INSERT INTO tblIDs (IDName, LastID) 
    VALUES ('SomeOtherTestID', 1);
GO

El procedimiento almacenado utilizado para actualizar los valores almacenados en la tabla y devolver la siguiente ID:

CREATE PROCEDURE [dbo].[GetNextID](
    @IDName nvarchar(255)
)
AS
BEGIN
    /*
        Description:    Increments and returns the LastID value from tblIDs
        for a given IDName
        Author:         Max Vernon
        Date:           2012-07-19
    */

    DECLARE @Retry int;
    DECLARE @EN int, @ES int, @ET int;
    SET @Retry = 5;
    DECLARE @NewID int;
    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    SET NOCOUNT ON;
    WHILE @Retry > 0
    BEGIN
        BEGIN TRY
            BEGIN TRANSACTION;
            SET @NewID = COALESCE((SELECT LastID 
                FROM tblIDs 
                WHERE IDName = @IDName),0)+1;
            IF (SELECT COUNT(IDName) 
                FROM tblIDs 
                WHERE IDName = @IDName) = 0 
                    INSERT INTO tblIDs (IDName, LastID) 
                    VALUES (@IDName, @NewID)
            ELSE
                UPDATE tblIDs 
                SET LastID = @NewID 
                WHERE IDName = @IDName;
            COMMIT TRANSACTION;
            SET @Retry = -2; /* no need to retry since the operation completed */
        END TRY
        BEGIN CATCH
            IF (ERROR_NUMBER() = 1205) /* DEADLOCK */
                SET @Retry = @Retry - 1;
            ELSE
                BEGIN
                SET @Retry = -1;
                SET @EN = ERROR_NUMBER();
                SET @ES = ERROR_SEVERITY();
                SET @ET = ERROR_STATE()
                RAISERROR (@EN,@ES,@ET);
                END
            ROLLBACK TRANSACTION;
        END CATCH
    END
    IF @Retry = 0 /* must have deadlock'd 5 times. */
    BEGIN
        SET @EN = 1205;
        SET @ES = 13;
        SET @ET = 1
        RAISERROR (@EN,@ES,@ET);
    END
    ELSE
        SELECT @NewID AS NewID;
END
GO

Ejecuciones de muestra del proceso almacenado:

EXEC GetNextID 'SomeTestID';

NewID
2

EXEC GetNextID 'SomeTestID';

NewID
3

EXEC GetNextID 'SomeOtherTestID';

NewID
2

EDITAR:

He agregado un nuevo índice, ya que el SP no está utilizando el índice IX_tblIDs_Name existente; Supongo que el procesador de consultas está utilizando el índice agrupado ya que necesita el valor almacenado en LastID. De todos modos, este índice ES utilizado por el plan de ejecución real:

CREATE NONCLUSTERED INDEX IX_tblIDs_IDName_LastID 
ON dbo.tblIDs
(
    IDName ASC
) 
INCLUDE
(
    LastID
)
WITH (FILLFACTOR = 100
    , ONLINE=ON
    , ALLOW_ROW_LOCKS = ON
    , ALLOW_PAGE_LOCKS = ON);

EDITAR # 2:

Tomé el consejo que dio @AaronBertrand y lo modifiqué un poco. La idea general aquí es refinar la declaración para eliminar bloqueos innecesarios y, en general, hacer que el SP sea más eficiente.

El siguiente código reemplaza el código anterior de BEGIN TRANSACTIONa END TRANSACTION:

BEGIN TRANSACTION;
SET @NewID = COALESCE((SELECT LastID 
        FROM dbo.tblIDs 
        WHERE IDName = @IDName), 0) + 1;

IF @NewID = 1
    INSERT INTO tblIDs (IDName, LastID) 
    VALUES (@IDName, @NewID);
ELSE
    UPDATE dbo.tblIDs 
    SET LastID = @NewID 
    WHERE IDName = @IDName;

COMMIT TRANSACTION;

Dado que nuestro código nunca agrega un registro a esta tabla con 0 LastID, podemos suponer que si @NewID es 1, entonces la intención es agregar una nueva ID a la lista, de lo contrario, estamos actualizando una fila existente en la lista.

Max Vernon
fuente
La forma en que configuró la base de datos para admitir RCSI es irrelevante. Estás escalando intencionalmente hasta SERIALIZABLEaquí.
Aaron Bertrand
Sí, solo quería agregar toda la información relevante. ¡Me alegra que estés confirmando que es irrelevante!
Max Vernon
Es muy fácil hacer que sp_getapplock se convierta en una víctima de punto muerto, pero no si comienza la transacción, llame a sp_getapplock una vez para adquirir un bloqueo exclusivo y continúe con su modificación.
AK
1
¿IDName es único? Luego recomiende "crear un índice único no agrupado". Sin embargo, si necesita valores nulos, entonces el índice también necesitaría ser filtrado .
crokusek

Respuestas:

15

Primero, evitaría hacer un viaje de ida y vuelta a la base de datos por cada valor. Por ejemplo, si su aplicación sabe que necesita 20 ID nuevas, no realice 20 viajes de ida y vuelta. Realice una sola llamada de procedimiento almacenado e incremente el contador en 20. Además, podría ser mejor dividir su tabla en varias.

Es posible evitar puntos muertos por completo. No tengo ningún punto muerto en mi sistema. Hay varias formas de lograr eso. Mostraré cómo usaría sp_getapplock para eliminar puntos muertos. No tengo idea de si esto funcionará para usted, porque SQL Server es de código cerrado, por lo que no puedo ver el código fuente y, como tal, no sé si he probado todos los casos posibles.

Lo siguiente describe lo que funciona para mí. YMMV.

Primero, comencemos con un escenario donde siempre tenemos una cantidad considerable de puntos muertos. En segundo lugar, usaremos sp_getapplock para eliminarlos. El punto más importante aquí es hacer una prueba de esfuerzo de su solución. Su solución puede ser diferente, pero debe exponerla a una alta concurrencia, como demostraré más adelante.

Prerrequisitos

Vamos a configurar una tabla con algunos datos de prueba:

CREATE TABLE dbo.Numbers(n INT NOT NULL PRIMARY KEY); 
GO 

INSERT INTO dbo.Numbers 
    ( n ) 
        VALUES  ( 1 ); 
GO 
DECLARE @i INT; 
    SET @i=0; 
WHILE @i<21  
    BEGIN 
    INSERT INTO dbo.Numbers 
        ( n ) 
        SELECT n + POWER(2, @i) 
        FROM dbo.Numbers; 
    SET @i = @i + 1; 
    END;  
GO

SELECT n AS ID, n AS Key1, n AS Key2, 0 AS Counter1, 0 AS Counter2
INTO dbo.DeadlockTest FROM dbo.Numbers
GO

ALTER TABLE dbo.DeadlockTest ADD CONSTRAINT PK_DeadlockTest PRIMARY KEY(ID);
GO

CREATE INDEX DeadlockTestKey1 ON dbo.DeadlockTest(Key1);
GO

CREATE INDEX DeadlockTestKey2 ON dbo.DeadlockTest(Key2);
GO

Es probable que los dos procedimientos siguientes se incluyan en un punto muerto:

CREATE PROCEDURE dbo.UpdateCounter1 @Key1 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
SET @Key1=@Key1-10000;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
COMMIT;
GO

CREATE PROCEDURE dbo.UpdateCounter2 @Key2 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
SET @Key2=@Key2-10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
SET @Key2=@Key2+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
COMMIT;
GO

Reproducción de puntos muertos

Los siguientes bucles deberían reproducir más de 20 puntos muertos cada vez que los ejecute. Si obtiene menos de 20, aumente el número de iteraciones.

En una pestaña, ejecuta esto;

DECLARE @i INT, @DeadlockCount INT;
SELECT @i=0, @DeadlockCount=0;

WHILE @i<5000 BEGIN ;
  BEGIN TRY 
    EXEC dbo.UpdateCounter1 @Key1=123456;
  END TRY
  BEGIN CATCH
    SET @DeadlockCount = @DeadlockCount + 1;
    ROLLBACK;
  END CATCH ;
  SET @i = @i + 1;
END;
SELECT 'Deadlocks caught: ', @DeadlockCount ;

En otra pestaña, ejecute este script.

DECLARE @i INT, @DeadlockCount INT;
SELECT @i=0, @DeadlockCount=0;

WHILE @i<5000 BEGIN ;
  BEGIN TRY 
    EXEC dbo.UpdateCounter2 @Key2=123456;
  END TRY
  BEGIN CATCH
    SET @DeadlockCount = @DeadlockCount + 1;
    ROLLBACK;
  END CATCH ;
  SET @i = @i + 1;
END;
SELECT 'Deadlocks caught: ', @DeadlockCount ;

Asegúrese de comenzar ambos en un par de segundos.

Usando sp_getapplock para eliminar puntos muertos

Modifique ambos procedimientos, vuelva a ejecutar el ciclo y vea que ya no tiene puntos muertos:

ALTER PROCEDURE dbo.UpdateCounter1 @Key1 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
SET @Key1=@Key1-10000;
UPDATE dbo.DeadlockTest SET Counter1=Counter1+1 WHERE Key1=@Key1;
COMMIT;
GO

ALTER PROCEDURE dbo.UpdateCounter2 @Key2 INT
AS
SET NOCOUNT ON ;
SET XACT_ABORT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ;
EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';
SET @Key2=@Key2-10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
SET @Key2=@Key2+10000;
UPDATE dbo.DeadlockTest SET Counter2=Counter2+1 WHERE Key2=@Key2;
COMMIT;
GO

Usando una tabla con una fila para eliminar puntos muertos

En lugar de invocar sp_getapplock, podemos modificar la siguiente tabla:

CREATE TABLE dbo.DeadlockTestMutex(
ID INT NOT NULL,
CONSTRAINT PK_DeadlockTestMutex PRIMARY KEY(ID),
Toggle INT NOT NULL);
GO

INSERT INTO dbo.DeadlockTestMutex(ID, Toggle)
VALUES(1,0);

Una vez que tenemos esta tabla creada y poblada, podemos reemplazar la siguiente línea

EXEC sp_getapplock @Resource='DeadlockTest', @LockMode='Exclusive';

con este, en ambos procedimientos:

UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;

Puede volver a ejecutar la prueba de esfuerzo y comprobar por sí mismo que no tenemos puntos muertos.

Conclusión

Como hemos visto, sp_getapplock se puede usar para serializar el acceso a otros recursos. Como tal, puede usarse para eliminar puntos muertos.

Por supuesto, esto puede ralentizar significativamente las modificaciones. Para abordar eso, debemos elegir la granularidad correcta para el bloqueo exclusivo y, siempre que sea posible, trabajar con conjuntos en lugar de filas individuales.

Antes de usar este enfoque, debe probarlo usted mismo. Primero, debe asegurarse de obtener al menos un par de docenas de puntos muertos con su enfoque original. En segundo lugar, no debería tener puntos muertos cuando vuelva a ejecutar el mismo script de repro usando el procedimiento almacenado modificado.

En general, no creo que haya una buena manera de determinar si su T-SQL está a salvo de puntos muertos simplemente mirándolo o mirando el plan de ejecución. En mi opinión, la única forma de determinar si su código es propenso a puntos muertos es exponerlo a una alta concurrencia.

¡Buena suerte con la eliminación de puntos muertos! No tenemos ningún punto muerto en nuestro sistema, lo cual es excelente para nuestro equilibrio entre el trabajo y la vida.

Alaska
fuente
2
+1 como sp_getapplock es una herramienta útil que no se conoce bien. Dado un desastre horrible que puede tomar tiempo para separar, es un truco útil para serializar un proceso que está en un punto muerto. Pero, ¿debería ser la primera opción para un caso como este que se entienda fácilmente y pueda (quizás debería) tratarse con mecanismos de bloqueo estándar?
Mark Storey-Smith
2
@ MarkStorey-Smith Es mi primera opción porque lo investigué y probé el estrés solo una vez, y puedo reutilizarlo en cualquier situación: la serialización ya ha sucedido, por lo que todo lo que sucede después de sp_getapplock no afecta el resultado. Con los mecanismos de bloqueo estándar, nunca puedo estar tan seguro: agregar un índice o simplemente obtener otro plan de ejecución puede causar puntos muertos donde antes no había ninguno. Pregúntame cómo lo sé.
AK
Supongo que me falta algo obvio, pero ¿cómo el uso UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;previene los puntos muertos?
Dale K
9

El uso de la XLOCKsugerencia en su SELECTenfoque o en el siguiente UPDATEdebe ser inmune a este tipo de punto muerto:

DECLARE @Output TABLE ([NewId] INT);
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

BEGIN TRANSACTION;

UPDATE
    dbo.tblIDs WITH (XLOCK)
SET 
    LastID = LastID + 1
OUTPUT
    INSERTED.[LastId] INTO @Output
WHERE
    IDName = @IDName;

IF(@@ROWCOUNT = 1)
BEGIN
    SELECT @NewId = [NewId] FROM @Output;
END
ELSE
BEGIN
    SET @NewId = 1;

    INSERT dbo.tblIDs
        (IDName, LastID)
    VALUES
        (@IDName, @NewId);
END

SELECT [NewId] = @NewId ;

COMMIT TRANSACTION;

Volverá con un par de otras variantes (¡si no lo superas!).

Mark Storey-Smith
fuente
Si bien XLOCKevitará que un contador existente se actualice desde varias conexiones, ¿no necesitaría un TABLOCKXpara evitar que varias conexiones agreguen el mismo contador nuevo?
Dale K
1
@DaleBurrell No, tendría PK o restricción única en IDName.
Mark Storey-Smith
7

Mike Defehr me mostró una forma elegante de lograr esto de una manera muy ligera:

ALTER PROCEDURE [dbo].[GetNextID](
    @IDName nvarchar(255)
)
AS
BEGIN
    /*
        Description:    Increments and returns the LastID value from tblIDs for a given IDName
        Author:         Max Vernon / Mike Defehr
        Date:           2012-07-19

    */

    DECLARE @Retry int;
    DECLARE @EN int, @ES int, @ET int;
    SET @Retry = 5;
    DECLARE @NewID int;
    SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
    SET NOCOUNT ON;
    WHILE @Retry > 0
    BEGIN
        BEGIN TRY
            UPDATE dbo.tblIDs 
            SET @NewID = LastID = LastID + 1 
            WHERE IDName = @IDName;

            IF @NewID IS NULL
            BEGIN
                SET @NewID = 1;
                INSERT INTO tblIDs (IDName, LastID) VALUES (@IDName, @NewID);
            END
            SET @Retry = -2; /* no need to retry since the operation completed */
        END TRY
        BEGIN CATCH
            IF (ERROR_NUMBER() = 1205) /* DEADLOCK */
                SET @Retry = @Retry - 1;
            ELSE
                BEGIN
                SET @Retry = -1;
                SET @EN = ERROR_NUMBER();
                SET @ES = ERROR_SEVERITY();
                SET @ET = ERROR_STATE()
                RAISERROR (@EN,@ES,@ET);
                END
        END CATCH
    END
    IF @Retry = 0 /* must have deadlock'd 5 times. */
    BEGIN
        SET @EN = 1205;
        SET @ES = 13;
        SET @ET = 1
        RAISERROR (@EN,@ES,@ET);
    END
    ELSE
        SELECT @NewID AS NewID;
END
GO

(Para completar, aquí está la tabla asociada con el proceso almacenado)

CREATE TABLE [dbo].[tblIDs]
(
    IDName nvarchar(255) NOT NULL,
    LastID int NULL,
    CONSTRAINT [PK_tblIDs] PRIMARY KEY CLUSTERED 
    (
        [IDName] ASC
    ) WITH 
    (
        PAD_INDEX = OFF
        , STATISTICS_NORECOMPUTE = OFF
        , IGNORE_DUP_KEY = OFF
        , ALLOW_ROW_LOCKS = ON
        , ALLOW_PAGE_LOCKS = ON
        , FILLFACTOR = 100
    ) 
);
GO

Este es el plan de ejecución para la última versión:

ingrese la descripción de la imagen aquí

Y este es el plan de ejecución para la versión original (punto muerto susceptible):

ingrese la descripción de la imagen aquí

Claramente, ¡la nueva versión gana!

A modo de comparación, la versión intermedia con el (XLOCK)etc, produce el siguiente plan:

ingrese la descripción de la imagen aquí

Yo diría que es una victoria! Gracias por la ayuda de todos!

Max Vernon
fuente
2
De hecho, debería funcionar, pero está utilizando SERIALIZABLE donde no es aplicable. Las filas fantasmas no pueden existir aquí, entonces, ¿por qué usar un nivel de aislamiento que existe para evitarlas? Además, si alguien llama a su procedimiento desde otro o desde una conexión donde se inició una transacción externa, cualquier otra acción que inicie continuará en SERIALIZABLE. Eso puede ser complicado.
Mark Storey-Smith
2
SERIALIZABLEno existe para prevenir fantasmas. Existe para proporcionar una semántica de aislamiento serializable , es decir, el mismo efecto persistente en la base de datos que si las transacciones involucradas se hubieran ejecutado en serie en un orden no especificado.
Paul White dice GoFundMonica
6

No para robar el trueno de Mark Storey-Smith, pero él está en algo con su publicación anterior (que por cierto ha recibido la mayor cantidad de votos). El consejo que le di a Max se centró en la construcción "UPDATE set @variable = column = column + value", que considero realmente genial, pero creo que puede estar indocumentado (aunque debe ser compatible, porque está allí específicamente para el TCP puntos de referencia).

Aquí hay una variación de la respuesta de Mark: debido a que está devolviendo el nuevo valor de ID como un conjunto de registros, puede eliminar por completo la variable escalar, tampoco debería ser necesaria una transacción explícita, y estoy de acuerdo en que es innecesario jugar con los niveles de aislamiento. también. El resultado es muy limpio y bastante resbaladizo ...

ALTER PROC [dbo].[GetNextID]
  @IDName nvarchar(255)
  AS
BEGIN
SET NOCOUNT ON;

DECLARE @Output TABLE ([NewID] INT);

UPDATE dbo.tblIDs SET LastID = LastID + 1
OUTPUT inserted.[LastId] INTO @Output
WHERE IDName = @IDName;

IF(@@ROWCOUNT = 1)
    SELECT [NewID] FROM @Output;
ELSE
    INSERT dbo.tblIDs (IDName, LastID)
    OUTPUT INSERTED.LastID AS [NewID]
    VALUES (@IDName,1);
END
Mike DeFehr
fuente
3
Convino en que esto debería ser inmune al estancamiento, pero es propenso a una condición de carrera en el inserto, si omite la transacción.
Mark Storey-Smith
4

Arreglé un punto muerto similar en un sistema el año pasado al cambiar esto:

IF (SELECT COUNT(IDName) FROM tblIDs WHERE IDName = @IDName) = 0 
  INSERT INTO tblIDs (IDName, LastID) VALUES (@IDName, @NewID)
ELSE
  UPDATE tblIDs SET LastID = @NewID WHERE IDName = @IDName;

A esto:

UPDATE tblIDs SET LastID = @NewID WHERE IDName = @IDName;
IF @@ROWCOUNT = 0
BEGIN
  INSERT ...
END

En general, seleccionar un COUNTjusto para determinar la presencia o ausencia es un desperdicio. En este caso, ya que es 0 o 1, no es como si fuera mucho trabajo, pero (a) ese hábito puede desangrarse en otros casos donde será mucho más costoso (en esos casos, use en IF NOT EXISTSlugar de IF COUNT() = 0), y (b) El escaneo adicional es completamente innecesario. Los UPDATEesencialmente realiza la misma comprobación.

Además, esto me parece un olor a código serio:

SET @NewID = COALESCE((SELECT LastID FROM tblIDs WHERE IDName = @IDName),0)+1;

¿Cuál es el punto aquí? ¿Por qué no solo usar una columna de identidad o derivar esa secuencia usando ROW_NUMBER()en el momento de la consulta?

Aaron Bertrand
fuente
La mayoría de las tablas que tenemos están usando un IDENTITY. Esta tabla admite algunos códigos heredados escritos en MS Access que estarían bastante involucrados en la actualización. La SET @NewID=línea simplemente incrementa el valor almacenado en la tabla para la ID dada (pero ya lo sabe). ¿Puedes ampliar cómo podría usar ROW_NUMBER()?
Max Vernon
@MaxVernon no sin saber lo que LastIDrealmente significa en su modelo. ¿Cual es su propósito? El nombre no se explica por sí mismo. ¿Cómo lo usa Access?
Aaron Bertrand
Una función en Access quiere agregar una fila a cualquier tabla dada que no tenga una IDENTIDAD. First Access llama GetNextID('WhatevertheIDFieldIsCalled')para obtener la siguiente ID para usar, luego la inserta en la nueva fila junto con los datos necesarios.
Max Vernon
Implementaré tu cambio. ¡Un caso puro de "menos es más"!
Max Vernon
1
Su punto muerto fijo puede resurgir. Su segundo patrón también es vulnerable: sqlblog.com/blogs/alexander_kuznetsov/archive/2010/01/12/… Para eliminar los puntos muertos, usaría sp_getapplock. El sistema de carga mixta de mayo con cientos de usuarios no tiene puntos muertos.
AK