Postgres SKIP LOCKED: Xoá Bỏ Gánh Nặng Message Broker

Tìm hiểu cách mệnh đề FOR UPDATE SKIP LOCKED trong PostgreSQL giúp dựng hàng đợi tác vụ an toàn giao dịch mà không cần thêm Redis hay RabbitMQ.

Postgres SKIP LOCKED: Xoá Bỏ Gánh Nặng Message Broker
Trong bài viết này

Phản xạ quen thuộc của nhiều kỹ sư phần mềm khi cần xử lý tác vụ nền (background job) là khởi tạo ngay một cụm Redis, RabbitMQ hoặc AWS SQS. Với các hệ thống luồng dữ liệu khổng lồ hàng trăm nghìn sự kiện mỗi giây, message broker chuyên dụng là bắt buộc; tuy nhiên, đối với phần lớn ứng dụng web thông thường xử lý gửi email, xuất file báo cáo, đồng bộ webhook và xử lý thanh toán, việc bổ sung thêm một hệ thống trung gian phân tán mang lại gánh nặng vận hành lẫn rủi ro sai lệch dữ liệu không hề nhỏ.

Nhờ mệnh đề FOR UPDATE SKIP LOCKED có sẵn từ PostgreSQL 9.5, bạn hoàn toàn có thể xây dựng hàng đợi tác vụ tin cậy, an toàn giao dịch và hiệu năng cao ngay trên cơ sở dữ liệu hiện có.

Cạm bẫy Dual-Write khi dùng Message Broker bên ngoài

Khi dữ liệu nghiệp vụ nằm trong PostgreSQL còn hàng đợi công việc lại nằm ở Redis hay SQS, mọi thao tác bất đồng bộ đều biến thành bài toán ghi kép (dual-write) phân tán. Lấy ví dụ về luồng tạo đơn hàng: ứng dụng lưu hóa đơn vào bảng invoices trong Postgres và gửi tác vụ send_invoice_email sang Redis.

Nếu giao dịch cơ sở dữ liệu đã commit thành công nhưng kết nối mạng đến Redis gặp sự cố khiến việc đẩy job thất bại, email sẽ không bao giờ được gửi đi. Ngược lại, nếu bạn đẩy job vào Redis trước nhưng giao dịch Postgres sau đó bị rollback do vi phạm ràng buộc dữ liệu, worker sẽ lấy công việc và xử lý một đơn hàng không hề tồn tại. Thậm chí khi cả hai đều thành công, một worker siêu nhanh bên Redis có thể truy vấn Postgres trước khi giao dịch tạo đơn kịp commit xong, gây ra lỗi "không tìm thấy bản ghi" do hiện tượng race condition.

Để khắc phục triệt để vấn đề này trên hệ thống phân tán, bạn phải triển khai mẫu Transactional Outbox phức tạp kết hợp công cụ bắt sự kiện thay đổi dữ liệu (CDC). Ngược lại, nếu lưu trực tiếp hàng đợi vào một bảng trong PostgreSQL, việc đưa tác vụ vào hàng đợi chỉ là một câu lệnh INSERT nằm chung trong một giao dịch ACID với dữ liệu nghiệp vụ: tất cả cùng thành công hoặc cùng hủy bỏ.

Nút thắt cổ chai khi khóa dữ liệu theo cách truyền thống

Sơ đồ minh họa các worker xử lý song song bỏ qua các hàng đang bị khóa

Việc tạo bảng hàng đợi đơn giản với cột trạng thái (pending, running, completed) thường gặp phải bế tắc lớn về xử lý đồng thời.

Nếu hai tiến trình worker cùng truy vấn bảng tại cùng một thời điểm với câu lệnh cơ bản:

SELECT id, payload FROM jobs 
WHERE status = 'pending' 
ORDER BY id ASC LIMIT 1;

Cả hai worker sẽ đọc cùng một bản ghi trước khi kịp cập nhật trạng thái sang running, dẫn đến việc xử lý trùng lặp tác vụ. Khi dùng khóa bi quan truyền thống với FOR UPDATE, lỗi xử lý trùng được loại bỏ nhưng tính song song của worker bị triệt tiêu hoàn toàn. Khi Worker A thực hiện SELECT ... FOR UPDATE, Postgres sẽ khóa bản ghi đó lại. Worker B chạy câu truy vấn tương tự sẽ bị treo (block) để chờ Worker A hoàn thành toàn bộ giao dịch mới được chạy tiếp. Toàn bộ dàn worker song song bị ép thành một hàng dọc đơn lẻ.

Cơ chế hoạt động của FOR UPDATE SKIP LOCKED

Mệnh đề SKIP LOCKED giải quyết nút thắt cổ chai này một cách triệt để. Khi câu truy vấn sử dụng FOR UPDATE SKIP LOCKED, PostgreSQL duyệt qua các hàng khớp điều kiện theo thứ tự sắp xếp. Nếu phát hiện hàng dữ liệu đang bị khóa bởi một giao dịch khác, Postgres sẽ không dừng lại chờ đợi; nó lập tức bỏ qua hàng đó và chuyển sang khóa bản ghi kế tiếp còn trống.

Nhờ cơ chế này, các worker có thể lấy tác vụ hoàn toàn độc lập, không chặn lẫn nhau ở tầng ứng dụng nhưng vẫn duy trì tính toàn vẹn và an toàn ở tầng lưu trữ cơ sở dữ liệu.

Mẫu câu lệnh nhận việc nguyên tử bằng CTE

Trong môi trường thực tế, worker cần nhận tác vụ, đánh dấu trạng thái đang chạy và lấy dữ liệu payload chỉ trong một truy vấn duy nhất thông qua Common Table Expression (CTE):

WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'pending'
    AND run_at <= NOW()
  ORDER BY priority DESC, id ASC
  LIMIT 1
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs
SET status = 'running',
    locked_at = NOW(),
    attempts = attempts + 1
FROM next_job
WHERE jobs.id = next_job.id
RETURNING jobs.id, jobs.payload, jobs.attempts;

Câu lệnh này đảm bảo duy nhất một worker nhận được công việc, khóa bản ghi, chuyển trạng thái sang running và trả về payload ngay lập tức. Giao dịch nhận việc commit ngay sau câu lệnh này giúp giải phóng kết nối cơ sở dữ liệu, không giữ lock kéo dài trong lúc worker thực thi tác vụ nặng. Nếu tiến trình worker bị crash giữa chừng, một tác vụ quét định kỳ (reaper job) có thể tìm các bản ghi có locked_at quá hạn để khôi phục trạng thái về pending cho lượt thử tiếp theo.

Tối ưu hiệu năng và giới hạn vận hành

Để hàng đợi chạy mượt mà trên Postgres trong thời gian dài, bạn cần chú ý hai yếu tố cốt lõi:

  1. Sử dụng Partial Index: Một chỉ mục bộ phận tổng hợp giúp câu lệnh tìm kiếm chỉ quét các bản ghi chưa xử lý mà không quét toàn bảng:
CREATE INDEX idx_jobs_pending 
ON jobs (priority DESC, run_at ASC, id ASC) 
WHERE status = 'pending';

Do các tác vụ đã hoàn thành không nằm trong chỉ mục này, tốc độ truy vấn nhận việc luôn duy trì ở mức dưới vài mili-giây kể cả khi bảng lưu trữ hàng triệu bản ghi lịch sử.

  1. Kiểm soát Table Bloat: Tần suất cập nhật trạng thái liên tục sẽ tạo ra nhiều phiên bản hàng chết (dead tuples) trong cơ chế MVCC của PostgreSQL. Bạn nên cấu hình autovacuum hoạt động tích cực hơn cho bảng jobs, hoặc định kỳ dọn dẹp các tác vụ thành công sang bảng lưu trữ riêng.

Với tải xử lý dưới 5.000 đến 10.000 tác vụ mỗi giây, giải pháp SKIP LOCKED trên PostgreSQL mang lại sự ổn định tuyệt đối, khả năng giám sát trực quan bằng SQL thuần và loại bỏ hoàn toàn chi phí bảo trì một cụm message broker riêng biệt.

Nguồn tham khảo

  1. postgresql.org
  2. postgresql.org

Có hỗ trợ AI · PKN duyệt lại

0

Phản hồi

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