Tabel antrian FIFO untuk beberapa pekerja di SQL Server

15

Saya mencoba menjawab pertanyaan stackoverflow berikut:

Setelah mengirim jawaban yang agak naif, saya pikir saya akan meletakkan uang saya di tempat mulut saya dan benar-benar menguji skenario yang saya sarankan, untuk memastikan saya tidak mengirim OP dengan sia-sia. Ya, ternyata itu jauh lebih sulit dari yang saya kira (saya yakin tidak ada orang di sana).

Inilah yang saya coba dan pikirkan:

  • Pertama saya mencoba TOP 1 UPDATE dengan ORDER BY di dalam tabel turunan, menggunakan ROWLOCK, READPAST. Ini menghasilkan kebuntuan dan juga memproses barang-barang yang rusak. Itu harus sedekat mungkin dengan FIFO, kecuali kesalahan yang membutuhkan upaya untuk memproses baris yang sama lebih dari sekali.

  • Saya kemudian mencoba memilih QueueID berikutnya yang diinginkan ke dalam variabel, dengan menggunakan berbagai kombinasi READPAST, UPDLOCK, HOLDLOCK, dan ROWLOCKuntuk secara eksklusif melestarikan baris untuk update dengan sesi tersebut. Semua variasi yang saya coba menderita dari masalah yang sama seperti sebelumnya serta, untuk kombinasi tertentu dengan READPAST, mengeluh:

    Anda hanya dapat menentukan kunci READPAST di tingkat isolasi READ COMMITTED atau REPEATABLE READ.

    Hal ini membingungkan karena itu BACA BERTEKAD. Saya pernah mengalami ini sebelumnya dan itu membuat frustrasi.

  • Sejak saya mulai menulis pertanyaan ini, Remus Rusani memposting jawaban baru untuk pertanyaan itu. Saya membaca artikel tertautnya dan melihat bahwa dia menggunakan bacaan yang merusak, karena dia mengatakan dalam jawabannya bahwa "tidak mungkin secara realistis untuk menahan kunci selama durasi panggilan web." Setelah membaca apa yang dikatakan artikelnya tentang hot spot dan halaman yang membutuhkan penguncian untuk melakukan pembaruan atau menghapus, saya khawatir bahwa bahkan jika saya dapat menemukan kunci yang benar untuk melakukan apa yang saya cari, itu tidak akan dapat diskalakan dan dapat tidak menangani konkurensi besar-besaran.

Saat ini saya tidak yakin ke mana harus pergi. Benarkah mempertahankan kunci saat baris diproses tidak dapat dicapai (bahkan jika itu tidak mendukung tps tinggi atau concurrency besar-besaran)? Apa yang saya lewatkan?

Dengan harapan orang-orang yang lebih pintar daripada saya dan orang-orang yang lebih berpengalaman daripada saya dapat membantu, di bawah ini adalah naskah tes yang saya gunakan. Itu kembali ke metode TOP 1 UPDATE tapi saya meninggalkan metode lain, berkomentar, kalau-kalau Anda ingin menjelajahi itu juga.

Rekatkan masing-masing ke dalam sesi terpisah, jalankan sesi 1, lalu cepat-cepat yang lainnya. Dalam sekitar 50 detik tes akan berakhir. Lihat Pesan dari setiap sesi untuk melihat pekerjaan apa yang dilakukan (atau bagaimana gagal). Sesi pertama akan menampilkan rowset dengan snapshot diambil satu detik sekali yang merinci hadirnya kunci dan item antrian yang sedang diproses. Kadang-kadang itu berfungsi, dan di waktu lain tidak bekerja sama sekali.

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

Sesi 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

Sesi 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

Sesi 4 dan lebih tinggi - sebanyak yang Anda suka

/* 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
sumber
2
Antrian seperti yang dijelaskan dalam artikel yang ditautkan dapat menskalakan hingga ratusan atau ribuan operasi lebih rendah per detik. Masalah pertikaian hot spot hanya relevan pada skala yang lebih tinggi. Ada strategi mitigasi yang diketahui yang dapat mencapai throughput yang lebih tinggi pada sistem kelas atas, mencapai puluhan ribu per detik, tetapi mitigasi tersebut perlu evaluasi yang cermat dan digunakan di bawah pengawasan SQLCAT .
Remus Rusanu
Satu kerutan yang menarik adalah dengan READPAST, UPDLOCK, ROWLOCKskrip saya untuk mengambil data ke tabel QueueHistory tidak melakukan apa-apa. Saya ingin tahu apakah itu karena StatusID tidak berkomitmen? Itu menggunakan WITH (NOLOCK)sehingga secara teoritis harus bekerja ... dan itu berhasil sebelum! Saya tidak yakin mengapa itu tidak berfungsi sekarang, tapi mungkin itu pengalaman belajar yang lain.
ErikE
Bisakah Anda mengurangi kode Anda menjadi sampel terkecil yang menunjukkan kebuntuan dan masalah lain yang Anda coba selesaikan?
Nick Chammas
@Nick Saya akan mencoba untuk mengurangi kode. Tentang komentar Anda yang lain, ada kolom identitas yang merupakan bagian dari indeks berkerumun dan dipesan setelah tanggal. Saya cukup bersedia untuk menghibur "membaca destruktif" (HAPUS dengan OUTPUT) tetapi salah satu persyaratan yang diminta adalah, dalam kasus contoh aplikasi gagal, agar baris kembali ke pemrosesan secara otomatis. Jadi pertanyaan saya di sini adalah apakah itu mungkin.
ErikE
Coba pendekatan membaca yang destruktif dan tempatkan barang-barang yang mengandung air dalam tabel terpisah dari tempat mereka dapat di-enqueued jika perlu. Jika itu memperbaikinya, maka Anda dapat berinvestasi dalam membuat proses requeque ini bekerja dengan lancar.
Nick Chammas

Jawaban:

10

Anda perlu persis 3 petunjuk kunci

  • READPAST
  • UPDLOCK
  • KELITI

Saya menjawab ini sebelumnya pada SO: /programming/939831/sql-server-process-queue-race-condition/940001#940001

Seperti yang dikatakan Remus, menggunakan broker layanan lebih baik tetapi petunjuk ini berfungsi

Kesalahan Anda tentang tingkat isolasi biasanya berarti replikasi atau NOLOCK terlibat.

gbn
sumber
Menggunakan petunjuk itu pada skrip saya seperti yang diberikan di atas menghasilkan kebuntuan dan proses rusak. ( UPDATE SET ... FROM (SELECT TOP 1 ... FROM ... ORDER BY ...)) Apakah ini berarti bahwa pola UPDATE saya dengan memegang kunci tidak berfungsi? Juga, saat Anda menggabungkan READPASTdengan HOLDLOCKAnda mendapatkan kesalahan. Tidak ada replikasi pada server ini dan tingkat isolasi BACA BERKOMITMEN.
ErikE
2
@ErikE - Sama pentingnya dengan bagaimana Anda meminta tabel adalah bagaimana tabel disusun. Tabel yang Anda gunakan sebagai antrian harus dikelompokkan dalam urutan dequeue sehingga item berikutnya yang akan dibagikan tidak ambigu . Ini sangat penting. Melacak kode Anda di atas, saya tidak melihat indeks berkerumun ditentukan.
Nick Chammas
@Nick, itu masuk akal dan saya tidak tahu mengapa saya tidak memikirkannya. Saya menambahkan batasan PK yang tepat (dan memperbarui skrip saya di atas), dan masih menemui jalan buntu. Namun, barang-barang sekarang diproses dalam urutan yang benar, kecuali pemrosesan ulang untuk barang-barang yang menemui jalan buntu.
ErikE
@ErikE - 1. Antrian Anda seharusnya hanya berisi item yang antri. Dequeuing dan item harus berarti menghapusnya dari tabel antrian. Saya melihat bahwa Anda malah memperbarui StatusIDuntuk membagikan item. Apakah itu benar? 2. Pesanan dequeue Anda harus jelas. Jika Anda mengantri item GETDATE(), maka pada volume tinggi sangat mungkin bahwa beberapa item akan sama-sama memenuhi syarat untuk keluar pada saat yang sama. Ini akan menyebabkan kebuntuan. Saya sarankan menambahkan IDENTITYindeks berkerumun untuk menjamin pesanan dequeue jelas.
Nick Chammas
1

SQL server berfungsi baik untuk menyimpan data relasional. Sedangkan untuk antrian pekerjaan, itu tidak terlalu bagus. Lihat artikel ini yang ditulis untuk MySQL tetapi juga dapat diterapkan di sini. https://blog.engineyard.com/2011/5-subtle-ways-youre-using-mysql-as-a-queue-and-why-itll-bite-you

Eric Humphrey - lotahelp
sumber
Terima kasih, Eric. Dalam balasan asli saya untuk pertanyaan itu, saya menyarankan untuk menggunakan SQL Server Service Broker karena saya tahu pasti bahwa metode table-as-queue tidak benar-benar untuk apa database dibuat. Tapi saya pikir itu bukan rekomendasi yang baik lagi karena SB benar-benar hanya untuk pesan. Properti ACID dari data yang dimasukkan ke dalam basis data membuatnya menjadi wadah yang sangat menarik untuk dicoba (ab) digunakan. Bisakah Anda menyarankan produk alternatif murah yang akan berfungsi dengan baik sebagai antrian umum? Dan dapat dicadangkan, dll. Dll?
ErikE
8
Artikel ini bersalah karena kesalahan yang diketahui dalam pemrosesan antrian: gabungkan status dan acara ke dalam satu tabel (sebenarnya jika Anda melihat komentar artikel, Anda akan melihat saya keberatan beberapa waktu lalu). Gejala khas dari masalah ini adalah bidang 'diproses / diproses'. Menggabungkan negara dengan peristiwa (mis. Membuat tabel negara 'antrian') menghasilkan pertumbuhan 'antrian' ke ukuran besar (karena tabel negara adalah antrian). Memisahkan peristiwa menjadi antrian sejati mengarah ke antrian yang 'terkuras' (kosong) dan ini berperilaku jauh lebih baik.
Remus Rusanu
Bukankah artikelnya menyarankan hal itu: tabel antrian HANYA memiliki item yang siap untuk bekerja.?
ErikE
2
@ErikE: Anda merujuk ke paragraf ini, bukan? itu juga sangat mudah untuk menghindari sindrom satu meja besar. Cukup buat tabel terpisah untuk email baru, dan ketika Anda selesai memprosesnya, MASUKKAN mereka ke penyimpanan jangka panjang dan kemudian HAPUS mereka dari tabel antrian. Tabel email baru biasanya akan tetap sangat kecil dan operasinya cepat . Pertengkaran saya dengan ini adalah yang diberikan sebagai solusi untuk masalah 'antrian besar'. Rekomendasi ini seharusnya sudah di pembukaan artikel, merupakan masalah mendasar .
Remus Rusanu
Jika Anda mulai berpikir dalam pemisahan yang jelas antara keadaan vs peristiwa, maka Anda mulai membatalkan jalur yang jauh lebih mudah. Bahkan rekomendasi di atas akan berubah menjadi memasukkan email baru ke dalam emailstabel dan ke dalam new_emailsantrian. Memproses jajak pendapat new_emailsantrian dan memperbarui status dalam emailstabel . Ini juga menghindari masalah kondisi 'gemuk' bepergian dalam antrian. Jika kita akan berbicara tentang pemrosesan terdistribusi dan antrian sejati , dengan komunikasi, (mis. SSB) maka semuanya menjadi lebih rumit karena keadaan bersama bermasalah dalam sistem terdistorsi.
Remus Rusanu