web-dev-qa-db-fra.com

FIFO table de files d'attente pour plusieurs travailleurs dans SQL Server

J'essayais de répondre à la question de stackoverflow suivante:

Après avoir posté une réponse quelque peu naïve, je me suis dit que je mettrais mon argent là où ma bouche était et en fait test le scénario que je proposais, pour être sûr de ne pas envoyer l'OP sur un chasse aux oies sauvages. Eh bien, cela s'est avéré être beaucoup plus difficile que je ne le pensais (ce n'est une surprise pour personne, j'en suis sûr).

Voici ce que j'ai essayé et pensé:

  • J'ai d'abord essayé une TOP 1 UPDATE avec un ORDER BY dans une table dérivée, en utilisant ROWLOCK, READPAST. Cela a entraîné des blocages et a également traité des articles hors service. Il doit être aussi proche de FIFO que possible, à l'exception des erreurs qui nécessitent d'essayer de traiter la même ligne plusieurs fois.

  • J'ai ensuite essayé de sélectionner le QueueID souhaité suivant dans une variable, en utilisant diverses combinaisons de READPAST, UPDLOCK, HOLDLOCK et ROWLOCK pour conserver exclusivement la ligne pour mise à jour par cette session. Toutes les variantes que j'ai essayées ont souffert des mêmes problèmes qu'auparavant ainsi que, pour certaines combinaisons avec READPAST, se plaignant:

    Vous pouvez uniquement spécifier le verrou READPAST dans les niveaux d'isolement READ COMMITTED ou REPEATABLE READ.

    C'était déroutant car cela l'était LIRE ENGAGÉ. J'ai déjà rencontré ça et c'est frustrant.

  • Depuis que j'ai commencé à écrire cette question, Remus Rusani a posté une nouvelle réponse à la question. J'ai lu son article lié et je vois qu'il utilise des lectures destructrices, car il a dit dans sa réponse qu'il "n'était pas possible de conserver les verrous pendant la durée des appels Web". Après avoir lu ce que dit son article concernant les points chauds et les pages nécessitant un verrouillage pour effectuer une mise à jour ou une suppression, je crains que même si je pouvais déterminer les verrous corrects pour faire ce que je cherchais, il ne serait pas évolutif et pourrait pas gérer une concurrence massive.

Pour l'instant, je ne sais pas où aller. Est-il vrai que le maintien des verrous pendant le traitement de la ligne ne peut pas être réalisé (même s'il ne prend pas en charge des tps élevés ou une concurrence massive)? Qu'est-ce que je rate?

Dans l'espoir que des gens plus intelligents que moi et des gens plus expérimentés que moi puissent m'aider, voici le script de test que j'utilisais. Il est revenu à la méthode TOP 1 UPDATE mais j'ai laissé l'autre méthode dedans, commentée, au cas où vous voudriez l'explorer aussi.

Collez chacun de ces éléments dans une session distincte, exécutez la session 1, puis rapidement toutes les autres. Dans environ 50 secondes, le test sera terminé. Regardez les messages de chaque session pour voir quel travail il a fait (ou comment il a échoué). La première session affichera un ensemble de lignes avec un instantané pris une fois par seconde détaillant les verrous présents et les éléments de file d'attente en cours de traitement. Cela fonctionne parfois, et d'autres fois ne fonctionne pas du tout.

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

Session 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

Session 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

Session 4 et plus - autant que vous le souhaitez

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

Vous avez besoin de exactement 3 astuces de verrouillage

  • READPAST
  • UPDLOCK
  • DAME DE NAGE

J'ai répondu à cela précédemment sur SO: https://stackoverflow.com/questions/939831/sql-server-process-queue-race-condition/940001#940001

Comme le dit Remus, l'utilisation de Service Broker est plus agréable mais ces conseils fonctionnent

Votre erreur sur le niveau d'isolement signifie généralement la réplication ou NOLOCK est impliqué.

10
gbn

Le serveur SQL fonctionne très bien pour stocker des données relationnelles. Quant à une file d'attente, ce n'est pas si génial. Consultez cet article écrit pour MySQL mais il peut également s'appliquer ici. https://blog.engineyard.com/2011/5/5subtle-ways-youre-using-mysql-as-a-queue-and-why-itll-bite-yo