Mỗi khi hệ thống phát sinh nhu cầu xử lý tác vụ ngầm (background jobs)—chẳng hạn như gửi email xác thực, xuất báo cáo PDF, đồng bộ webhook hay xử lý dữ liệu định kỳ—phản xạ phổ biến của nhiều kỹ sư backend là thiết lập ngay một hệ thống message broker riêng biệt như Redis (kết hợp BullMQ hoặc Sidekiq), RabbitMQ hoặc Amazon SQS. Mặc dù các broker chuyên dụng rất mạnh mẽ ở quy mô lớn, chúng lại kéo theo chi phí vận hành đáng kể: thêm một dịch vụ lưu trữ trạng thái cần giám sát, sao lưu định kỳ, phân quyền và cấu hình cảnh báo sự cố.
Đặc biệt hơn, việc tách biệt hàng đợi ra khỏi cơ sở dữ liệu chính dễ dẫn tới lỗi bất nhất dữ liệu do ghi hai nơi (dual-write problem): bản ghi đơn hàng đã lưu thành công trong cơ sở dữ liệu nhưng tiến trình đẩy tin nhắn vào hàng đợi bị gián đoạn mạng, khiến tác vụ nền bị thất lạc vĩnh viễn.
Nếu dự án của bạn đang dùng PostgreSQL làm cơ sở dữ liệu chính, bạn hoàn toàn có thể xây dựng một hàng đợi tác vụ hoàn chỉnh, an toàn và hỗ trợ xử lý song song cực tốt ngay trên bảng dữ liệu hiện có nhờ vào mệnh đề FOR UPDATE SKIP LOCKED.
Cạm Bẫy Của Hàng Đợi Database Truyền Thống
Trong quá khứ, việc dùng bảng SQL làm hàng đợi thường bị xem là phản mẫu thiết kế (anti-pattern) vì vấn đề nghẽn khóa tranh chấp. Cách tiếp cận ngây thơ thường được viết như sau:
-- Worker 1 và Worker 2 cùng thăm dò một thời điểm
SELECT id, payload FROM background_jobs
WHERE status = 'pending'
ORDER BY scheduled_at ASC
LIMIT 1;
Khi có nhiều worker cùng chạy câu lệnh trên tại một thời điểm, tất cả đều đọc được cùng một bản ghi công việc cũ nhất, dẫn đến tình trạng nhiều tiến trình cùng thực thi một tác vụ và gây xung đột dữ liệu.
Để khắc phục, giải pháp đầu tiên thường là thêm khóa dòng FOR UPDATE:
SELECT id, payload FROM background_jobs
WHERE status = 'pending'
ORDER BY scheduled_at ASC
LIMIT 1
FOR UPDATE;
FOR UPDATE giải quyết triệt để lỗi trùng lặp, nhưng lại tạo ra hiện tượng thắt cổ chai nghiêm trọng. Khi Worker 1 khóa bản ghi đang chờ xử lý đầu tiên, Worker 2 khi chạy câu truy vấn sẽ không tự động lấy bản ghi tiếp theo; nó bị hệ quản trị khóa chặn (block) và phải đứng chờ cho đến khi Worker 1 hoàn tất toàn bộ transaction. Dù bạn tăng số lượng worker lên bao nhiêu, khả năng xử lý song song vẫn bị biến thành một hàng dọc đơn lẻ.
SKIP LOCKED: Giải Phóng Năng Lực Xử Lý Song Song

PostgreSQL đã cung cấp giải pháp hoàn hảo cho bài toán này với tùy chọn SKIP LOCKED đi kèm mệnh đề khóa. Cơ chế này hoạt động rất trực quan: khi worker thực thi truy vấn tìm kiếm bản ghi, PostgreSQL duyệt qua các dòng theo thứ tự sắp xếp, tự động bỏ qua (skip) những dòng đang bị transaction khác giữ khóa và trả về ngay lập tức dòng trống kế tiếp.
BEGIN;
SELECT id, payload
FROM background_jobs
WHERE status = 'pending'
ORDER BY scheduled_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;
-- Tiến trình worker thực thi logic tại ứng dụng...
UPDATE background_jobs
SET status = 'completed', updated_at = NOW()
WHERE id = :claimed_id;
COMMIT;
Nhờ SKIP LOCKED, mười worker cùng truy vấn cơ sở dữ liệu sẽ nhận về mười tác vụ hoàn toàn độc lập trong cùng một thời điểm mà không worker nào phải chờ đợi. Trong trường hợp tiến trình worker bị crash đột ngột giữa chừng, kết nối database bị ngắt, PostgreSQL sẽ tự động rollback transaction và nhả khóa của dòng dữ liệu đó ra. Bản ghi ngay lập tức quay về trạng thái khả dụng để worker khác tiếp quản mà không cần viết thêm logic dọn dẹp phức tạp.
Mẫu Thiết Kế Nguyên Tử Với CTE Trong Thực Tế
Nếu tác vụ nghiệp vụ kéo dài vài chục giây hoặc vài phút, việc giữ kết nối transaction mở liên tục từ lúc bắt đầu đến lúc hoàn tất là điều không nên, vì nó làm lãng phí connection pool và giữ khóa bảng lâu không cần thiết.
Giải pháp tối ưu trong môi trường production là dùng một câu lệnh UPDATE nguyên tử kết hợp Common Table Expression (CTE) để chiếm quyền xử lý (claim) chỉ trong một transaction ngắn vài mili-giây:
WITH next_job AS (
SELECT id
FROM background_jobs
WHERE status = 'pending'
AND scheduled_at <= NOW()
ORDER BY scheduled_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE background_jobs j
SET status = 'processing',
locked_at = NOW(),
attempts = attempts + 1
FROM next_job
WHERE j.id = next_job.id
RETURNING j.id, j.payload, j.attempts;
Sau khi nhận được payload từ câu lệnh trên, ứng dụng đóng transaction ngay lập tức, tiến hành xử lý công việc ngầm, và chỉ cập nhật trạng thái completed khi hoàn tất. Bạn có thể thiết lập thêm một trường locked_at để xây dựng cơ chế timeout: nếu một tác vụ ở trạng thái processing quá 15 phút mà chưa xong do worker chết âm thầm, một tiến trình dọn dẹp (reaper) sẽ tự động reset trạng thái về pending để tránh mất việc.
Đánh Thức Hàng Đợi Sự Kiện Bằng LISTEN Và NOTIFY
Việc thăm dò cơ sở dữ liệu (polling) liên tục sau mỗi vài trăm mili-giây có thể gây lãng phí chu kỳ CPU khi hệ thống nhàn rỗi. Để giải quyết vấn đề độ trễ mà không tạo áp lực truy vấn thừa, bạn có thể kết hợp hàng đợi với cơ chế Pub/Sub tích hợp sẵn của PostgreSQL qua hai lệnh LISTEN và NOTIFY.
Mỗi khi một tác vụ mới được ghi vào bảng background_jobs, transaction ghi có thể phát tín hiệu thông báo:
NOTIFY job_channel, 'new_job';
Các worker ở trạng thái rảnh chỉ cần lắng nghe thông qua lệnh LISTEN job_channel; và chuyển sang trạng thái ngủ (sleep). Ngay khi có thông báo đẩy tới, worker lập tức thức dậy, thực thi câu truy vấn CTE nhận việc, xử lý hết các bản ghi có sẵn và quay lại trạng thái ngủ khi hàng đợi trống. Mô hình này giúp loại bỏ hoàn toàn chi phí polling liên tục nhưng vẫn đảm bảo tác vụ được nhận diện với độ trễ tính bằng mili-giây.
Tối Ưu Hóa Chỉ Mục (Partial Index)
Khi bảng hàng đợi lưu lại lịch sử hàng trăm nghìn hoặc hàng triệu tác vụ đã hoàn thành, câu lệnh tìm kiếm sẽ chậm đi nếu quét tuần tự toàn bảng. Việc tạo một partial index (chỉ mục một phần) chỉ lọc các dòng status = 'pending' sẽ giúp câu truy vấn đạt hiệu năng tối đa:
CREATE INDEX idx_jobs_pending
ON background_jobs (scheduled_at ASC)
WHERE status = 'pending';
Partial index này chỉ lưu các công việc chưa chạy, giúp kích thước chỉ mục luôn cực kỳ nhỏ gọn, nằm trọn trong bộ nhớ đệm RAM (shared_buffers) và giúp câu truy vấn trả kết quả gần như tức thì.
Ranh Giới Kỹ Thuật: Khi Nào Nên Tách Khỏi Postgres?
Ưu điểm lớn nhất của mô hình hàng đợi trên PostgreSQL là tính toàn vẹn giao dịch (ACID). Bạn có thể lưu một đơn hàng mới và đẩy tác vụ gửi email thông báo vào bảng background_jobs trong cùng một transaction duy nhất. Cả hai cùng thành công hoặc cùng thất bại, loại bỏ hoàn toàn các lỗi lệch dữ liệu thường gặp khi dùng message broker bên ngoài. Với quy mô hàng nghìn tác vụ mỗi phút, giải pháp này hoạt động vô cùng ổn định và tiết kiệm tài nguyên hạ tầng.
Tuy nhiên, PostgreSQL vốn là hệ quản trị cơ sở dữ liệu quan hệ, không được tối ưu cho việc liên tục thêm, cập nhật và xóa hàng chục nghìn bản ghi mỗi giây. Với lưu lượng quá lớn, cơ chế MVCC của Postgres sẽ tạo ra nhiều dead tuples, khiến tiến trình VACUUM phải làm việc liên tục và dễ gây phình dữ liệu (table bloat).
Khi thông lượng hệ thống vượt qua mốc hàng trăm tác vụ mỗi giây, việc chuyển đổi sang các hệ thống chuyên dụng như Kafka hay SQS là điều cần thiết. Nhưng ở quy mô vừa và nhỏ của phần lớn các sản phẩm hiện nay, tận dụng FOR UPDATE SKIP LOCKED chính là giải pháp kỹ thuật tinh gọn, thực dụng và an toàn nhất mà bạn có thể áp dụng ngay hôm nay.



Đang tải bình luận…