SQL Server의 여러 작업자에 대한 FIFO 대기열 테이블


15

다음 stackoverflow 질문에 답변하려고했습니다.

다소 순진한 답변을 게시 한 후, 나는 입에 돈을 넣고 실제로 제안하는 시나리오를 테스트 하여 거친 거위 추적에서 OP를 보내지 않았 음을 확인했습니다. 글쎄, 그것은 내가 생각했던 것보다 훨씬 더 힘들다는 것이 밝혀졌습니다 (아무도 놀라지 않을 것입니다).

내가 시도하고 생각한 것은 다음과 같습니다.

  • 먼저 파생 테이블 내부에서 ORDER BY를 사용하여 TOP 1 UPDATE를 시도했습니다 ROWLOCK, READPAST. 교착 상태가 발생하고 항목이 잘못 처리되었습니다. 동일한 행을 두 번 이상 처리해야하는 오류를 제외하고 가능한 한 FIFO에 가까워 야합니다.

  • 그때의 다양한 조합을 사용하여 변수에 원하는 다음 QueueID을 선택 했어요 READPAST, UPDLOCK, HOLDLOCK, 그리고 ROWLOCK독점적으로 해당 세션에 의해 업데이트에 대한 행을 유지합니다. 내가 시도한 모든 변형은 이전과 동일한 문제로 인해 문제가 발생했습니다 READPAST.

    READ COMMITTED 또는 REPEATABLE READ 격리 수준에서만 READPAST 잠금을 지정할 수 있습니다.

    이 때문에 혼동되었다 READ 커밋. 나는 전에 이것에 부딪쳤다.

  • 이 질문을 쓰기 시작한 후 Remus Rusani는이 질문에 대한 새로운 답변을 게시했습니다. 나는 그의 링크 된 기사를 읽고 그가 파괴적인 읽기를 사용하고 있음을 알았다. 왜냐하면 그의 답변에서 "웹 호출 기간 동안 잠금을 유지하는 것은 현실적으로 불가능하다"고 말했다. 업데이트 또는 삭제를 수행하기 위해 잠금이 필요한 핫스팟 및 페이지에 관한 그의 기사를 읽은 후, 내가 찾고있는 것을 수행하기 위해 올바른 잠금을 수행 할 수는 있지만 확장 할 수 없으며 대규모 동시성을 처리하지 않습니다.

지금은 어디로 가야할지 모르겠습니다. 행이 처리되는 동안 잠금을 유지 관리 할 수 ​​없다는 것이 사실입니까 (높은 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 감독 하에 배포됩니다 .
Remus Rusanu

흥미로운 점 중 하나 READPAST, UPDLOCK, ROWLOCK는 QueueHistory 테이블에 데이터를 캡처하는 스크립트를 사용하여 아무 작업도 수행하지 않는다는 것입니다. StatusID가 커밋되지 않았기 때문에 궁금합니다. 사용하고 WITH (NOLOCK)작동해야하므로 이론적으로 ... 그리고 전에 일했다! 왜 지금 작동하지 않는지 잘 모르겠지만 아마도 다른 학습 경험 일 것입니다.
ErikE

교착 상태 및 해결하려는 다른 문제를 나타내는 가장 작은 샘플로 코드를 줄일 수 있습니까?
Nick Chammas

@Nick 코드를 줄이려고합니다. 다른 의견에 대해서는 클러스터 색인의 일부이며 날짜 이후에 정렬되는 ID 열이 있습니다. 필자는 "파괴적 읽기"(OUTPUT을 사용하여 삭제)를 즐겁게하려고하지만 요청 된 요구 사항 중 하나는 응용 프로그램 인스턴스가 실패한 경우 행이 자동으로 처리로 돌아가는 것이 었습니다. 그래서 내 질문은 그것이 가능한지 여부입니다.
ErikE

파괴적인 읽기 접근 방식을 시도하고 대기열에서 제외 된 항목을 필요한 경우 다시 대기열에 넣을 수있는 별도의 테이블에 배치하십시오. 그래도 문제가 해결되면이 재 대기열 프로세스가 원활하게 작동하도록 투자 할 수 있습니다.
Nick Chammas

답변:


10

정확히 3 개의 힌트 힌트 가 필요 합니다

  • READPAST
  • 업록
  • 놋좆

나는 이전에 이렇게 대답했다 : /programming/939831/sql-server-process-queue-race-condition/940001#940001

Remus가 말했듯이 Service Broker를 사용하는 것이 좋지만 이러한 힌트는 효과가 있습니다.

격리 수준에 대한 오류는 일반적으로 복제 또는 NOLOCK이 관련된 것을 의미합니다.


위의 스크립트에서 힌트를 사용하면 교착 상태가 발생하고 순서가 잘못됩니다. ( UPDATE SET ... FROM (SELECT TOP 1 ... FROM ... ORDER BY ...)) 이것은 잠금을 유지 한 UPDATE 패턴이 작동하지 않음을 의미합니까? 또한, 당신이 결합하는 순간 READPASTHOLDLOCK당신은 오류가 발생합니다. 이 서버에 복제가 없으며 격리 수준이 READ COMMITTED입니다.
ErikE

2
@ErikE-테이블을 쿼리하는 방법만큼이나 테이블을 구성하는 방법이 중요합니다. 큐로 사용중인 테이블은 큐 에서 제외 될 다음 항목이 모호하지 않도록 큐 순서클러스터 되어야합니다 . 이것은 중요합니다. 위의 코드를 감추면 클러스터 된 인덱스가 정의되어 있지 않습니다.
Nick Chammas

@Nick 그것은 완벽하게 저명한 의미를 가지고 있으며 왜 그것을 생각하지 못했는지 모르겠습니다. 적절한 PK 제약 조건을 추가하고 위의 스크립트를 업데이트했지만 여전히 교착 상태가 발생했습니다. 그러나 이제 교착 상태 항목에 대한 반복 처리를 제외하고 항목이 올바른 순서로 처리되었습니다.
ErikE

@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

8
이 기사는 대기열 처리에서 알려진 오류로 유죄 판결을 받았습니다. 상태와 이벤트를 단일 테이블로 결합합니다 (실제로 기사 의견을 보면 얼마 전에 이의를 제기 한 것을 알 수 있습니다). 이 문제의 일반적인 증상은 'processed / processing'필드입니다. 상태를 이벤트와 결합하면 (즉, 상태 테이블을 '큐'로 설정) '큐'가 큰 크기로 커집니다 (상태 테이블 이므로 ). 이벤트를 실제 대기열로 분리하면 대기열이 '비워지고'(빈 상태가 됨) 훨씬 잘 작동 합니다 .
Remus Rusanu

이 기사에서는 큐 테이블에 작업 준비가 된 항목 만 있습니다.
ErikE

2
@ErikE :이 단락을 참조하고 있습니까? 하나의 큰 테이블 증후군을 피하는 것도 정말 쉽습니다. 새 이메일에 대해 별도의 테이블을 작성하고 처리가 완료되면이를 장기 스토리지에 삽입 한 후 큐 테이블에서 삭제하십시오. 새 이메일 표는 일반적으로 매우 작게 유지되며 작업 속도가 빠릅니다 . 이것에 대한 나의 싸움 은 '큰 대기열'문제에 대한 해결책으로 제공됩니다 . 이 권장 사항은 기사의 개설에 있었어야했지만 근본적인 문제입니다.
레무스 Rusanu

상태와 이벤트를 명확하게 구분하여 생각하기 시작하면 vdown이 훨씬 쉬운 경로로 시작됩니다. 심지어 recomemendation 위로 변경됩니다 에 새 이메일 삽입 emails테이블 new_emails큐. 처리는 new_emails큐를 폴링하고 emails테이블 의 상태를 업데이트 합니다 . 이것은 또한 '지방'상태가 큐에서 이동하는 문제를 피합니다. 통신 (예 : SSB)을 통해 분산 처리 및 실제 대기열 에 대해 이야기 할 경우 공유 상태가 방해받는 시스템에서 문제가되기 때문에 상황이 더욱 복잡해집니다.
레무스 루사
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.