📮 Hàng đợi cho tác vụ nặng: đẩy việc dài ra background

By Better Wise

Bài trước bạn đã đặt rate limit ở cửa vào để chặn tải xấu. Nhưng rate limit không cứu được hệ thống khi chính tác vụ của bạn quá nặng: một request hợp lệ mất 800ms vẫn nhân lên thành hàng trăm request treo khi lưu lượng tăng. Bài này chỉ cho bạn cách tách phần việc dài ra khỏi request và để worker làm sau.

Sau bài này, bạn sẽ:

  • Nhận ra tác vụ nào nên đẩy ra background, tác vụ nào phải giữ trong request.
  • Mô tả được luồng API → queue → worker cho ba ca quen thuộc: gửi email, resize ảnh, gọi API bên thứ ba.
  • Biết một job cần mang theo những gì để chạy lại an toàn.
  • Đọc được queue depth và tuổi job để biết nút thắt tiếp theo nằm ở đâu.

Một request 800ms kéo theo cả cụm server

Sự cố tôi nhớ nhất: API POST /orders chạy ổn ở 45ms. Team thêm "gửi email xác nhận" vào cuối handler. Gọi SMTP mất trung bình 800ms. p95 nhảy từ 60ms lên 1.1s, rồi 502 xuất hiện lúc cao điểm.

Toán đơn giản: số request đồng thời ≈ RPS × thời gian xử lý. Ở 200 RPS và 800ms, bạn cần 160 chỗ xử lý cùng lúc. Thread pool chỉ có 100, phần còn lại xếp hàng, chờ, timeout. Thời gian xử lý trong request chính là thứ bị nhân lên theo lưu lượng.

Việc Làm ngay trong request Đẩy ra background
Đọc dữ liệu để trả về cho người dùng Có Không
Gửi email, thông báo, push Không Có
Resize ảnh, nén video, xuất PDF Không Có
Gọi API bên thứ ba có thể chậm Không Có
Ghi audit log, đồng bộ sang hệ thống khác Không Có

Đường đi của một job: API trả lời trước, worker làm sau

flowchart TD
  Client["👤<br/>Client"] --> API["⚡<br/>API server"]
  API --> DB[("🗄️<br/>Database")]
  API --> Q["📥<br/>Queue"]
  Q --> W1["⚙️<br/>Worker 1"]
  Q --> W2["⚙️<br/>Worker 2"]
  W1 --> SMTP["📧<br/>Email service"]
  W2 --> Storage["🖼️<br/>Object storage"]
  W1 --> DLQ["☠️<br/>Dead letter queue"]

Bốn bước lặp lại ở mọi hệ thống:

  1. API tạo một job với payload đầy đủ, đẩy vào queue, thường mất vài ms.
  2. API trả về ngay cho người dùng, kèm job id hoặc trạng thái "đang xử lý".
  3. Worker lấy job ra, xử lý, cập nhật kết quả vào database.
  4. Nếu xử lý lỗi, job được thử lại; lỗi quá số lần cho phép thì vào dead letter queue.

Queue phổ biến: Redis, RabbitMQ, SQS, Kafka. Điểm chung là chúng bảo đảm at-least-once, nghĩa là job sẽ chạy ít nhất một lần, có thể chạy hai lần.

Ba ca thực tế

Gửi email xác nhận đơn hàng. Trước: handler gọi SMTP 800ms, cộng vào mọi lần đặt hàng. Sau: API đẩy job 3ms, tổng latency về 48ms. Tám worker gửi song song, khách nhận email sau vài giây. Nút thắt tiếp theo là tốc độ của nhà cung cấp email, không còn là API của bạn.

Resize ảnh. Đây là việc CPU-bound: một ảnh 5MB mất khoảng 2.5 giây. Trên máy 4 vCPU, bạn xử lý tối đa khoảng 1.6 ảnh mỗi giây, và trong lúc resize thì mọi request khác cũng chậm theo vì hết CPU. Một lần upload 50 ảnh là 125 giây. Sau khi tách ra: API chỉ lưu file gốc (200ms) và trả URL tạm, worker resize trên cụm riêng, client nhận thông báo khi xong.

Gọi API bên thứ ba. Bạn đặt timeout 30 giây cho một API đối tác. Hôm đối tác chậm, 20 request đồng thời treo 20 thread trong 30 giây, dù người dùng chỉ chờ một thao tác nhỏ. Đẩy ra background thì API của bạn vẫn nhẹ, còn worker chịu trận với retry và backoff.

Warning: Queue không làm công việc ít đi. Nó chỉ làm API trả lời nhanh hơn. Toàn bộ việc vẫn phải được worker xử lý, và worker chính là nút thắt tiếp theo của bạn.

Job cần mang theo những gì

Một job tốt tự chạy được một mình, không dựa vào bộ nhớ RAM của server đã tạo ra nó:

  • Payload đủ dữ liệu: order_id, user_id, email, đường dẫn file.
  • Idempotency key, thường là một mã duy nhất theo nghiệp vụ, để worker kiểm tra "job này đã chạy chưa".
  • Số lần retry còn lại và thời điểm thử lại (backoff).
  • Timeout riêng cho job.

Vì broker chỉ bảo đảm at-least-once, worker phải idempotent. Ví dụ email: trước khi gửi, kiểm tra bảng sent_notifications theo order_id. Chạy hai lần thì lần thứ hai bỏ qua.

Khi hàng đợi dài ra: nút thắt đã chuyển chỗ

Giả sử worker xử lý 20 job/giây nhưng lưu lượng sinh ra 50 job/giây. Chênh 30 job/giây, sau 10 phút queue có thêm 18.000 job. Khách chờ 15 phút mới nhận được email.

Lúc này API vẫn nhanh, biểu đồ latency vẫn đẹp, nhưng hệ thống đang ốm. Hai con số cần xem:

Chỉ số Nói lên điều gì
Queue depth Đang tồn bao nhiêu việc
Tuổi job cũ nhất Người dùng đang chờ bao lâu, đây là chỉ số quan trọng hơn

Nếu 30 giây là chấp nhận được, thêm worker hoặc scale ngang cụm worker. Nếu không, giảm việc: gộp nhiều email thành một, batch 50 ảnh một lần, hoặc bỏ những job không còn giá trị.

Ba cái bẫy khi đưa việc ra background

  • Đừng đẩy mọi thứ vào queue. Việc cần kết quả ngay để trả cho người dùng thì phải ở lại trong request.
  • Đặt timeout của worker nhỏ hơn visibility timeout của broker. Không thì job bị giao lại cho worker khác và chạy hai lần.
  • Luôn có dead letter queue và báo động trên số job trong đó. Retry vô hạn khi worker lỗi sẽ làm queue phình mãi.
  • Đừng để job phụ thuộc session trong RAM của API server. Đây chính là lý do server cần stateless.
  • Theo dõi đúng ba số: queue depth, tuổi job cũ nhất, tỉ lệ job lỗi.

Chốt lại

Thời gian xử lý nằm trong request sẽ bị nhân lên theo lưu lượng. Tách việc dài ra queue và worker giúp API trả lời trong vài chục ms, nhưng chỉ là dịch nút thắt sang phía worker. Job phải tự chứa dữ liệu, phải idempotent, và phải có đường thoát khi lỗi.

Bài tiếp theo đi vào phần đọc số: RPS, latency, p95 và error rate, để bạn nhìn biểu đồ và biết hệ thống đang khỏe hay đang trên đà sập.

  1. Số request đồng thời xấp xỉ bằng RPS nhân với _____.
  2. Cách phổ biến để API trả lời nhanh là đẩy job vào _____ rồi để worker xử lý ở background.
  3. Vì broker thường chỉ bảo đảm _____, job có thể chạy hai lần, nên worker phải _____.
  4. Job lỗi quá số lần retry được đưa vào _____ để không chặn cả hàng đợi.
  5. Chỉ số cho biết người dùng đang chờ bao lâu là _____.

Front: Vì sao thêm một lời gọi mất 800ms vào handler lại làm sập hệ thống? Back: Vì thời gian xử lý nhân với RPS quyết định số request đồng thời bị chiếm chỗ.

Front: Công thức số request đồng thời của một web server? Back: Xấp xỉ RPS nhân với thời gian xử lý trung bình của một request.

Front: Producer trong mô hình hàng đợi là gì? Back: Thành phần tạo job và đẩy vào queue, thường là API server.

Front: Worker làm gì? Back: Lấy job từ queue và xử lý ở background, tách khỏi request của người dùng.

Front: Bốn broker hàng đợi phổ biến? Back: Redis, RabbitMQ, SQS, Kafka.

Front: Vì sao resize ảnh không nên chạy trong request? Back: Nó là việc CPU-bound, chiếm CPU lâu khiến mọi request khác chậm theo.

Front: at-least-once nghĩa là gì? Back: Job được bảo đảm chạy ít nhất một lần, có thể chạy lặp, nên worker phải idempotent.

Front: Idempotency key dùng để làm gì? Back: Nhận diện job đã xử lý để chạy lại không tạo hiệu ứng trùng, ví dụ gửi hai email.

Front: Dead letter queue là gì? Back: Nơi chứa job lỗi quá số lần retry, để không chặn cả hàng đợi.

Front: Queue depth tăng 30 job mỗi giây nghĩa là gì? Back: Worker chậm hơn tốc độ sinh job, nút thắt đã chuyển sang phía worker.

Front: Chỉ số nào phản ánh độ trễ người dùng tốt hơn queue depth? Back: Tuổi của job cũ nhất trong queue.

Front: Hàng đợi có làm tổng công việc ít đi không? Back: Không, nó chỉ làm API trả lời nhanh hơn; worker vẫn phải xử lý toàn bộ việc.

Front: Timeout của worker nên đặt thế nào so với visibility timeout của broker? Back: Nhỏ hơn, để job không bị giao lại và chạy hai lần.