SQL Server中多个工作线程的FIFO队列表


15

我试图回答以下stackoverflow问题:

在发布一个有点天真的答案之后,我想我会把钱放在嘴边,实际上 测试我所建议的方案,以确保我不会像往常一样追逐OP。好吧,事实证明,这比我想象的要难得多(我敢肯定,这对任何人来说都不奇怪)。

这是我尝试过并想到的:

  • 首先,我尝试使用派生表中的ORDER BY进行TOP 1 UPDATE ROWLOCK, READPAST。这产生了死锁,并且还处理了乱序的项目。它必须尽可能接近FIFO,以排除需要多次尝试处理同一行的错误。

  • 然后,我尝试选择所需的下一个QueueID到一个变量,使用的各种组合READPASTUPDLOCKHOLDLOCK,并ROWLOCK专门由会话保持了更新的行。我尝试过的所有变体都遇到了与以前相同的问题,并且对于与的某些组合READPAST,抱怨如下:

    您只能在READ COMMITTED或REPEATABLE READ隔离级别中指定READPAST锁。

    这是令人困惑的,因为它是“已提交读”。我以前遇到过这个问题,这令人沮丧。

  • 自从我开始写这个问题以来,Remus Rusani发布了一个新的问题答案。我阅读了他的链接文章,看到他正在使用破坏性读取,因为他在回答中说“在网络通话期间实际上不可能保持锁定状态”。在阅读了他的文章中有关热点和需要锁定才能进行任何更新或删除的页面的内容后,我担心即使我能够算出正确的锁定来执行我想要的操作,也无法扩展并且可能无法处理大量并发。

现在,我不确定该去哪里。是真的无法实现在处理行时维持锁定(即使它不支持高tps或大量并发)吗?我想念什么?

希望比我更聪明的人和比我更老练的人可以提供帮助,以下是我使用的测试脚本。它又切换回TOP 1 UPDATE方法,但是我也将另一种方法留在其中,注释掉,以防您也想探索一下。

将这些粘贴到单独的会话中,运行会话1,然后快速将所有其他会话粘贴。在大约50秒内,测试将结束。查看每个会话中的消息,以查看其所做的工作(或失败的方式)。第一个会话将显示一个带有快照的行集,第二个快照详细描述了存在的锁和正在处理的队列项。有时它可以工作,而其他时候则根本不工作。

第一场

/* 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: 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: 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的监督下进行部署。
Remus Rusanu'7

一个有趣的皱纹是,使用READPAST, UPDLOCK, ROWLOCK我的脚本将数据捕获到QueueHistory表中没有做任何事情。我想知道是否是因为StatusID未提交?从WITH (NOLOCK)理论上讲,它的使用应该可以工作……而且以前确实可以工作!我不确定为什么现在不起作用,但这可能是另一种学习经历。
ErikE 2012年

您能否将代码减少到最小的样本,该样本会出现死锁和您要解决的其他问题?
Nick Chammas

@Nick我将尝试减少代码。关于您的其他评论,有一个标识列是聚集索引的一部分,并在日期之后按顺序排列。我非常愿意进行“破坏性读取”(使用OUTPUT进行删除),但是在应用程序实例失败的情况下,要求的条件之一是使该行自动返回到处理状态。所以我的问题是这是否可能。
ErikE 2012年

尝试使用破坏性的读取方法,然后将已出列的项目放置在单独的表中,并在必要时将它们从该表中重新入列。如果可以解决问题,那么您可以投资使此重新入队过程顺利进行。
Nick Chammas

Answers:


10

你需要确切地 3个锁提示

  • 预读
  • 密码锁
  • 洛克

我以前在SO上回答过这个问题:https : //stackoverflow.com/questions/939831/sql-server-process-queue-race-condition/940001#940001

如Remus所说,使用服务代理更好但是这些提示确实有效

您关于隔离级别的错误通常意味着涉及复制或NOLOCK。


如上所述,在我的脚本上使用这些提示会产生死锁,并且进程混乱。(UPDATE SET ... FROM (SELECT TOP 1 ... FROM ... ORDER BY ...))这是否意味着我持有锁的UPDATE模式无法正常工作?此外,当您结合在一起时READPASTHOLDLOCK就会得到错误。该服务器上没有复制,隔离级别为READ COMMITTED。
ErikE 2012年

2
@ErikE-表的结构与查询表一样重要。您要用作队列的表必须按照出队顺序聚集,以便要出队的下一项是明确的。这很关键。略过上面的代码,我看不到任何定义的聚集索引。
Nick Chammas

@Nick非常有意义,我不知道为什么我没有想到它。我添加了适当的PK约束(并在上面更新了我的脚本),但仍然遇到死锁。但是,现在按正确的顺序处理了这些项目,除非对死锁的项目重复进行处理。
ErikE 2012年

@ErikE-1.您的队列应仅包含排队的项目。使出队和出队应意味着从队列表中将其删除。我看到您正在更新StatusID来使项目出队。那是对的吗?2.您的出队顺序必须明确。如果您使用来对项目进行排队GETDATE(),那么在大批量交易中,很可能有多个项目有资格同时出队。这将导致死锁。我建议IDENTITY在聚集索引中添加一个,以确保明确的出队顺序。
Nick Chammas

1

SQL Server非常适合存储关系数据。至于工作队列,并不是很好。请参阅为MySQL写的这篇文章,但也可以在这里应用。https://blog.engineyard.com/2011/5-subtle-ways-youre-using-mysql-as-a-queue-and-why-itll-bite-you


谢谢,埃里克。在我对问题的原始答复中,我建议使用SQL Server Service Broker,因为我知道表即队列方法并不是数据库的真正用途。但是我认为这不再是一个好的建议,因为SB实际上仅用于消息传递。放在数据库中的数据的ACID属性使其成为尝试(滥用)非常有吸引力的容器。您能否建议一个替代的低成本产品,该产品可以很好地用作通用队列?并且可以备份等等等吗?
ErikE 2012年

8
这篇文章犯了队列处理中的一个已知谬误:将状态和事件合并到一个表中(实际上,如果您查看文章注释,您会看到我在一段时间前对此表示反对)。此问题的典型症状是“处理/处理”字段。将状态与事件结合(即,使状态表成为“队列”)会导致“队列”增长到巨大的大小(因为状态表 队列)。将事件分成一个真实的队列会导致一个队列“耗尽”(变空),并且表现更好。
Remus Rusanu

文章不是完全表明:队列表中只有准备工作的项目。
ErikE 2012年

2
@ErikE:您指的是这一段,对吗?避免单表综合症也很容易。只需为新电子邮件创建一个单独的表,然后在完成处理后,将其插入长期存储中,然后从队列表中删除它们。新电子邮件的表格通常会很小,并且操作很快。我对此的争吵是作为解决 “大队列”问题的一种解决方法。该建议应该在本文开头就提出,是一个基本问题。
雷木斯·鲁萨努

如果您开始将状态与事件明确区分开来思考,那么开始使用vdown会容易得多。上述即使是recomemendation会变成插入新的电子邮件到emailsnew_emails队列。处理过程轮询new_emails队列并更新emails表中的状态。这也避免了“胖”状态在队列中传播的问题。如果我们要讨论分布式处理和真正的队列,以及通信(例如SSB),那么事情会变得更加复杂,因为共享状态在分布式系统中是有问题的。
Remus Rusanu 2012年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.