Таблиця черг FIFO для декількох працівників у SQL Server


15

Я намагався відповісти на таке запитання stackoverflow:

Опублікувавши дещо наївну відповідь, я зрозумів, що покладу гроші туди, де є рот, і фактично перевіряю сценарій, який я пропонував, щоб бути впевненим, що я не відсилаю ОП на погоню за дикими гусками. Що ж, це виявилося набагато важче, ніж я думав (не дивуюсь нікому, я впевнений).

Ось що я спробував і подумав:

  • Спочатку я спробував ТОП-1 ОНОВЛЕННЯ ЗАМОВЛЕННЯ Внутрішньої таблиці, використовуючи ROWLOCK, READPAST. Це призвело до виникнення тупикових ситуацій, а також обробляло предмети з ладу. Він повинен бути наближений до FIFO, наскільки це можливо, забороняючи помилки, які потребують спроби обробляти один і той же рядок не один раз.

  • Потім я спробував вибрати потрібний наступний QueueID в змінну, використовуючи різні комбінації READPAST, UPDLOCK, HOLDLOCKі ROWLOCKвиключно зберегти рядок для поновлення на цій сесії. Усі варіанти, з якими я намагався, страждали від тих же проблем, що і раніше, а також для певних комбінацій зі READPASTскаргами:

    Ви можете вказати замок READPAST лише на рівнях ізоляції READ COMMITTED або REPEATABLE READ.

    Це було заплутано, тому що це було ПРОЧИТАНО ЗАВАНТАЖЕНО. Я до цього стикався, і це неприємно.

  • Оскільки я почав писати це питання, Рем Русані опублікував нову відповідь на питання. Я читаю його пов’язану статтю і бачу, що він використовує руйнівні читання, оскільки у своїй відповіді він сказав, що "реально не можна триматися на блокування протягом тривалості веб-дзвінків". Прочитавши те, що йдеться в його статті про гарячі точки та сторінки, що вимагають блокування, щоб зробити будь-яке оновлення чи видалити, я побоююся, що навіть якщо мені вдасться розробити правильні блокування, щоб зробити те, що я шукаю, це не було б масштабованим та міг би не впоратися з величезною одночасністю.

Зараз я не впевнений, куди йти. Чи правда, що збереження блокувань під час обробки рядка неможливо досягти (навіть якщо він не підтримував високу tps чи велику одночасність)? Що я пропускаю?

Сподіваючись, що люди розумніші за мене та люди досвідченіші за мене можуть допомогти, нижче наведений тестовий сценарій, який я використовував. Він повертається назад до методу TOP 1 UPDATE, але я залишив інший метод, прокоментував його, якщо ви хочете вивчити і це.

Вставте кожне з них в окремий сеанс, запустіть сеанс 1, а потім швидко всі інші. Приблизно через 50 секунд тест закінчиться. Перегляньте Повідомлення кожного сеансу, щоб побачити, яку роботу він зробив (чи як не вдався). Перший сеанс покаже набір рядків із знімком, зробленим раз на секунду, з деталізацією присутніх замків та оброблюваних елементів черги. Це працює іноді, а інший час взагалі не працює.

Сесія 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;
*/

Сесія 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

Сесія 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

Сесія 4 і вище - скільки завгодно

/* 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
Черги, як описано у зв'язаній статті, можуть масштабуватися до сотень чи менших тисяч операцій в секунду. Питання щодо суперечки «гарячих точок» є актуальними лише у більш високих масштабах. Відомі стратегії пом'якшення наслідків, які дозволяють досягти більшої пропускної здатності на високому рівні системи, що йде в десятки тисяч в секунду, але ці пом’якшення потребують ретельної оцінки та розгортаються під наглядом SQLCAT .
Рем Русану

Одна цікава цікавість - це те, що з READPAST, UPDLOCK, ROWLOCKмоїм сценарієм збору даних до таблиці QueueHistory нічого не робиться. Цікаво, чи це тому, що StatusID не вчиняється? Це використання WITH (NOLOCK)теоретично повинно працювати ... і це працювало раніше! Я не впевнений, чому зараз це не працює, але це, мабуть, інший досвід навчання.
ЕрікЕ

Чи можете ви зменшити свій код до найменшого зразка, який демонструє тупиковість та інші проблеми, які ви намагаєтеся вирішити?
Нік Шаммас

@Nick Я спробую зменшити код. Щодо інших ваших коментарів є стовпець ідентичності, який є частиною кластерного індексу та упорядкований після дати. Я цілком готовий розважити "руйнівне читання" (DELETE with OUTPUT), але однією з запитуваних вимог було, у випадку відмови екземпляра програми, рядок автоматично повертається до обробки. Тож моє питання тут - чи це можливо.
ЕрікЕ

Спробуйте руйнівний підхід до читання і помістіть відкладені предмети в окрему таблицю, звідки вони можуть бути повторно заблоковані, якщо це необхідно. Якщо це виправить це, ви можете вкласти гроші в те, щоб цей процес повторного запиту працював безперебійно.
Нік Чаммас

Відповіді:


10

Вам потрібно рівно 3 підказки про блокування

  • ЧИТАННЯ
  • UPDLOCK
  • ВІДКЛЮЧЕННЯ

Я відповів на це раніше на SO: /programming/939831/sql-server-process-queue-race-condition/940001#940001

Як каже Ремус, використовувати сервіс-брокер приємніше, але ці підказки діють

Ваша помилка щодо рівня ізоляції зазвичай означає реплікацію або задіяно NOLOCK.


Використання цих натяків на мій сценарій, як зазначено вище, призводить до тупикових ситуацій та процесів. ( UPDATE SET ... FROM (SELECT TOP 1 ... FROM ... ORDER BY ...)) Чи означає це, що мій шаблон ОНОВЛЕННЯ з триманням блокування не може працювати? Крім того , в той момент , ви поєднуєте READPASTз HOLDLOCKвами отримаєте помилку. На цьому сервері немає реплікації, і рівень ізоляції ЧИТАЙТЕ ЗАВДАНО.
ErikE

2
@ErikE - Не менш важливо, як те, як ви запитуєте таблицю, як структура таблиці. Таблиця, яку ви використовуєте в якості черги, повинна бути кластеризована в порядку декетування, таким чином, щоб наступний елемент, який буде видалено, був однозначним . Це критично. Скинувши свій код вище, я не бачу жодних кластерних індексів.
Нік Чаммас

@ Нік, що має цілком іменитий сенс, і я не знаю, чому я не думав про це. Я додав належне обмеження ПК (і оновив сценарій вище), і все ще отримав тупикові місця. Однак елементи тепер обробляються в правильному порядку, що забороняє повторну обробку для тупикових елементів.
ErikE

@ErikE - 1. Ваша черга повинна містити лише елементи, що стоять у черзі. Відмітка та елемент повинні означати видалення з таблиці черги. Я бачу, що ви замість цього оновлюєте StatusIDелемент для видалення елемента. Це правильно? 2. Ваше замовлення на декею має бути однозначним. Якщо ви ставите в чергу товари GETDATE(), то при великих обсягах велика ймовірність, що декілька предметів будуть однаково придатними для виведення з ладу одночасно. Це призведе до тупиків. Я пропоную додати IDENTITYдо кластеризованого індексу, щоб гарантувати однозначний порядок dequeue.
Нік Чаммас

1

SQL-сервер чудово працює для зберігання реляційних даних. Що стосується черги на роботу, то вона не така велика. Дивіться цю статтю, написану для MySQL, але вона також може застосовуватися тут. https://blog.engineyard.com/2011/5-subtle-ways-youre-using-mysql-as-a-queue-and-why-itll-bite-you


Спасибі, Еріку. У моїй оригінальній відповіді на запитання я пропонував використовувати брокер SQL Server Service, тому що я знаю, що метод table-as-queue насправді не те, для чого створена база даних. Але я вважаю, що це вже не є хорошою рекомендацією, оскільки SB дійсно лише для повідомлень. Властивості ACID даних, що містяться в базі даних, роблять це дуже привабливим контейнером для спроб (ab) використання. Чи можете ви запропонувати альтернативний дешевий продукт, який буде добре функціонувати як загальна черга? А можна зробити резервне копіювання тощо тощо?
ErikE

8
У статті винна відома помилка в обробці черг: об'єднайте стан та події в єдину таблицю (насправді, якщо ви подивитесь на коментарі до статті, ви побачите, що я заперечив проти цього деякий час тому). Типовим симптомом цієї проблеми є поле "оброблена / оброблена". Поєднання стану з подіями (тобто перетворенням таблиці стану "чергою") призводить до збільшення "черги" до величезних розмірів (оскільки таблиця стану - це черга). Виділення подій на справжню чергу призводить до черги, яка «стікає» (порожня), і це веде себе набагато краще.
Рем Русану

Чи не підкреслює ця стаття саме так: таблиця черги містить ТОЛЬКІ елементи, готові до роботи.?
ЕрікЕ

2
@ErikE: ви посилаєтесь на цей абзац, правда? також дуже просто уникнути синдрому однієї великої таблиці. Просто створіть окрему таблицю для нових електронних листів, і коли ви закінчите їх обробляти, Вставте їх у довготривале сховище, а потім видаліть їх із таблиці черг. Таблиця нових листів зазвичай залишається дуже малою, і операції над нею будуть швидкими . Моя сварка з цим полягає в тому, що це дається як спосіб вирішення питання про "великі черги". Ця рекомендація мала бути під час відкриття статті, є принциповим питанням.
Рем Русану

Якщо ви починаєте мислити в чіткому розділенні стану проти події, тоді ви починаєте vdown набагато простішим шляхом. Навіть вищезгадана рекомендація може змінитись, щоб вставити нові електронні листи в emailsтаблицю та в new_emailsчергу. Обробка опитує new_emailsчергу та оновлює стан у emailsтаблиці . Це також дозволяє уникнути проблеми "жирного" стану подорожей у чергах. Якщо ми б говорили про розподілену обробку та справжні черги, з комунікацією (наприклад, SSB), то все стає більш складним, оскільки спільний стан проблематично в дистирбуваних системах.
Рем Русану
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.