Tabla de cola FIFO para varios trabajadores en SQL Server

15

Intentaba responder la siguiente pregunta de stackoverflow:

Después de publicar una respuesta un tanto ingenua, pensé que pondría mi dinero donde estaba mi boca y realmente probaría el escenario que estaba sugiriendo, para asegurarme de que no estaba enviando el OP en una loca búsqueda. Bueno, resultó ser mucho más difícil de lo que pensaba (no hay sorpresa para nadie, estoy seguro).

Esto es lo que he probado y pensado:

  • Primero probé una ACTUALIZACIÓN TOP 1 con un ORDER BY dentro de una tabla derivada, usando ROWLOCK, READPAST. Esto produjo puntos muertos y también procesó artículos fuera de servicio. Debe estar lo más cerca posible de FIFO, salvo errores que requieran intentar procesar la misma fila más de una vez.

  • Luego trató de selección del próximo QueueID deseada en una variable, usando varias combinaciones de READPAST, UPDLOCK, HOLDLOCK, y ROWLOCKpara preservar la fila exclusivamente para la actualización de esa sesión. Todas las variaciones que probé sufrieron los mismos problemas que antes, así como, para ciertas combinaciones con READPAST, quejarse:

    Solo puede especificar el bloqueo READPAST en los niveles de aislamiento READ COMMITTED o REPEATABLE READ.

    Esto fue confuso porque fue LEÍDO COMPROMETIDO. Me he encontrado con esto antes y es frustrante.

  • Desde que comencé a escribir esta pregunta, Remus Rusani publicó una nueva respuesta a la pregunta. Leí su artículo vinculado y veo que está usando lecturas destructivas, ya que dijo en su respuesta que "no es realmente posible mantener las cerraduras durante la duración de las llamadas web". Después de leer lo que dice su artículo sobre los puntos críticos y las páginas que requieren bloqueo para actualizar o eliminar, me temo que incluso si pudiera encontrar los bloqueos correctos para hacer lo que estoy buscando, no sería escalable y podría No manejar la concurrencia masiva.

En este momento no estoy seguro de dónde ir. ¿Es cierto que no se puede mantener los bloqueos mientras se procesa la fila (incluso si no admitía tps altos o concurrencia masiva)? ¿Qué me estoy perdiendo?

Con la esperanza de que las personas más inteligentes que yo y las personas con más experiencia que yo puedan ayudar, a continuación se muestra el script de prueba que estaba usando. Volvió al método de ACTUALIZACIÓN TOP 1, pero dejé el otro método en, comentado, en caso de que quiera explorar eso también.

Pegue cada uno de estos en una sesión separada, ejecute la sesión 1, luego rápidamente todos los demás. En unos 50 segundos la prueba habrá terminado. Mire los Mensajes de cada sesión para ver qué trabajo hizo (o cómo falló). La primera sesión mostrará un conjunto de filas con una instantánea tomada una vez por segundo que detalla los bloqueos presentes y los elementos de cola procesados. Funciona a veces, y otras veces no funciona en absoluto.

Sesión 1

/* Session 1: Setup and control - Run this session first, then immediately run all other sessions */
IF Object_ID('dbo.Queue', 'U') IS NULL
   CREATE TABLE dbo.Queue (
      QueueID int identity(1,1) NOT NULL,
      StatusID int NOT NULL,
      QueuedDate datetime CONSTRAINT DF_Queue_QueuedDate DEFAULT (GetDate()),
      CONSTRAINT PK_Queue PRIMARY KEY CLUSTERED (QueuedDate, QueueID)
   );

IF Object_ID('dbo.QueueHistory', 'U') IS NULL
   CREATE TABLE dbo.QueueHistory (
      HistoryDate datetime NOT NULL,
      QueueID int NOT NULL
   );

IF Object_ID('dbo.LockHistory', 'U') IS NULL
   CREATE TABLE dbo.LockHistory (
      HistoryDate datetime NOT NULL,
      ResourceType varchar(100),
      RequestMode varchar(100),
      RequestStatus varchar(100),
      ResourceDescription varchar(200),
      ResourceAssociatedEntityID varchar(200)
   );

IF Object_ID('dbo.StartTime', 'U') IS NULL
   CREATE TABLE dbo.StartTime (
      StartTime datetime NOT NULL
   );

SET NOCOUNT ON;

IF (SELECT Count(*) FROM dbo.Queue) < 10000 BEGIN
   TRUNCATE TABLE dbo.Queue;

   WITH A (N) AS (SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1),
   B (N) AS (SELECT 1 FROM A Z, A I, A P),
   C (N) AS (SELECT Row_Number() OVER (ORDER BY (SELECT 1)) FROM B O, B W)
   INSERT dbo.Queue (StatusID, QueuedDate)
   SELECT 1, DateAdd(millisecond, C.N * 3, GetDate() - '00:05:00')
   FROM C
   WHERE C.N <= 10000;
END;

TRUNCATE TABLE dbo.StartTime;
INSERT dbo.StartTime SELECT GetDate() + '00:00:15'; -- or however long it takes you to go run the other sessions
GO
TRUNCATE TABLE dbo.QueueHistory;
SET NOCOUNT ON;

DECLARE
   @Time varchar(8),
   @Now datetime;
SELECT @Time = Convert(varchar(8), StartTime, 114)
FROM dbo.StartTime;
WAITFOR TIME @Time;

DECLARE @i int,
@QueueID int;
SET @i = 1;
WHILE @i <= 33 BEGIN
   SET @Now  = GetDate();
   INSERT dbo.QueueHistory
   SELECT
      @Now,
      QueueID
   FROM
      dbo.Queue Q WITH (NOLOCK)
   WHERE
      Q.StatusID <> 1;

   INSERT dbo.LockHistory
   SELECT
      @Now,
      L.resource_type,
      L.request_mode,
      L.request_status,
      L.resource_description,
      L.resource_associated_entity_id
   FROM
      sys.dm_tran_current_transaction T
      INNER JOIN sys.dm_tran_locks L
         ON L.request_owner_id = T.transaction_id;
   WAITFOR DELAY '00:00:01';
   SET @i = @i + 1;
END;

WITH Cols AS (
   SELECT *, Row_Number() OVER (PARTITION BY HistoryDate ORDER BY QueueID) Col
   FROM dbo.QueueHistory
), P AS (
   SELECT *
   FROM
      Cols
      PIVOT (Max(QueueID) FOR Col IN ([1], [2], [3], [4], [5], [6], [7], [8])) P
)
SELECT L.*, P.[1], P.[2], P.[3], P.[4], P.[5], P.[6], P.[7], P.[8]
FROM
   dbo.LockHistory L
   FULL JOIN P
      ON L.HistoryDate = P.HistoryDate

/* Clean up afterward
DROP TABLE dbo.StartTime;
DROP TABLE dbo.LockHistory;
DROP TABLE dbo.QueueHistory;
DROP TABLE dbo.Queue;
*/

Sesión 2

/* Session 2: Simulate an application instance holding a row locked for a long period, and eventually abandoning it. */
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET NOCOUNT ON;
SET XACT_ABORT ON;

DECLARE
   @QueueID int,
   @Time varchar(8);
SELECT @Time = Convert(varchar(8), StartTime + '0:00:01', 114)
FROM dbo.StartTime;
WAITFOR TIME @Time;
BEGIN TRAN;

--SET @QueueID = (
--   SELECT TOP 1 QueueID
--   FROM dbo.Queue WITH (READPAST, UPDLOCK)
--   WHERE StatusID = 1 -- ready
--   ORDER BY QueuedDate, QueueID
--);

--UPDATE dbo.Queue
--SET StatusID = 2 -- in process
----OUTPUT Inserted.*
--WHERE QueueID = @QueueID;

SET @QueueID = NULL;
UPDATE Q
SET Q.StatusID = 1, @QueueID = Q.QueueID
FROM (
   SELECT TOP 1 *
   FROM dbo.Queue WITH (ROWLOCK, READPAST)
   WHERE StatusID = 1
   ORDER BY QueuedDate, QueueID
) Q

PRINT @QueueID;

WAITFOR DELAY '00:00:20'; -- Release it partway through the test

ROLLBACK TRAN; -- Simulate client disconnecting

Sesión 3

/* Session 3: Run a near-continuous series of "failed" queue processing. */
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET XACT_ABORT ON;
SET NOCOUNT ON;
DECLARE
   @QueueID int,
   @EndDate datetime,
   @NextDate datetime,
   @Time varchar(8);

SELECT
   @EndDate = StartTime + '0:00:33',
   @Time = Convert(varchar(8), StartTime, 114)
FROM dbo.StartTime;

WAITFOR TIME @Time;

WHILE GetDate() < @EndDate BEGIN
   BEGIN TRAN;

   --SET @QueueID = (
   --   SELECT TOP 1 QueueID
   --   FROM dbo.Queue WITH (READPAST, UPDLOCK)
   --   WHERE StatusID = 1 -- ready
   --   ORDER BY QueuedDate, QueueID
   --);

   --UPDATE dbo.Queue
   --SET StatusID = 2 -- in process
   ----OUTPUT Inserted.*
   --WHERE QueueID = @QueueID;

   SET @QueueID = NULL;
   UPDATE Q
   SET Q.StatusID = 1, @QueueID = Q.QueueID
   FROM (
      SELECT TOP 1 *
      FROM dbo.Queue WITH (ROWLOCK, READPAST)
      WHERE StatusID = 1
      ORDER BY QueuedDate, QueueID
   ) Q

   PRINT @QueueID;

   SET @NextDate = GetDate() + '00:00:00.015';
   WHILE GetDate() < @NextDate SET NOCOUNT ON;
   ROLLBACK TRAN;
END

Sesión 4 y superior: tantas como quieras

/* Session 4: "Process" the queue normally, one every second for 30 seconds. */
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
SET XACT_ABORT ON;
SET NOCOUNT ON;

DECLARE @Time varchar(8);
SELECT @Time = Convert(varchar(8), StartTime, 114)
FROM dbo.StartTime;
WAITFOR TIME @Time;

DECLARE @i int,
@QueueID int;
SET @i = 1;
WHILE @i <= 30 BEGIN
   BEGIN TRAN;

   --SET @QueueID = (
   --   SELECT TOP 1 QueueID
   --   FROM dbo.Queue WITH (READPAST, UPDLOCK)
   --   WHERE StatusID = 1 -- ready
   --   ORDER BY QueuedDate, QueueID
   --);

   --UPDATE dbo.Queue
   --SET StatusID = 2 -- in process
   --WHERE QueueID = @QueueID;

   SET @QueueID = NULL;
   UPDATE Q
   SET Q.StatusID = 1, @QueueID = Q.QueueID
   FROM (
      SELECT TOP 1 *
      FROM dbo.Queue WITH (ROWLOCK, READPAST)
      WHERE StatusID = 1
      ORDER BY QueuedDate, QueueID
   ) Q

   PRINT @QueueID;
   WAITFOR DELAY '00:00:01'
   SET @i = @i + 1;
   DELETE dbo.Queue
   WHERE QueueID = @QueueID;   
   COMMIT TRAN;
END
ErikE
fuente
2
Las colas como se describe en el artículo vinculado pueden escalar a cientos o menos miles de operaciones por segundo. Los problemas de contención de puntos críticos solo son relevantes a mayor escala. Existen estrategias de mitigación conocidas que pueden lograr un mayor rendimiento en el sistema de gama alta, llegando a decenas de miles por segundo, pero esas mitigaciones necesitan una evaluación cuidadosa y se implementan bajo la supervisión de SQLCAT .
Remus Rusanu
Una arruga interesante es que con READPAST, UPDLOCK, ROWLOCKmi script para capturar datos en la tabla QueueHistory no está haciendo nada. Me pregunto si eso se debe a que el StatusID no está comprometido. Está usando WITH (NOLOCK)tan teóricamente debería funcionar ... ¡y funcionó antes! No estoy seguro de por qué no funciona ahora, pero probablemente sea otra experiencia de aprendizaje.
ErikE
¿Podría reducir su código a la muestra más pequeña que exhibe el interbloqueo y otros problemas que está tratando de resolver?
Nick Chammas
@ Nick Intentaré reducir el código. Sobre sus otros comentarios, hay una columna de identidad que es parte del índice agrupado y ordenada después de la fecha. Estoy bastante dispuesto a entretener una "lectura destructiva" (BORRAR con SALIDA) pero uno de los requisitos solicitados era, en el caso de que fallara una instancia de la aplicación, que la fila volviera al procesamiento automáticamente. Entonces mi pregunta aquí es si eso es posible.
ErikE
Pruebe el enfoque de lectura destructiva y coloque los elementos retirados en una tabla separada de donde se pueden volver a colocar en cola si fuera necesario. Si eso lo soluciona, puede invertir en hacer que este proceso de reenlace funcione sin problemas.
Nick Chammas

Respuestas:

10

Necesitas exactamente 3 pistas de bloqueo

  • READPAST
  • UPDLOCK
  • CHUMACERA

Respondí esto anteriormente en SO: /programming/939831/sql-server-process-queue-race-condition/940001#940001

Como dice Remus, usar Service Broker es mejor, pero estas sugerencias funcionan

Su error sobre el nivel de aislamiento generalmente significa replicación o NOLOCK está involucrado.

gbn
fuente
El uso de esas sugerencias en mi script como se indica arriba produce puntos muertos y procesos fuera de servicio. ( UPDATE SET ... FROM (SELECT TOP 1 ... FROM ... ORDER BY ...)) ¿Significa esto que mi patrón ACTUALIZAR con mantener un bloqueo no puede funcionar? Además, el momento en que se combina READPASTcon HOLDLOCKusted obtiene el error. No hay replicación en este servidor y el nivel de aislamiento es LEÍDO COMPROMETIDO.
ErikE
2
@ErikE: tan importante como la forma en que consulta la tabla es cómo está estructurada. La tabla que está utilizando como cola debe estar agrupada en el orden de la cola de modo que el siguiente elemento que se va a poner en cola no sea ambiguo . Esto es critico. Ojeando su código anterior, no veo ningún índice agrupado definido.
Nick Chammas
@Nick que tiene mucho sentido eminente y no sé por qué no lo pensé. Agregué la restricción PK adecuada (y actualicé mi script anterior), y todavía obtuve puntos muertos. Sin embargo, los artículos ahora se procesaron en el orden correcto, salvo el procesamiento repetido de los artículos bloqueados.
ErikE
@ErikE - 1. Su cola solo debe contener elementos en cola. Dequeuing y item deben significar eliminarlo de la tabla de colas. Veo que, en cambio, está actualizando StatusIDpara eliminar un elemento. ¿Es eso correcto? 2. Su orden de retiro debe ser inequívoca. Si está haciendo cola en los artículos GETDATE(), entonces, a grandes volúmenes, es muy probable que varios artículos sean igualmente elegibles para retirarse al mismo tiempo. Esto conducirá a puntos muertos. Sugiero agregar un IDENTITYal índice agrupado para garantizar un orden de eliminación inequívoco.
Nick Chammas
1

El servidor SQL funciona muy bien para almacenar datos relacionales. En cuanto a una cola de trabajo, no es tan genial. Vea este artículo que está escrito para MySQL pero también puede aplicarse aquí. https://blog.engineyard.com/2011/5-subtle-ways-youre-using-mysql-as-a-queue-and-why-itll-bite-you

Eric Humphrey - lotsahelp
fuente
Gracias Eric En mi respuesta original a la pregunta, sugería usar SQL Server Service Broker porque sé con certeza que el método de tabla como cola no es realmente para lo que se hizo la base de datos. Pero creo que ya no es una buena recomendación porque SB es realmente solo para mensajes. Las propiedades ACID de los datos colocados en la base de datos lo convierten en un contenedor muy atractivo para intentar (ab) usar. ¿Puede sugerir un producto alternativo y de bajo costo que funcione bien como una cola genérica? ¿Y se puede hacer una copia de seguridad, etc., etc.?
ErikE
8
El artículo es culpable de una falacia conocida en el procesamiento de colas: combine el estado y los eventos en una sola tabla (en realidad, si mira los comentarios del artículo, verá que había objetado esto hace algún tiempo). El síntoma típico de este problema es el campo 'procesado / procesado'. La combinación del estado con los eventos (es decir, hacer que la tabla de estado sea la 'cola') da como resultado que la 'cola' crezca a tamaños enormes (ya que la tabla de estado es la cola). La separación de eventos en una cola real conduce a una cola que 'drena' (se vacía) y esto se comporta mucho mejor.
Remus Rusanu
¿El artículo no sugiere exactamente eso: la tabla de cola tiene SOLO elementos listos para trabajar?
ErikE
2
@ErikE: te estás refiriendo a este párrafo, ¿verdad? También es muy fácil evitar el síndrome de una mesa grande. Simplemente cree una tabla separada para los nuevos correos electrónicos, y cuando haya terminado de procesarlos, INSÉRTESE en el almacenamiento a largo plazo y luego BORRElos de la tabla de la cola. La tabla de correos electrónicos nuevos generalmente será muy pequeña y las operaciones serán rápidas . Mi disputa con esto es que se da como una solución para el problema de las "grandes colas". Esta recomendación debería haber estado en la apertura del artículo, es un tema fundamental .
Remus Rusanu
Si comienzas a pensar en una clara separación de estado y evento, entonces comienzas vdown un camino mucho más fácil. Incluso la recomendación anterior cambiaría para insertar nuevos correos electrónicos en la emailstabla y en la new_emailscola. El procesamiento sondea la new_emailscola y actualiza el estado en la emailstabla . Esto también evita el problema del estado 'gordo' viajando en colas. Si hablamos sobre el procesamiento distribuido y las colas verdaderas , con la comunicación (por ejemplo, SSB), entonces las cosas se vuelven más complicadas ya que el estado compartido es problemático en los sistemas con problemas.
Remus Rusanu