Tabella delle code FIFO per più lavoratori in SQL Server


15

Stavo tentando di rispondere alla seguente domanda di StackOverflow:

Dopo aver postato una risposta un po 'ingenua, ho pensato di mettere i miei soldi dove era la mia bocca e di testare effettivamente lo scenario che stavo suggerendo, per essere sicuro di non aver inviato l'OP in una caccia all'oca selvatica. Bene, si è rivelato molto più difficile di quanto pensassi (nessuna sorpresa per nessuno, ne sono sicuro).

Ecco cosa ho provato e pensato:

  • Per prima cosa ho provato un TOP 1 UPDATE con un ORDER BY all'interno di una tabella derivata, usando ROWLOCK, READPAST. Ciò ha comportato deadlock e inoltre ha elaborato gli articoli fuori servizio. Deve essere il più vicino possibile a FIFO, salvo errori che richiedono il tentativo di elaborare la stessa riga più di una volta.

  • Ho poi provato selezionando il successivo QueueID desiderato in una variabile, utilizzando diverse combinazioni di READPAST, UPDLOCK, HOLDLOCKe ROWLOCKconservare esclusivamente la riga per l'aggiornamento da quella sessione. Tutte le varianti che ho provato soffrivano degli stessi problemi di prima e, per alcune combinazioni con READPAST, lamentarsi:

    È possibile specificare il blocco READPAST solo nei livelli di isolamento READ COMMITTED o REPEATABLE READ.

    Questo era confuso perché era LEGATO IMPEGNATO. L'ho già incontrato prima ed è frustrante.

  • Da quando ho iniziato a scrivere questa domanda, Remus Rusani ha pubblicato una nuova risposta alla domanda. Ho letto il suo articolo collegato e vedo che sta usando letture distruttive, dal momento che ha risposto nella sua risposta che "non è realisticamente possibile aggrapparsi ai blocchi per la durata delle chiamate web". Dopo aver letto ciò che dice il suo articolo in merito a hot spot e pagine che richiedono il blocco per eseguire aggiornamenti o eliminazioni, temo che anche se fossi in grado di elaborare i blocchi corretti per fare ciò che sto cercando, non sarebbe scalabile e potrebbe non gestire una concorrenza massiccia.

In questo momento non sono sicuro dove andare. È vero che il mantenimento dei blocchi mentre la riga viene elaborata non può essere raggiunto (anche se non supporta tps elevati o concorrenza elevata)? Cosa mi sto perdendo?

Nella speranza che le persone più intelligenti di me e le persone più esperte di me possano dare una mano, di seguito è lo script di test che stavo usando. È tornato al metodo TOP 1 UPDATE ma ho lasciato l'altro metodo, commentato, nel caso volessi esplorare anche quello.

Incollare ciascuno di questi in una sessione separata, eseguire la sessione 1, quindi rapidamente tutti gli altri. In circa 50 secondi il test sarà terminato. Guarda i Messaggi di ogni sessione per vedere che lavoro ha fatto (o come ha fallito). La prima sessione mostrerà un set di righe con uno snapshot eseguito una volta al secondo in dettaglio i blocchi presenti e gli elementi della coda in fase di elaborazione. A volte funziona e altre volte non funziona affatto.

Sessione 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;
*/

Sessione 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

Sessione 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

Sessione 4 e successive: quante ne vuoi

/* 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

2
Le code come descritto nell'articolo collegato possono ridimensionare a centinaia o meno migliaia di operazioni al secondo. I problemi di contesa hot spot sono rilevanti solo su scala più elevata. Esistono strategie di mitigazione note che possono raggiungere un throughput più elevato sul sistema di fascia alta, andando a decine di migliaia al secondo, ma tali mitigazioni richiedono un'attenta valutazione e sono implementate sotto la supervisione di SQLCAT .
Remus Rusanu,

Una ruga interessante è che con il READPAST, UPDLOCK, ROWLOCKmio script per acquisire dati nella tabella QueueHistory non si fa nulla. Mi chiedo se questo perché StatusID non è stato eseguito il commit? Sta usando WITH (NOLOCK)così teoricamente dovrebbe funzionare ... e ha funzionato prima! Non sono sicuro del perché non funzioni ora, ma probabilmente è un'altra esperienza di apprendimento.
ErikE

Potresti ridurre il codice all'esempio più piccolo che presenta il deadlock e altri problemi che stai cercando di risolvere?
Nick Chammas,

@Nick proverò a ridurre il codice. A proposito degli altri tuoi commenti, c'è una colonna di identità che fa parte dell'indice cluster e ordinata dopo la data. Sono abbastanza disposto a intrattenere una "lettura distruttiva" (ELIMINA con OUTPUT) ma uno dei requisiti richiesti era, nel caso di un'istanza dell'applicazione non riuscita, che la riga tornasse automaticamente all'elaborazione. Quindi la mia domanda qui è se questo è possibile.
ErikE,

Prova l'approccio di lettura distruttiva e posiziona gli oggetti dequeered in una tabella separata da dove possono essere nuovamente reinquadrati, se necessario. Se ciò lo risolve, puoi investire per far funzionare senza problemi questo processo di riaccodamento.
Nick Chammas,

Risposte:


10

Sono necessari esattamente 3 suggerimenti per il blocco

  • READPAST
  • UPDLOCK
  • SCALMO

Ho già risposto in precedenza su SO: /programming/939831/sql-server-process-queue-race-condition/940001#940001

Come dice Remus, l'uso del broker di servizi è migliore ma questi suggerimenti funzionano

Il tuo errore relativo al livello di isolamento di solito significa che è coinvolta la replica o NOLOCK.


L'uso di questi suggerimenti sulla mia sceneggiatura come indicato sopra produce deadlock e processi fuori servizio. ( UPDATE SET ... FROM (SELECT TOP 1 ... FROM ... ORDER BY ...)) Questo significa che il mio modello UPDATE con il blocco non può funzionare? Inoltre, nel momento in cui ti unisci READPASTa HOLDLOCKte ottieni l'errore. Non vi è alcuna replica su questo server e il livello di isolamento è LEGGI IMPEGNATO.
ErikE

2
@ErikE - Importante quanto la query della tabella è la struttura della tabella. La tabella che si sta utilizzando come coda deve essere raggruppata nell'ordine di dequeue in modo tale che l'elemento successivo da dequeued sia inequivocabile . Questo è fondamentale. Scorrendo il codice sopra, non vedo alcun indice cluster definito.
Nick Chammas,

@ Nick che ha perfettamente un senso eminente e non so perché non ci abbia pensato. Ho aggiunto il vincolo PK corretto (e ho aggiornato il mio script sopra) e ho ancora deadlock. Tuttavia, gli articoli sono stati ora elaborati nell'ordine corretto, salvo l'elaborazione ripetuta per gli articoli bloccati.
ErikE

@ErikE - 1. La coda deve contenere solo elementi in coda. Dequeuing e item dovrebbero significare eliminarlo dalla tabella delle code. Vedo che stai invece aggiornando StatusIDper dequeue un elemento. È corretto? 2. Il tuo ordine di dequeue deve essere inequivocabile. Se stai accodando gli articoli per GETDATE(), quindi a volumi elevati è molto probabile che più elementi siano ugualmente idonei per il dequeuing allo stesso tempo. Questo porterà a deadlock. Suggerisco di aggiungere un IDENTITYindice cluster per garantire un ordine di dequeue inequivocabile.
Nick Chammas,

1

Il server SQL funziona perfettamente per l'archiviazione dei dati relazionali. Per quanto riguarda una coda di lavoro, non è così eccezionale. Vedi questo articolo scritto per MySQL ma può essere applicato anche qui. https://blog.engineyard.com/2011/5-subtle-ways-youre-using-mysql-as-a-queue-and-why-itll-bite-you


Grazie Eric. Nella mia risposta originale alla domanda, stavo suggerendo di utilizzare SQL Server Service Broker perché so per certo che il metodo tabella come coda non è proprio quello per cui è stato creato il database. Ma penso che non sia più una buona raccomandazione perché SB è davvero solo per i messaggi. Le proprietà ACID dei dati inseriti nel database lo rendono un contenitore molto interessante da provare (ab) utilizzare. Puoi suggerire un prodotto alternativo ea basso costo che funzionerà bene come una coda generica? E può essere eseguito il backup, ecc. Ecc.?
ErikE

8
L'articolo è colpevole di un errore noto nell'elaborazione della coda: combina stato ed eventi in una singola tabella (in realtà se guardi i commenti dell'articolo vedrai che mi ero opposto qualche tempo fa). Il sintomo tipico di questo problema è il campo "elaborato / elaborato". Combinando lo stato con gli eventi (cioè rendendo la tabella di stato la "coda") si ottiene un aumento della "coda" a dimensioni enormi (poiché la tabella di stato è la coda). Separare gli eventi in una vera coda porta a una coda che "svuota" (si svuota) e questo si comporta molto meglio.
Remus Rusanu,

L'articolo non suggerisce esattamente questo: la tabella delle code ha SOLO elementi pronti per il lavoro.?
ErikE,

2
@ErikE: ti riferisci a questo paragrafo, giusto? è anche molto facile evitare la sindrome da tavolo unico. Basta creare una tabella separata per le nuove e-mail e quando hai finito di elaborarle, INSERISCI nella memoria a lungo termine e poi ELIMINA dalla tabella delle code. La tabella delle nuove e-mail rimarrà generalmente molto piccola e le operazioni su di essa saranno veloci . Il mio litigio con questo è che viene dato come soluzione alternativa al problema delle "grandi code". Questa raccomandazione avrebbe dovuto essere in apertura dell'articolo, è una questione fondamentale .
Remus Rusanu,

Se inizi a pensare in una netta separazione tra stato e evento, inizi a creare un percorso molto più semplice. Anche la raccomandazione di cui sopra cambierebbe in inserire nuove e-mail nella emailstabella e nella new_emailscoda. L'elaborazione esegue il polling della new_emailscoda e aggiorna lo stato nella emailstabella . Ciò evita anche il problema dello stato "grasso" che viaggia in coda. Se parliamo di elaborazione distribuita e code vere , con comunicazione (ad esempio SSB), le cose diventano più complicate poiché lo stato condiviso è problematico nei sistemi distirbitati.
Remus Rusanu,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.