Tengo una tabla que es utilizada por una aplicación heredada como un sustituto de los IDENTITY
campos en varias otras tablas.
Cada fila de la tabla almacena la última ID utilizada LastID
para 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 IDName
campo:
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 TRANSACTION
a 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.
fuente
SERIALIZABLE
aquí.Respuestas:
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:
Es probable que los dos procedimientos siguientes se incluyan en un punto muerto:
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;
En otra pestaña, ejecute este script.
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:
Usando una tabla con una fila para eliminar puntos muertos
En lugar de invocar sp_getapplock, podemos modificar la siguiente tabla:
Una vez que tenemos esta tabla creada y poblada, podemos reemplazar la siguiente línea
con este, en ambos procedimientos:
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.
fuente
UPDATE dbo.DeadlockTestMutex SET Toggle = 1 - Toggle WHERE ID = 1;
previene los puntos muertos?El uso de la
XLOCK
sugerencia en suSELECT
enfoque o en el siguienteUPDATE
debe ser inmune a este tipo de punto muerto:Volverá con un par de otras variantes (¡si no lo superas!).
fuente
XLOCK
evitará que un contador existente se actualice desde varias conexiones, ¿no necesitaría unTABLOCKX
para evitar que varias conexiones agreguen el mismo contador nuevo?Mike Defehr me mostró una forma elegante de lograr esto de una manera muy ligera:
(Para completar, aquí está la tabla asociada con el proceso almacenado)
Este es el plan de ejecución para la última versión:
Y este es el plan de ejecución para la versión original (punto muerto susceptible):
Claramente, ¡la nueva versión gana!
A modo de comparación, la versión intermedia con el
(XLOCK)
etc, produce el siguiente plan:Yo diría que es una victoria! Gracias por la ayuda de todos!
fuente
SERIALIZABLE
no 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.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 ...
fuente
Arreglé un punto muerto similar en un sistema el año pasado al cambiar esto:
A esto:
En general, seleccionar un
COUNT
justo 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 enIF NOT EXISTS
lugar deIF COUNT() = 0
), y (b) El escaneo adicional es completamente innecesario. LosUPDATE
esencialmente realiza la misma comprobación.Además, esto me parece un olor a código serio:
¿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?fuente
IDENTITY
. Esta tabla admite algunos códigos heredados escritos en MS Access que estarían bastante involucrados en la actualización. LaSET @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 usarROW_NUMBER()
?LastID
realmente significa en su modelo. ¿Cual es su propósito? El nombre no se explica por sí mismo. ¿Cómo lo usa Access?GetNextID('WhatevertheIDFieldIsCalled')
para obtener la siguiente ID para usar, luego la inserta en la nueva fila junto con los datos necesarios.