Phản xạ quen thuộc của nhiều kỹ sư khi cần xử lý tác vụ bất đồng bộ là thêm ngay một message broker như Redis, RabbitMQ hay Amazon SQS vào hệ thống. Tuy nhiên, với phần lớn các ứng dụng web thông thường, quyết định này mang lại sự phức tạp hạ tầng không đáng có và tạo ra lỗi không đồng nhất dữ liệu mà mệnh đề FOR UPDATE SKIP LOCKED trong PostgreSQL có thể xử lý gọn gàng chỉ với một câu truy vấn SQL.
Khi bạn tách riêng database lưu dữ liệu chính và broker lưu hàng đợi tác vụ, trạng thái ứng dụng bị phân mảnh. Xét trường hợp một luồng đăng ký người dùng cần gửi email chào mừng: nếu ghi thông tin người dùng vào database thành công nhưng quá trình đẩy job sang Redis bị ngắt kết nối mạng, người dùng sẽ không nhận được email. Ngược lại, nếu đẩy job sang hàng đợi trước khi transaction database hoàn tất, worker có thể lấy job ra xử lý khi bản ghi người dùng chưa hề tồn tại. Để giải quyết triệt để lỗi phân tán này, giải pháp kinh điển là mô hình Transactional Outbox—nghĩa là lại phải ghi job vào một bảng trong PostgreSQL trước khi chuyển tiếp.
Nút thắt đồng thời của hàng đợi database truyền thống
Trước đây, các đội ngũ kỹ thuật thường e ngại dùng database quan hệ làm queue vì việc nhiều worker cùng đọc bảng dễ dẫn đến xung đột khóa (locking contention) hoặc xử lý trùng lặp. Trong cách tiếp cận ngây thơ, các worker cùng quét tìm bản ghi chưa xử lý:
-- Worker 1 và Worker 2 chạy cùng một mili-giây
SELECT id, payload FROM jobs WHERE status = 'pending' ORDER BY created_at LIMIT 1;
-- Cả 2 worker đều nhận được id = 42
UPDATE jobs SET status = 'processing' WHERE id = 42;
Vì lệnh SELECT thông thường không giữ khóa độc quyền, cả hai worker đều thấy bản ghi số 42 ở trạng thái chờ và cùng thực thi logic xử lý, dẫn đến việc tác vụ bị chạy hai lần.
Nếu khắc phục bằng cách thêm khóa hàng SELECT ... FOR UPDATE, bạn giải quyết được việc trùng lặp nhưng lại phá hủy hiệu năng xử lý song song. Khi Worker 1 khóa bản ghi cũ nhất, Worker 2 bắt buộc phải dừng lại và chờ transaction của Worker 1 kết thúc (commit hoặc rollback) thì mới được kiểm tra tiếp. Một cụm 20 worker xử lý nền lập tức biến thành một hàng dọc tuần tự, triệt tiêu hoàn toàn khả năng mở rộng quy mô.
Cơ chế giải phóng tắc nghẽn của FOR UPDATE SKIP LOCKED

Từ phiên bản PostgreSQL 9.5, mệnh đề FOR UPDATE SKIP LOCKED đã thay đổi hoàn toàn bài toán này. Thay vì bắt câu lệnh truy vấn phải đứng chờ khi gặp một hàng đang bị transaction khác giữ khóa, bộ xử lý của PostgreSQL sẽ bỏ qua hàng đó và lập tức tìm bản ghi tiếp theo thỏa mãn điều kiện lọc.
Nhờ vậy, hàng chục worker độc lập có thể query vào cùng một bảng tác vụ tại cùng một thời điểm mà không hề bị block hay gặp deadlock. Mỗi worker nhận đúng một job riêng biệt mà không cần thêm tầng khóa phân tán (distributed locking) hay điều phối phức tạp nào ở tầng ứng dụng.
Đây là cấu trúc truy vấn chuẩn để lấy và nhận quyền xử lý job một cách nguyên tử (atomic) trong một câu lệnh duy nhất:
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'pending'
AND run_at <= NOW()
ORDER BY priority DESC, created_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET
status = 'processing',
started_at = NOW(),
attempts = attempts + 1
FROM next_job
WHERE jobs.id = next_job.id
RETURNING jobs.*;
Khóa hàng này gắn liền với transaction của cơ sở dữ liệu. Nếu worker đang xử lý mà bị crash hoặc đứt kết nối mạng giữa chừng, transaction tự động rollback, khóa được giải phóng và job lập tức quay lại trạng thái chờ cho worker khác nhận lại. Bạn không cần tự viết cơ chế phát hiện worker chết phức tạp qua heartbeat.
Chiến lược đánh chỉ mục và tối ưu hiệu năng production
Một bảng làm hàng đợi thường có tần suất ghi, cập nhật và xóa rất dày đặc. Nếu không đánh index hợp lý, hiện tượng phình dữ liệu (table bloat) và quét toàn bảng (sequential scan) sẽ làm sụt giảm tốc độ truy vấn.
Để đảm bảo câu lệnh lấy job luôn đạt độ phức tạp $O(1)$ hoặc $O(\log N)$, giải pháp tối ưu là sử dụng partial index chỉ đánh dấu các tác vụ chưa hoàn thành:
CREATE INDEX idx_jobs_pending
ON jobs (priority DESC, created_at ASC)
WHERE status = 'pending';
Chỉ mục này chỉ lưu trữ các bản ghi đang chờ xử lý, giúp kích thước index luôn rất nhỏ và nằm trọn vẹn trong bộ nhớ RAM, bất kể bảng lịch sử đã tích lũy hàng triệu job đã hoàn tất.
Ngoài ra, để tránh bloat do các phiên bản dữ liệu cũ (dead tuples) sinh ra liên tục từ lệnh UPDATE, hãy cấu hình tiến trình dọn dẹp tự động (autovacuum) riêng cho bảng queue với tần suất tích cực hơn mặc định:
ALTER TABLE jobs SET (autovacuum_vacuum_scale_factor = 0.05);
Khi nào mới thực sự cần chuyển sang Message Broker chuyên dụng?
Một hàng đợi chạy trực tiếp trên PostgreSQL với SKIP LOCKED có thể xử lý dễ dàng từ vài trăm đến vài nghìn tác vụ mỗi giây trên một cấu hình máy chủ vừa phải. Các thư viện nổi tiếng như River (Go), Oban (Elixir) hay GoodJob (Ruby) đều vận hành rất mượt mà trên nền tảng này trong nhiều hệ thống thực tế lớn.
Tuy vậy, bạn nên cân nhắc chuyển đổi sang Kafka hoặc RabbitMQ khi chạm đến các giới hạn sau:
- Thông lượng cực lớn: Khi hệ thống cần tiếp nhận và xử lý hàng chục nghìn thông điệp mỗi giây, chi phí ghi log WAL và trang dữ liệu MVCC của PostgreSQL sẽ làm nghẽn I/O đĩa cứng.
- Mô hình Pub/Sub phân nhánh phức tạp: Khi một sự kiện cần được sao chép và phân phối tức thì đến hàng chục nhóm người nhận độc lập với độ trễ tính bằng mili-giây.
- Tác vụ chạy quá lâu: Giữ một transaction mở suốt nhiều giờ để chờ một task nặng có thể ngăn cản tiến trình autovacuum dọn dẹp ID transaction cũ, gây ảnh hưởng đến toàn bộ cụm database.
Với các nhu cầu web thông thường—gửi email, xuất file báo cáo, gọi webhook của bên thứ ba hay xử lý xác thực thanh toán—việc tận dụng PostgreSQL với FOR UPDATE SKIP LOCKED mang lại sự an toàn tuyệt đối về mặt ACID, đơn giản hóa việc sao lưu dữ liệu, giảm thiểu số lượng dịch vụ phải vận hành và giúp toàn bộ kiến trúc hệ thống gọn gàng hơn rất nhiều.

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