Skip to content

Hàng đợi bằng database ​

Dùng một bảng trong MySQL hay PostgreSQL làm hàng đợi job: gửi email, tạo thumbnail, gọi webhook, đồng bộ sang hệ thống khác. App ghi một dòng vào bảng jobs, còn các worker lấy dòng ra để xử lý.

Cách này phổ biến hơn nhiều người nghĩ. Các thư viện job queue nghiêm túc đều chạy trên database: pg-boss và Graphile Worker cho Node.js với PostgreSQL, Solid Queue (mặc định của Rails 8) cho MySQL, PostgreSQL và SQLite (pg-boss, Graphile Worker, Solid Queue).

Vì sao dùng database làm hàng đợi ​

Lợi thế lớn nhất là thêm job trong cùng transaction với dữ liệu. Ví dụ tạo đơn hàng và tạo job gửi email xác nhận:

sql
BEGIN;
INSERT INTO orders (id, user_id, total) VALUES (?, ?, ?);
INSERT INTO jobs (queue, payload, run_at) VALUES ('email', '{"order_id": 123}', CURRENT_TIMESTAMP);
COMMIT;

Hai lệnh cùng commit hoặc cùng rollback. Nếu thay bằng cách ghi đơn vào database rồi publish lên RabbitMQ, thì có hai chỗ hỏng:

  • Commit xong mà publish lỗi thì có đơn nhưng không có email.
  • Publish trước rồi transaction rollback thì có email cho một đơn không tồn tại.

Vấn đề này gọi là dual write, tức là ghi vào hai hệ thống mà không có transaction chung (microservices.io: Transactional outbox).

Lợi thế khác:

  • Không thêm hạ tầng. Không phải vận hành, giám sát, backup thêm Redis hay RabbitMQ.
  • Truy vấn được hàng đợi bằng SQL. Đếm job lỗi, tìm job của một đơn, chạy lại job đã hỏng đều chỉ là một câu SQL.

Đổi lại, database phải gánh thêm tải ghi và tải đọc liên tục. Mục Khi nào chuyển sang message broker nói khi nào cách này không còn hợp.

Thiết kế bảng ​

sql
CREATE TABLE jobs (
  id           BIGINT PRIMARY KEY,            -- MySQL: AUTO_INCREMENT; PostgreSQL: GENERATED ALWAYS AS IDENTITY
  queue        VARCHAR(50)  NOT NULL,         -- 'email', 'thumbnail', ...
  payload      JSON         NOT NULL,         -- PostgreSQL nên dùng JSONB
  status       VARCHAR(20)  NOT NULL DEFAULT 'pending',  -- pending | running | done | failed
  priority     INT          NOT NULL DEFAULT 0,
  run_at       TIMESTAMP    NOT NULL,         -- chưa tới giờ thì chưa chạy (job hẹn giờ, retry)
  attempts     INT          NOT NULL DEFAULT 0,
  max_attempts INT          NOT NULL DEFAULT 5,
  locked_by    VARCHAR(64)  NULL,             -- worker đang giữ job
  locked_until TIMESTAMP    NULL,             -- hết lease thì job được thu hồi
  last_error   TEXT         NULL,
  created_at   TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_jobs_fetch ON jobs (queue, status, run_at);
  • Index (queue, status, run_at) để câu lấy job tìm thẳng tới job pending đã tới giờ, không phải quét bảng.
  • Trên PostgreSQL có thể dùng partial index … WHERE status = 'pending'. Index chỉ chứa job đang chờ, nên luôn nhỏ dù bảng có hàng triệu job đã xong.
  • run_at dùng cho cả job hẹn giờ (gửi email nhắc sau 3 ngày) lẫn retry có backoff.

Lấy job: vì sao cần SKIP LOCKED ​

Có ba cách viết câu lấy job, và chỉ cách thứ ba dùng được khi có nhiều worker.

Cách 1: SELECT rồi UPDATE. Worker SELECT một job pending, rồi UPDATE sang running. Hai worker hỏi cùng lúc sẽ thấy cùng một job và cùng chạy nó. Đây đúng là lỗi check-then-act (Chống trùng & race condition).

Cách 2: SELECT … FOR UPDATE. Không còn chạy trùng, nhưng mọi worker đều muốn lock job đầu tiên trong hàng. Worker đến sau phải chờ worker đang giữ lock, nên các worker xếp hàng chạy lần lượt. Thêm worker gần như không nhanh hơn.

Cách 3: SELECT … FOR UPDATE SKIP LOCKED. Worker gặp dòng đang bị lock thì bỏ qua và lấy dòng kế tiếp, không chờ. Mỗi worker nhận một job khác nhau và chạy song song.

Thử: 3 worker cùng lấy 10 job
10tick để xong mọi job (chạy tuần tự: 19)
4lần chạy trùng job
0tick worker đứng chờ lock
0123456789
worker 1#1#1#3#4#4#6#6#6#9#9
worker 2#1#1#4#4#7#9#9
worker 3#2#2#2#5#5#7#8#8#10
đang chạy jobjob bị nhiều worker chạy cùng lúcchờ lock

Hai worker hỏi trong cùng một tick thấy cùng một job đang pending, nên cùng chạy nó. Job gửi email thì khách nhận hai email.

Giả định minh hoạ: mỗi job chạy 1 đến 3 tick; các worker bắt đầu lệch nhau và nghỉ khác nhau giữa hai lần lấy job. Ở cách FOR UPDATE, worker giữ lock dòng job trong suốt lúc xử lý.

sql
SELECT id, payload FROM jobs
WHERE queue = 'email' AND status = 'pending' AND run_at <= CURRENT_TIMESTAMP
ORDER BY priority DESC, run_at, id
LIMIT 10
FOR UPDATE SKIP LOCKED;

Những điều tài liệu chính thức nói về SKIP LOCKED:

  • Phiên bản. MySQL có SKIP LOCKED và NOWAIT từ 8.0.1 (MySQL blog). PostgreSQL có từ 9.5; Solid Queue cũng ghi yêu cầu này: MySQL 8+, MariaDB 10.6+, PostgreSQL 9.5+ (Solid Queue).
  • Chỉ dành cho bảng kiểu hàng đợi. Cả MySQL lẫn PostgreSQL đều ghi rõ: bỏ qua dòng bị lock nghĩa là kết quả không phản ánh đúng dữ liệu, nên không dùng cho nghiệp vụ thông thường, mà chỉ để nhiều consumer đọc một bảng kiểu hàng đợi mà không tranh lock (MySQL: Locking Reads, PostgreSQL: SELECT, The Locking Clause).
  • Replication. MySQL coi câu có SKIP LOCKED hoặc NOWAIT là không an toàn với statement-based replication (MySQL: Locking Reads). Binlog mặc định dạng ROW nên không bị ảnh hưởng (Replication).
  • Có thể trả ít dòng hơn LIMIT. Gặp nhiều dòng đang bị lock thì kết quả có thể ít hơn 10 dòng, thậm chí rỗng dù hàng đợi vẫn còn job. Worker coi kết quả rỗng là “tạm hết việc” rồi hỏi lại sau.

Giữ transaction trong lúc xử lý, hay lấy job rồi commit ngay ​

Đây là quyết định thiết kế quan trọng nhất.

Cách A: xử lý trong transactionCách B: lấy job bằng lease rồi commit
Cách làmBEGIN, lock job bằng SKIP LOCKED, xử lý, đánh dấu done, COMMITTransaction ngắn đổi job sang running kèm locked_until, commit; xử lý ngoài transaction; xong thì đánh dấu done
Worker crash giữa chừngTransaction rollback, lock tự nhả, job trở lại hàng đợi ngayJob kẹt ở running tới khi hết lease, có job thu hồi đưa về pending
Mỗi worker giữMột connection và một transaction mở suốt thời gian xử lýConnection chỉ trong vài mili giây lúc lấy và lúc cập nhật job
Hợp vớiJob ngắn (dưới vài giây), chỉ đụng databaseJob dài, gọi API bên ngoài, upload file

Cách A đơn giản, nhưng transaction mở lâu gây hại cho cả database. Trên PostgreSQL, transaction mở lâu chặn VACUUM dọn các dòng cũ. Trên MySQL, nó làm undo log phình ra (MySQL chương 6). Thêm nữa, gọi API bên ngoài khi đang giữ transaction thì lock bị giữ lâu theo API đó. Vì vậy với job thông thường, cách B an toàn hơn.

Cách B trên PostgreSQL: một câu lệnh ​

sql
UPDATE jobs
SET status = 'running', locked_by = $1, attempts = attempts + 1,
    locked_until = now() + interval '5 minutes'
WHERE id IN (
  SELECT id FROM jobs
  WHERE queue = $2 AND status = 'pending' AND run_at <= now()
  ORDER BY priority DESC, run_at, id
  LIMIT 10
  FOR UPDATE SKIP LOCKED
)
RETURNING id, payload, attempts, max_attempts;

Cách B trên MySQL: transaction ngắn hai bước ​

MySQL không có RETURNING, và không cho UPDATE một bảng với subquery đọc chính bảng đó (lỗi 1093), nên phải tách hai câu trong một transaction ngắn:

js
// mysql2/promise; worker chạy vòng lặp này liên tục
async function claimJobs(pool, queue, workerId, batch = 10) {
  const conn = await pool.getConnection()
  try {
    await conn.beginTransaction()
    const [jobs] = await conn.query(
      `SELECT id, payload, attempts, max_attempts FROM jobs
       WHERE queue = ? AND status = 'pending' AND run_at <= NOW()
       ORDER BY priority DESC, run_at, id
       LIMIT ? FOR UPDATE SKIP LOCKED`, [queue, batch])
    if (jobs.length) {
      await conn.query(
        `UPDATE jobs SET status = 'running', locked_by = ?, attempts = attempts + 1,
                locked_until = NOW() + INTERVAL 5 MINUTE
         WHERE id IN (?)`, [workerId, jobs.map((j) => j.id)])
    }
    await conn.commit()
    return jobs
  } catch (err) {
    await conn.rollback()
    throw err
  } finally {
    conn.release()
  }
}

Đánh dấu xong: kiểm tra mình còn giữ job ​

Với cách B, worker có thể bị treo quá locked_until (GC pause, event loop bị block). Job thu hồi trả job về pending, worker khác lấy và chạy lần nữa. Đây đúng là bài toán TTL của distributed lock. Vì vậy khi đánh dấu xong, phải kiểm tra mình còn giữ job:

sql
UPDATE jobs SET status = 'done', locked_by = NULL, locked_until = NULL
WHERE id = ? AND locked_by = ? AND status = 'running';
-- 0 dòng bị ảnh hưởng: job đã bị thu hồi và giao cho worker khác

Câu này chỉ giúp biết chuyện đã xảy ra, chứ không ngăn được việc job chạy hai lần. Vì vậy mọi job phải idempotent, tức là chạy hai lần cũng không gây hại. Ví dụ job gửi email ghi lại email_sent_at và bỏ qua nếu đã gửi; job gọi API thanh toán gửi kèm idempotency key (Chống trùng & race condition). Hàng đợi bằng database, giống mọi hàng đợi khác, chỉ đảm bảo mỗi job chạy ít nhất một lần (at-least-once).

Job treo: lease và thu hồi ​

Worker crash hay bị kill khi đang chạy job thì job nằm mãi ở running. Một job định kỳ (chạy mỗi phút) đưa các job hết lease về hàng đợi:

sql
UPDATE jobs
SET status = 'pending', locked_by = NULL, locked_until = NULL
WHERE status = 'running' AND locked_until < CURRENT_TIMESTAMP;
  • Lease phải dài hơn thời gian chạy bình thường của job, ví dụ gấp 3 đến 5 lần.
  • Job dài thì gia hạn định kỳ (heartbeat): UPDATE jobs SET locked_until = … WHERE id = ? AND locked_by = ?. Nếu 0 dòng bị ảnh hưởng thì worker đã mất job và nên dừng.
  • Lần thu hồi này đã tính vào attempts, nên một job làm worker crash liên tục cuối cùng sẽ vào failed thay vì chạy mãi.

Retry, backoff và dead letter ​

Job lỗi thì không chạy lại ngay mà hẹn lại sau một khoảng tăng dần (exponential backoff). Lỗi tạm thời như API bên ngoài đang quá tải sẽ có thời gian hồi phục. Thêm một phần ngẫu nhiên (jitter), để nhiều job cùng lỗi một lúc không cùng chạy lại một lúc.

js
async function failJob(pool, job, workerId, err) {
  if (job.attempts >= job.max_attempts) {
    // hết lượt: chuyển sang failed (dead letter) để người xem xét
    await pool.query(
      `UPDATE jobs SET status = 'failed', last_error = ?, locked_by = NULL, locked_until = NULL
       WHERE id = ? AND locked_by = ?`, [String(err.stack ?? err), job.id, workerId])
    return
  }
  // 30 giây, 1 phút, 2 phút, 4 phút… cộng thêm tới 20% ngẫu nhiên
  const base = 30 * 2 ** (job.attempts - 1)
  const delay = Math.round(base * (1 + Math.random() * 0.2))
  await pool.query(
    `UPDATE jobs SET status = 'pending', run_at = NOW() + INTERVAL ? SECOND,
            last_error = ?, locked_by = NULL, locked_until = NULL
     WHERE id = ? AND locked_by = ?`, [delay, String(err.message ?? err), job.id, workerId])
}
  • Job ở trạng thái failed đóng vai dead letter queue. Cần có cảnh báo khi số job failed tăng, và có cách chạy lại sau khi sửa lỗi: UPDATE jobs SET status = 'pending', attempts = 0, run_at = CURRENT_TIMESTAMP WHERE ….
  • Poison job là job lần nào cũng lỗi vì dữ liệu sai. Giới hạn max_attempts để nó không chiếm worker mãi.
  • Để tham khảo: Graphile Worker mặc định thử lại 25 lần trong khoảng 3 ngày (Graphile Worker docs). pg-boss có retry với exponential backoff và dead letter queue có thể đưa job chạy lại (pg-boss).

Polling hay nhận thông báo ​

Worker hỏi bảng jobs theo chu kỳ (polling). Chu kỳ ngắn thì job được chạy sớm, nhưng database phải trả lời nhiều câu hỏi rỗng; nhờ index ở trên, mỗi câu hỏi rỗng rất rẻ. Ví dụ Solid Queue mặc định cho worker hỏi mỗi 0,1 giây (Solid Queue).

PostgreSQL có thêm LISTEN/NOTIFY để đánh thức worker ngay khi có job mới, thay vì chờ tới lần hỏi tiếp:

sql
-- trong transaction thêm job
INSERT INTO jobs (...) VALUES (...);
NOTIFY jobs_email;          -- worker đang LISTEN jobs_email nhận được sau khi COMMIT

Theo tài liệu PostgreSQL (NOTIFY):

  • Thông báo chỉ được gửi khi transaction commit, còn rollback thì bị huỷ. Worker không bị đánh thức vì một job chưa tồn tại.
  • Payload mặc định phải ngắn hơn 8000 byte. Hãy gửi id thay vì cả dữ liệu.
  • Hàng đợi thông báo có giới hạn (8 GB với cấu hình chuẩn). Một session LISTEN để transaction mở lâu có thể làm hàng đợi này đầy, và khi đầy thì các transaction gọi NOTIFY lỗi lúc commit.

Thông báo chỉ dùng để đánh thức worker; worker vẫn lấy job bằng SKIP LOCKED như trên. Graphile Worker làm đúng như vậy, và công bố độ trễ thường dưới 3 ms từ lúc thêm job tới lúc job bắt đầu chạy (Graphile Worker docs).

Lưu ý khi dùng PgBouncer: LISTEN không hoạt động ở chế độ transaction pooling, chỉ chạy được ở session pooling (PgBouncer features). MySQL không có cơ chế tương đương, nên dùng polling.

Bảng hàng đợi phình to và chậm dần ​

Mỗi job tạo ra vài lần ghi: thêm, chuyển sang running, chuyển sang done. Nếu không dọn, bảng lớn dần, và tốc độ lấy job giảm dần theo thời gian.

  • Dọn job đã xong bằng job định kỳ: xoá theo lô những job done cũ hơn vài ngày, hoặc chuyển sang bảng lưu trữ. Với lượng job rất lớn, chia bảng theo ngày rồi xoá cả partition cũ, nhanh hơn nhiều so với DELETE (MySQL chương 10).
  • PostgreSQL và transaction mở lâu. Mỗi lần UPDATE hay DELETE để lại một phiên bản dòng cũ (dead tuple), và VACUUM chỉ dọn được khi không còn transaction nào cần tới nó. Brandur Leach mô tả một thí nghiệm: một transaction phân tích chạy lâu ở chỗ khác trong cùng database làm bảng hàng đợi tích tới khoảng 100.000 dead tuple. Do index không biết dòng nào đã chết, câu lấy job phải đi qua cả đống dòng chết. Thời gian lấy job tăng khoảng 15 lần, và sau một giờ hàng đợi tồn khoảng 60.000 job (Postgres Job Queues & Failure By MVCC).
    • Cách phòng: theo dõi và giới hạn transaction mở lâu (idle_in_transaction_session_timeout, statement_timeout); không chạy báo cáo nặng trên cùng database với hàng đợi; dùng partial index cho job pending.
  • MySQL gặp vấn đề tương tự với undo log khi có transaction mở lâu (MySQL chương 6).

Gửi message ra ngoài: transactional outbox ​

Khi cần đẩy sự kiện sang hệ thống khác (Kafka, RabbitMQ, service khác), bảng hàng đợi trở thành outbox. App ghi sự kiện vào bảng outbox trong cùng transaction với dữ liệu. Một tiến trình riêng (message relay) đọc bảng outbox rồi publish ra broker. Theo microservices.io, relay có hai cách:

  • Polling publisher: định kỳ đọc các dòng chưa gửi trong outbox, chính là cách lấy job ở trên.
  • Đọc transaction log: theo dõi binlog (MySQL) hay WAL (PostgreSQL) để bắt dòng mới trong outbox, tức là dùng CDC (MySQL chương 9).

Relay có thể publish một message nhiều lần, ví dụ khi nó crash sau lúc publish nhưng trước lúc đánh dấu đã gửi. Vì vậy consumer phải idempotent, ví dụ ghi lại id các message đã xử lý.

Thứ tự, ưu tiên, chống trùng job ​

  • Ưu tiên: cột priority đứng đầu ORDER BY. Nhưng job ưu tiên thấp có thể chờ mãi nếu job ưu tiên cao đến liên tục. Nếu cần đảm bảo, tách queue riêng với worker riêng.
  • Thứ tự không còn tuyệt đối. Với nhiều worker và SKIP LOCKED, job sau có thể chạy xong trước job trước. Nếu các job của cùng một đơn hàng phải chạy đúng thứ tự, thì cần cơ chế “mỗi đơn chỉ một job running”, hoặc dùng Kafka với key là mã đơn (Kafka giữ thứ tự trong một partition).
  • Chống trùng job: thêm cột job_key có UNIQUE (ví dụ send-receipt:order-123), rồi thêm job bằng upsert hoặc bắt lỗi trùng khoá. Graphile Worker có sẵn tính năng này dưới tên job_key (Graphile Worker docs).

Khi nào chuyển sang message broker ​

Dấu hiệuVì sao database không còn hợp
Lượng job rất lớn và tăng đềuMỗi job là vài lần ghi và lock trên database chính, cạnh tranh tài nguyên với nghiệp vụ
Một sự kiện cần nhiều consumer độc lập cùng nhận (fan-out)Bảng hàng đợi giao mỗi job cho một worker; muốn fan-out phải tự nhân bản job
Cần giữ message lâu để đọc lại từ đầu (replay), làm stream processingĐó là thế mạnh của Kafka (Kafka)
Nhiều service, nhiều đội dùng chung hàng đợiKhông nên cho nhiều service ghi vào database của nhau

Có thể kết hợp: vẫn ghi vào bảng outbox trong transaction để không mất sự kiện, rồi relay chuyển sang broker. Brandur cũng gợi ý cách này: ghi job vào bảng trước, rồi một tiến trình chuyển theo lô sang hàng đợi Redis (Postgres Job Queues & Failure By MVCC). Chi tiết về broker ở Hàng đợi message.

Thư viện có sẵn cho Node.js ​

Thư việnDatabaseĐặc điểm (theo tài liệu của thư viện)
pg-bossPostgreSQL 13+SKIP LOCKED, retry với exponential backoff, dead letter queue, lịch cron, hẹn giờ job
Graphile WorkerPostgreSQLLISTEN/NOTIFY kết hợp SKIP LOCKED, độ trễ thường dưới 3 ms, mặc định 25 lần thử trong khoảng 3 ngày, job_key chống trùng, cron

Với MySQL, hệ sinh thái Node.js chưa có thư viện phổ biến tương đương, nên thường tự viết theo các mẫu trên. Solid Queue của Rails là ví dụ cho thấy cách này chạy tốt trên MySQL 8 (Solid Queue). BullMQ, thư viện queue phổ biến nhất của Node.js, chạy trên Redis chứ không phải database.

Ghi nhớ

  1. Lợi thế lớn nhất của hàng đợi bằng database là thêm job trong cùng transaction với dữ liệu, nên tránh được dual write.
  2. Lấy job bằng FOR UPDATE SKIP LOCKED (MySQL 8.0.1+, PostgreSQL 9.5+). SELECT rồi UPDATE thì chạy trùng; FOR UPDATE không có SKIP LOCKED thì các worker xếp hàng chờ nhau.
  3. Job dài hoặc gọi API ngoài thì lấy job bằng lease rồi commit ngay; có job thu hồi job hết lease; khi đánh dấu xong thì kiểm tra locked_by.
  4. Hàng đợi chỉ đảm bảo at-least-once, nên mọi job phải idempotent.
  5. Retry với exponential backoff kèm jitter, giới hạn số lần, job hết lượt vào failed làm dead letter.
  6. Dọn job đã xong, tránh transaction mở lâu (nhất là trên PostgreSQL), và chuyển sang broker khi cần fan-out, replay hoặc lượng job quá lớn.

Nguồn tham khảo ​

Đã đối chiếu (đọc lại nguồn ngày 2026-10-15):

NguồnDùng cho phần
MySQL 8.4 Reference Manual, Locking Reads: NOWAIT và SKIP LOCKEDSKIP LOCKED bỏ dòng bị lock khỏi kết quả; chỉ hợp với bảng kiểu hàng đợi; không an toàn với statement-based replication; dùng được với FOR UPDATE và FOR SHARE
MySQL blog, MySQL 8.0.1: Using SKIP LOCKED and NOWAIT to handle hot rowsSKIP LOCKED và NOWAIT có từ MySQL 8.0.1
PostgreSQL docs, SELECT: The Locking ClauseSKIP LOCKED, NOWAIT, cảnh báo kết quả không nhất quán, chỉ hợp với bảng kiểu hàng đợi
PostgreSQL docs, NOTIFYGửi khi commit, payload dưới 8000 byte, hàng đợi 8 GB, transaction mở lâu làm hàng đợi đầy
PgBouncer featuresLISTEN không dùng được ở transaction pooling
Brandur Leach, Postgres Job Queues & Failure By MVCC (2015)Transaction mở lâu làm dead tuple tích tụ, thời gian lấy job tăng khoảng 15 lần, hàng đợi tồn khoảng 60.000 job sau một giờ; các cách giảm thiểu
microservices.io, Transactional outboxDual write, outbox, polling publisher và đọc transaction log, relay có thể gửi trùng nên consumer phải idempotent
pg-boss READMEPostgreSQL 13+, SKIP LOCKED, retry với exponential backoff, dead letter queue, cron
Graphile Worker docsLISTEN/NOTIFY kết hợp SKIP LOCKED, độ trễ dưới 3 ms, 25 lần thử trong khoảng 3 ngày, job_key, cron
Solid Queue READMEChạy trên MySQL, PostgreSQL, SQLite; dùng FOR UPDATE SKIP LOCKED khi có; yêu cầu MySQL 8+, MariaDB 10.6+, PostgreSQL 9.5+; worker hỏi mỗi 0,1 giây

Đã chạy thử (2026-10-15, MySQL 8.0.46): sửa một bảng với subquery đọc chính bảng đó báo ERROR 1093. Tài liệu MySQL mô tả giới hạn này ở Restrictions on Subqueries. Chi tiết ở Viết SQL khi phỏng vấn.

Từ kiến thức chung, chưa đối chiếu lần này:

  • PostgreSQL có SKIP LOCKED từ 9.5 (chỉ được Solid Queue xác nhận gián tiếp, chưa đọc release notes của PostgreSQL).
  • SKIP LOCKED có thể trả ít dòng hơn LIMIT, suy ra từ cách hoạt động; tài liệu PostgreSQL không nói thẳng.
  • Thiết lập idle_in_transaction_session_timeout của PostgreSQL.
  • Solid Queue là backend mặc định của Active Job trong Rails 8.
  • BullMQ chạy trên Redis.