Tabela kolejek FIFO dla wielu pracowników w programie SQL Server

15

Próbowałem odpowiedzieć na następujące pytanie dotyczące przepełnienia stosu:

Po opublikowaniu dość naiwnej odpowiedzi, pomyślałem, że położyłem pieniądze tam, gdzie były moje usta i faktycznie przetestowałem sugerowany scenariusz, aby upewnić się, że nie wysyłam OP na dziką gęś. Cóż, okazało się, że jest o wiele trudniejsze niż myślałem (nikogo to nie dziwi, jestem pewien).

Oto, o czym próbowałem i myślałem:

  • Najpierw próbowałem AKTUALIZACJI TOP 1 z ORDER BY wewnątrz pochodnej tabeli, używając ROWLOCK, READPAST. To spowodowało zakleszczenia, a także przetworzyło produkty poza kolejnością. Musi być jak najbliżej FIFO, wykluczając błędy, które wymagają próby przetworzenia tego samego wiersza więcej niż jeden raz.

  • Następnie próbowałem wybierając żądany następny queueid do zmiennej, stosując różne kombinacje READPAST, UPDLOCK, HOLDLOCK, i ROWLOCKaby zachować wyłącznie wiersz aktualizacji przez tę sesję. Wszystkie odmiany, których próbowałem, cierpiały z powodu tych samych problemów, co wcześniej, a także w przypadku niektórych kombinacji z READPASTnarzekaniem:

    Blokadę READPAST można określić tylko na poziomach izolacji READ COMMITTED lub REPEATABLE READ.

    Było to mylące, ponieważ zostało CZYTANE ZAANGAŻOWANE. Natknąłem się na to wcześniej i jest to frustrujące.

  • Odkąd zacząłem pisać to pytanie, Remus Rusani opublikował nową odpowiedź na to pytanie. Czytam jego linkowany artykuł i widzę, że używa destrukcyjnych odczytów, ponieważ w swojej odpowiedzi powiedział, że „nie można realistycznie trzymać blokad na czas połączeń internetowych”. Po przeczytaniu tego, co mówi jego artykuł na temat hot spotów i stron wymagających blokowania w celu wykonania dowolnej aktualizacji lub usunięcia, obawiam się, że nawet gdybym był w stanie wypracować odpowiednie blokady, aby zrobić to, czego szukam, nie byłby skalowalny i mógłby nie obsługuje ogromnej współbieżności.

W tej chwili nie jestem pewien, dokąd pójść. Czy to prawda, że ​​utrzymanie blokad podczas przetwarzania wiersza nie jest możliwe (nawet jeśli nie obsługiwało ono wysokiej tps lub ogromnej współbieżności)? czego mi brakuje?

W nadziei, że ludzie mądrzejsi ode mnie i ludzie bardziej doświadczeni ode mnie mogą pomóc, poniżej znajduje się skrypt testowy, którego używałem. Został przełączony z powrotem na metodę TOP 1 UPDATE, ale zostawiłem drugą metodę, skomentowałem, na wypadek, gdybyś też chciał to zbadać.

Wklej każdą z nich do osobnej sesji, uruchom sesję 1, a następnie szybko wszystkie pozostałe. Za około 50 sekund test się skończy. Spójrz na wiadomości z każdej sesji, aby zobaczyć, jaką pracę wykonała (lub jak się nie udało). Pierwsza sesja pokaże zestaw wierszy z migawką wykonywaną raz na sekundę, wyszczególniając obecne blokady i przetwarzane elementy kolejki. Czasami działa, a innym razem w ogóle nie działa.

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

Sesja 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

Sesja 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

Sesja 4 i nowsze - tyle, ile chcesz

/* 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
źródło
2
Kolejki opisane w powiązanym artykule mogą być skalowane do setek lub niższych tysięcy operacji na sekundę. Kwestie rywalizacji o miejsca aktywne dotyczą tylko większej skali. Znane są strategie łagodzenia, które mogą osiągnąć wyższą przepustowość w wysokiej klasy systemie, osiągając dziesiątki tysięcy na sekundę, ale te łagodzenia wymagają dokładnej oceny i są wdrażane pod nadzorem SQLCAT .
Remus Rusanu,
Ciekawe jest to, że READPAST, UPDLOCK, ROWLOCKmój skrypt do przechwytywania danych do tabeli QueueHistory nic nie robi. Zastanawiam się, czy to dlatego, że StatusID nie jest zatwierdzony? Używa, WITH (NOLOCK)więc teoretycznie powinno działać ... i działało wcześniej! Nie jestem pewien, dlaczego teraz nie działa, ale prawdopodobnie jest to kolejne doświadczenie edukacyjne.
ErikE,
Czy możesz zredukować kod do najmniejszej próbki, która wykazuje zakleszczenie i inne problemy, które próbujesz rozwiązać?
Nick Chammas,
@Nick Spróbuję zmniejszyć kod. W przypadku pozostałych komentarzy istnieje kolumna tożsamości, która jest częścią indeksu klastrowego i uporządkowana według daty. Jestem skłonny przeprowadzić „destrukcyjny odczyt” (USUŃ przy pomocy WYJŚCIA), ale jednym z wymaganych wymagań było, w przypadku awarii instancji aplikacji, automatyczne przywrócenie wiersza do przetwarzania. Moje pytanie brzmi więc, czy to możliwe.
ErikE,
Wypróbuj destrukcyjne podejście do odczytu i umieść odbarwione elementy w osobnej tabeli, skąd mogą być ponownie umieszczone w kolejce, jeśli to konieczne. Jeśli to naprawi, możesz zainwestować w sprawne działanie tego procesu kolejkowania.
Nick Chammas,

Odpowiedzi:

10

Potrzebujesz dokładnie 3 wskazówek dotyczących blokady

  • PRZECZYTAJ
  • UPDLOCK
  • DULKA

Odpowiedziałem na to wcześniej na stronie SO: /programming/939831/sql-server-process-queue-race-condition/940001#940001

Jak mówi Remus, korzystanie z usług brokera usług jest przyjemniejsze, ale te wskazówki działają

Błąd dotyczący poziomu izolacji zwykle oznacza, że ​​zaangażowana jest replikacja lub NOLOCK.

gbn
źródło
Korzystanie z tych wskazówek w moim skrypcie, jak podano powyżej, powoduje zakleszczenia i procesy są nieczynne. ( UPDATE SET ... FROM (SELECT TOP 1 ... FROM ... ORDER BY ...)) Czy to oznacza, że ​​mój wzór UPDATE z blokadą nie działa? Również w momencie połączenia READPASTz HOLDLOCKtobą pojawia się błąd. Na tym serwerze nie ma replikacji, a poziom izolacji to PRZECZYTAJ ZOBOWIĄZANO.
ErikE,
2
@ErikE - Równie ważne jak zapytanie tabeli jest jej struktura. Tabela, której używasz jako kolejki, musi być grupowana w kolejności usuwania z kolejki, aby następny element do usunięcia z kolejki był jednoznaczny . To jest krytyczne. Po przejrzeniu kodu powyżej nie widzę zdefiniowanych indeksów klastrowych.
Nick Chammas,
@Nick ma to całkowicie wybitny sens i nie wiem, dlaczego o tym nie pomyślałem. Dodałem odpowiednie ograniczenie PK (i zaktualizowałem mój skrypt powyżej) i wciąż mam zakleszczenia. Jednak elementy zostały teraz przetworzone we właściwej kolejności, z wyłączeniem powtórnego przetwarzania zakleszczonych elementów.
ErikE,
@ErikE - 1. Twoja kolejka powinna zawierać tylko pozycje w kolejce. Usuwanie z kolejki i element powinny oznaczać usunięcie go z tabeli kolejek. Widzę, że zamiast tego aktualizujesz element, StatusIDaby usunąć z kolejki element. Czy to jest poprawne? 2. Twoje zamówienie na kolejkę musi być jednoznaczne. Jeśli GETDATE()ustawisz w kolejce przedmioty , to przy dużych ilościach bardzo prawdopodobne jest, że wiele przedmiotów będzie w równym stopniu kwalifikować się do usuwania z kolejki w tym samym czasie. Doprowadzi to do impasu. Sugeruję dodanie IDENTITYdo indeksu klastrowego, aby zagwarantować jednoznaczne zamówienie na kolejkę.
Nick Chammas,
1

Serwer SQL doskonale nadaje się do przechowywania danych relacyjnych. Jeśli chodzi o kolejkę zadań, to nie jest tak świetne. Zobacz ten artykuł napisany dla MySQL, ale można go również zastosować tutaj. https://blog.engineyard.com/2011/5-subtle-ways-youre-using-mysql-as-a-queue-and-why-itll-bite-you

Eric Humphrey - lotsahelp
źródło
Dzięki, Eric. W mojej oryginalnej odpowiedzi na pytanie sugerowałem użycie SQL Server Service Broker, ponieważ wiem, że metoda tabeli jako kolejki nie jest tak naprawdę przeznaczona do bazy danych. Ale myślę, że nie jest to już dobra rekomendacja, ponieważ SB jest tak naprawdę tylko dla wiadomości. Właściwości ACID danych umieszczonych w bazie danych sprawiają, że jest to bardzo atrakcyjny pojemnik, którego można (ab) użyć. Czy możesz zasugerować alternatywny, tani produkt, który będzie dobrze działał jako ogólna kolejka? I można wykonać kopię zapasową itp. Itp.?
ErikE
8
Artykuł jest winny znanego błędu w przetwarzaniu kolejek: połącz stan i zdarzenia w jedną tabelę (właściwie, jeśli spojrzysz na komentarze do artykułu, zobaczysz, że sprzeciwiłem się temu jakiś czas temu). Typowym objawem tego problemu jest pole „przetwarzane / przetwarzane”. Połączenie stanu ze zdarzeniami (tj. Uczynienie tabeli stanu „kolejką”) powoduje powiększenie „kolejki” do ogromnych rozmiarów (ponieważ tabela stanów jest kolejką). Rozdzielenie zdarzeń na prawdziwą kolejkę prowadzi do kolejki, która „drenuje” (staje się pusta) i to działa znacznie lepiej.
Remus Rusanu,
Czy artykuł nie sugeruje dokładnie tego: tabela kolejek zawiera TYLKO elementy gotowe do pracy.?
ErikE,
2
@ErikE: masz na myśli ten akapit, prawda? bardzo łatwo jest uniknąć syndromu jednego stołu. Po prostu utwórz osobną tabelę dla nowych wiadomości e-mail, a gdy skończysz je przetwarzać, WŁÓŻ je do pamięci długoterminowej, a następnie USUŃ je z tabeli kolejek. Tabela nowych e-maili zwykle pozostanie bardzo mała, a operacje na niej będą szybkie . Moja kłótnia polega na tym, że jest to obejście problemu „dużych kolejek”. To zalecenie powinno być na początku artykułu, jest podstawową kwestią.
Remus Rusanu,
Jeśli zaczniesz myśleć w wyraźnym oddzieleniu stanu od zdarzenia, zaczniesz vdown o wiele łatwiejszą ścieżkę. Nawet powyższe zalecenie zmieniłoby się w wstawianie nowych wiadomości e-mail do emailstabeli i do new_emailskolejki. Przetwarzanie odpytuje new_emailskolejkę i aktualizuje stan w emailstabeli . Pozwala to również uniknąć problemu „tłustego” stanu podróżowania w kolejkach. Gdybyśmy mówili o przetwarzaniu rozproszonym i prawdziwych kolejkach, z komunikacją (np. SSB), wówczas sprawy stają się bardziej skomplikowane, ponieważ stan współdzielony jest problematyczny w systemach rozproszonych.
Remus Rusanu,