Danh sách bài viết

Bài 53: Vì Sao Cần Queue — Async Processing & Reliability

Module 5 tập trung vào queue và async processing với Redis. Bài này đặt nền cho cả module: tại sao xử lý đồng bộ (sync) không đủ cho nhiều tác vụ nặng, mô hình producer-consumer hoạt động ra sao, ba delivery semantics khác nhau như thế nào về hành vi khi lỗi, và Redis cung cấp những lựa chọn queue nào. Hiểu những điều này trước giúp bạn ra quyết định đúng ở các bài sau khi đào List queue, Streams và consumer group.

28/05/2026
12 phút đọc
0 lượt xem
1

Mục Tiêu Bài Học

  • Giải thích được tại sao xử lý đồng bộ (sync) không phù hợp cho các tác vụ nặng như gửi email, generate PDF, process video.
  • Mô tả mô hình producer-consumer: ba thành phần, luồng dữ liệu, và ý nghĩa của việc decouple.
  • Phân biệt ba delivery semantics: at-most-once, at-least-once, exactly-once — hành vi khi lỗi, khi nào dùng cái nào.
  • Biết Redis cung cấp những queue option nào (List, Streams, Pub/Sub, Sorted Set) và mỗi loại phù hợp bài toán gì.
  • So sánh được khi nào Redis queue đủ dùng và khi nào nên chọn Kafka, RabbitMQ, SQS.
  • Nhận diện các anti-pattern phổ biến khi dùng Redis làm queue.
2

Bài Toán Async Processing

Một HTTP request lý tưởng trả về trong vòng 200ms. Khi user nhấn "Đặt hàng", server xác nhận đơn, ghi database, trả về 201 Created — tất cả trong chưa đầy một phần năm giây. Nhưng thực tế có những tác vụ kéo dài hàng giây thậm chí hàng phút:

  • Gửi email xác nhận qua SMTP provider (50–500ms, đôi khi timeout).
  • Generate hóa đơn PDF với dữ liệu phức tạp (1–5 giây).
  • Transcode video sau khi upload (hàng phút).
  • Gọi third-party API chậm để enrich dữ liệu.
  • Tính toán report nặng từ nhiều bảng database.

Nếu làm đồng bộ — thực hiện tất cả trong vòng xử lý request — thì:

# Sync: user phải chờ
POST /orders
  → ghi DB (10ms)
  → gửi email (300ms)
  → generate PDF (2000ms)
  → gọi inventory API (500ms)
  → trả response (tổng ~2810ms)

# Hậu quả:
# - UX tệ: user ngồi chờ 3 giây
# - Nếu email server timeout (30s) → request timeout
# - Server thread bị chiếm trong suốt thời gian chờ
# - Scale out web server không giúp gì với bottleneck downstream

Async processing giải quyết bằng cách tách tác vụ nặng ra khỏi request cycle:

# Async: đẩy vào queue, trả response ngay
POST /orders
  → ghi DB (10ms)
  → LPUSH queue "send_email:order_123" (0.5ms)
  → LPUSH queue "gen_pdf:order_123" (0.5ms)
  → trả response 201 Created (tổng ~12ms)

# Background workers (processes riêng):
# Worker A: nhận "send_email:order_123" → gửi email
# Worker B: nhận "gen_pdf:order_123" → generate PDF

Response nhanh không có nghĩa là tác vụ không xảy ra — nó chỉ xảy ra ở chỗ khác, sau đó. User nhận thông báo khi hoàn thành (email, webhook, polling).

3

Mô Hình Producer-Consumer

Queue pattern gồm ba thành phần:

  • Producer: tạo task và đẩy vào queue. Producer không biết ai sẽ xử lý, cũng không chờ kết quả. Trong ví dụ trên, web server sau khi ghi DB là producer.
  • Queue: lưu trữ task theo thứ tự (FIFO hoặc theo priority/time). Queue đóng vai trò buffer giữa producer và consumer.
  • Consumer (worker): liên tục poll hoặc block-wait queue, lấy task ra và xử lý. Có thể có nhiều consumer xử lý song song.
Producer          Queue (Redis)         Consumer
   │                   │                    │
   │  LPUSH task_A     │                    │
   │ ─────────────────►│                    │
   │                   │  BRPOP (blocking)  │
   │                   │◄───────────────────│
   │                   │  task_A            │
   │                   │───────────────────►│
   │                   │                    │ (xử lý)
   │  LPUSH task_B     │                    │
   │ ─────────────────►│                    │
   │                   │  BRPOP             │
   │                   │◄───────────────────│
   │                   │  task_B            │
   │                   │───────────────────►│

Điểm mấu chốt là decoupling: producer và consumer không cần biết nhau, không cần chạy cùng lúc, không cần trên cùng một server. Producer có thể là web server Node.js, consumer có thể là Python worker — miễn là cùng nói chuyện với Redis.

Một số use case queue phổ biến:

  • Gửi email / SMS / notification: user action → queue → notification worker.
  • Image / video processing: upload xong → queue → media worker transcode.
  • Report generation, export: user yêu cầu → queue → report worker → lưu file → notify.
  • Webhook delivery: event xảy ra → queue → worker gọi endpoint của bên thứ ba.
  • Decouple service: order service ghi queue thay vì gọi trực tiếp inventory service.
  • Buffer traffic spike: request ào ạt vào giờ cao điểm → queue hấp thụ → worker xử lý đều đặn.
  • Retry failed operation: task lỗi → đẩy lại queue sau delay.
4

Lợi Ích Của Queue

Nhóm các lợi ích theo tính chất kỹ thuật:

  • Async response: request HTTP trả về ngay lập tức, không bị block bởi tác vụ nặng.
  • Reliability: nếu queue được persistent (Redis AOF/RDB) thì task không mất khi server restart. Nếu worker crash, task vẫn còn trong queue (với cơ chế phù hợp — xem Streams).
  • Scalability: thêm worker process là tăng throughput. Không cần thay đổi producer hay cấu trúc queue.
  • Load leveling: queue hấp thụ spike traffic. Consumer xử lý với tốc độ ổn định, không bị overload.
  • Retry: task fail có thể đẩy lại queue, có delay, có giới hạn số lần retry.
  • Decoupling: các service không phụ thuộc trực tiếp vào nhau. Order service không cần biết inventory service có đang chạy không.

Không phải bài toán nào cũng cần queue. Nếu tác vụ nhanh (< 50ms) và không cần retry, gọi trực tiếp đơn giản hơn. Queue thêm complexity — chỉ dùng khi complexity đó được đền bù bởi lợi ích thực sự.

5

Delivery Semantics — Ba Loại

Delivery semantics mô tả điều gì xảy ra với message khi có lỗi: lỗi mạng, crash của consumer, restart của broker. Đây là khái niệm nền tảng để chọn đúng công nghệ và thiết kế đúng consumer.

At-most-once

Message được giao tối đa một lần. Nếu consumer nhận được thì xử lý. Nếu consumer crash trước khi xử lý xong, message mất luôn — không ai giao lại.

# At-most-once với List RPOP
# Consumer lấy task ra khỏi queue TRƯỚC KHI xử lý
task = RPOP queue       # task bị xóa khỏi queue ngay đây
process(task)           # nếu crash ở đây → task MẤT
# Không có cơ chế giao lại

Ưu điểm: đơn giản, không cần tracking. Nhược điểm: mất message khi lỗi.

Khi nào chấp nhận được: analytics event, metrics sampling, log không critical — những gì mất vài record vẫn ổn về mặt business.

At-least-once

Message được giao ít nhất một lần. Nếu consumer crash sau khi xử lý nhưng trước khi xác nhận (ack), broker sẽ giao lại. Hậu quả: consumer có thể nhận duplicate.

# At-least-once với Streams XREADGROUP + XACK
task = XREADGROUP GROUP workers consumer1 COUNT 1 STREAMS jobs ">"
# task vào Pending Entries List (PEL) — vẫn được tracking
process(task)           # nếu crash ở đây → task vẫn trong PEL
XACK jobs workers task_id  # xác nhận thành công → xóa khỏi PEL

# Nếu crash trước XACK: XAUTOCLAIM giao lại cho consumer khác
# → consumer khác xử lý lại → duplicate

Ưu điểm: không mất message. Nhược điểm: cần consumer idempotent — xử lý cùng task hai lần không tạo ra side effect gấp đôi.

Đây là semantics phổ biến nhất trong production. Phần lớn hệ thống dùng at-least-once và thiết kế consumer idempotent.

Ví dụ idempotency: consumer gửi email → check DB xem email đã gửi chưa (theo order_id) trước khi thực sự gửi. Lần đầu: chưa có → gửi + đánh dấu. Lần hai (duplicate): đã có → skip.

Exactly-once

Mỗi message được xử lý đúng một lần. Không mất, không duplicate. Về lý thuyết, đây là lý tưởng. Về thực tế, exactly-once thực sự là rất khó — gần như không thể đảm bảo tuyệt đối khi có distributed systems vì network partition và crash có thể xảy ra ở bất kỳ điểm nào trong quá trình xử lý và ack.

Thực tế trong production: at-least-once + idempotent consumer = "effectively exactly-once". Đây là cách Kafka, Redis Streams, SQS đều khuyến nghị.

Exactly-once thực sự (với distributed transaction) chỉ cần thiết cho những bài toán cực kỳ nhạy cảm như thanh toán — và ngay cả ở đó, người ta thường giải qua idempotency key ở tầng application thay vì dựa vào broker đảm bảo.

6

Bảng So Sánh Semantics

Semantics Mất message Duplicate Độ phức tạp Use case điển hình
At-most-once Có thể Không Thấp Analytics event, metrics sampling, log không critical
At-least-once Không Có thể Trung bình Gửi email/notification, job processing (cần idempotent consumer)
Exactly-once Không Không Rất cao Thanh toán (thường đạt qua at-least-once + idempotency key)

Lựa chọn semantics phụ thuộc vào câu hỏi: điều gì tệ hơn với business — mất message hay xử lý duplicate? Với email: gửi thiếu (mất) tệ hơn gửi thừa một lần (duplicate có thể kiểm soát). Với thanh toán: cả hai đều tệ → cần idempotency key.

7

Redis Queue Options

Redis cung cấp nhiều data structure có thể dùng làm queue, mỗi loại có đặc điểm khác nhau:

List — LPUSH / BRPOP

Cách đơn giản nhất. Producer LPUSH task vào đầu list, consumer BRPOP blocking-wait ở cuối. Tự nhiên là FIFO. Semantics: at-most-onceBRPOP xóa task ngay khi lấy ra, không có cơ chế ack.

# Producer (Python, redis-py)
r.lpush("jobs", json.dumps({"type": "send_email", "order_id": 123}))

# Consumer
while True:
    _, task = r.brpop("jobs", timeout=5)
    if task:
        process(json.loads(task))  # nếu crash ở đây → task mất

Bài 54 đào chi tiết List queue và LMOVE để cải thiện reliability.

List với LMOVE — at-least-once cơ bản

LMOVE source dest LEFT RIGHT (atomic) cho phép di chuyển task sang "processing list" trước khi xử lý. Nếu crash, task vẫn trong processing list và có thể recover. Đây là "poor man's at-least-once" — cần logic recover thêm ở tầng application.

Streams — XADD / XREADGROUP / XACK

Cơ chế queue đầy đủ nhất của Redis. Consumer Group, Pending Entries List (PEL), XACK, XAUTOCLAIM. Semantics: at-least-once đầy đủ với built-in tracking. Hỗ trợ replay (đọc lại từ offset bất kỳ), nhiều consumer group độc lập, fan-out. Bài 55–58 sẽ đào chi tiết.

# Producer
r.xadd("jobs", {"type": "send_email", "order_id": "123"})

# Consumer với consumer group
msgs = r.xreadgroup("workers", "consumer1", {"jobs": ">"}, count=1, block=5000)
# Xử lý...
r.xack("jobs", "workers", msg_id)  # xác nhận → xóa khỏi PEL

Pub/Sub — KHÔNG phải queue

Pub/Sub là fire-and-forget. Message không được lưu trữ — nếu không có subscriber nào đang kết nối tại thời điểm publish, message mất. Không có persistence, không có retry, không có ack. Module 6 sẽ cover Pub/Sub cho use case thực sự của nó (real-time notification, invalidation broadcast).

Sorted Set — Delayed / Priority Queue

Score của Sorted Set có thể là timestamp (delayed job) hoặc priority number. ZRANGEBYSCORE jobs 0 now_timestamp lấy các job đến hạn. Bài 58 sẽ cover delayed jobs.

Option Semantics Replay Consumer Group Phù hợp
List RPOP At-most-once Không Không (manual) Simple job, không critical
List + LMOVE At-least-once (cơ bản) Không Không (manual) Cần safety cơ bản, không muốn Streams
Streams At-least-once Job processing production-grade
Pub/Sub At-most-once (fire-and-forget) Không Không Real-time broadcast, KHÔNG phải queue
Sorted Set At-most-once (tự implement) Không Không (manual) Delayed job, priority queue
8

Redis vs Dedicated Message Broker

Redis không phải message broker chuyên dụng. Biết giới hạn để chọn đúng:

Redis (Streams) Kafka RabbitMQ SQS
Setup Đơn giản (đã có Redis) Phức tạp (ZooKeeper/KRaft, broker cluster) Trung bình Managed, không cần ops
Throughput Cao (~100k msg/s/node) Rất cao (triệu msg/s) Cao Cao (managed)
Persistence RDB/AOF (giới hạn bởi RAM) Log lâu dài (disk, TB) Có (disk) Managed
Replay Có (Streams offset) Mạnh (consumer group offset, days/weeks) Hạn chế (message đã consume thì hết) Hạn chế (14 ngày)
Message routing Đơn giản (stream name) Topic/partition Exchange (direct, topic, fanout, headers) Queue / SNS topic
Phù hợp App đã dùng Redis, scale vừa Event streaming lớn, audit log lâu dài Routing phức tạp, AMQP protocol AWS ecosystem

Khi nào Redis queue đủ dùng

  • Application đã dùng Redis — không cần thêm infra.
  • Throughput vừa phải (dưới vài trăm nghìn message/s).
  • Cần reliable queue + replay → Streams.
  • Không cần routing phức tạp kiểu RabbitMQ exchange.
  • Không cần lưu log event hàng tuần/tháng (RAM có giới hạn).

Khi nào nên xét dedicated broker

  • Kafka: throughput cực cao, event streaming, audit log lâu dài, consumer cần đọc lại từ ngày N.
  • RabbitMQ: routing phức tạp (exchange, binding, dead-letter exchange native), AMQP ecosystem.
  • SQS/SNS: đã trên AWS, muốn managed service, tích hợp Lambda dễ.

BullMQ (Node.js) là wrapper trên Redis List/Streams cung cấp API queue cấp cao (priority, delay, repeat, rate limiting, UI dashboard). Bài 63 so sánh chi tiết Redis native vs BullMQ vs Kafka vs RabbitMQ.

9

Reliability Requirements Cần Lưu Ý

Nói "queue reliable" cần làm rõ reliable theo nghĩa nào:

  • Durability: message không mất khi Redis restart. Đòi hỏi Redis được cấu hình AOF hoặc RDB (đã cover bài 6). Mặc định Redis không persist — restart là mất hết queue.
  • Acknowledgment: consumer phải báo lại đã xử lý xong. List BRPOP không có ack; Streams XACK có. Không có ack thì crash consumer = mất task.
  • Retry: task xử lý fail cần được thử lại. Bài 59 sẽ cover retry strategy với backoff.
  • Dead Letter Queue (DLQ): task fail quá nhiều lần thì đưa sang DLQ thay vì retry mãi. Bài 60 sẽ cover.

Một incident điển hình sẽ gặp ở bài 57–58: consumer nhận task từ Streams, xử lý xong nhưng crash trước khi gọi XACK. Task kẹt trong PEL (Pending Entries List) mãi mãi. Không có XAUTOCLAIM thì task không bao giờ được tái giao — worker khác không nhận được dù consumer gốc đã chết.

10

Anti-patterns Cần Tránh

  • Dùng List RPOP cho task không được mất. List RPOP là at-most-once. Nếu consumer crash sau khi pop nhưng trước khi xử lý xong, task mất. Với task critical (gửi hóa đơn, trừ tiền), cần Streams với XACK.
  • Dùng Pub/Sub làm queue. Pub/Sub không lưu message. Subscriber offline thì message không được nhận. Đây là fire-and-forget, không phải queue. Nhầm lẫn này dẫn đến mất message âm thầm, rất khó debug.
  • Consumer không idempotent với at-least-once. Khi dùng Streams (at-least-once), duplicate là có thể xảy ra. Nếu consumer không idempotent thì duplicate gây side effect gấp đôi: charge thẻ hai lần, gửi email hai lần, tạo record hai lần.
  • Không cấu hình persistence. Queue trên Redis không persist sẽ mất toàn bộ khi Redis restart. Môi trường dev thường bỏ qua, production không được.
  • Reinvent Kafka bằng Redis cho event streaming khổng lồ. Redis Streams tốt cho job queue với throughput vừa. Nếu cần lưu event log hàng TB, replay theo ngày, consumer group với offset quản lý lâu dài — đó là Kafka territory.
  • Không giới hạn độ dài stream. Redis Streams mặc định tăng trưởng vô hạn. Cần dùng XADD * MAXLEN ~ 100000 hoặc cấu hình XTRIM để tránh Redis hết RAM.
11

Tổng Kết & Quiz

Những điểm cốt lõi của bài:

  • Async processing tách tác vụ nặng ra khỏi request cycle, giúp HTTP response nhanh và hệ thống không bị block.
  • Producer-consumer model decouple producer và consumer — không cần biết nhau, không cần chạy cùng lúc.
  • Ba delivery semantics: at-most-once (đơn giản, có thể mất), at-least-once (không mất, có thể duplicate — cần idempotent consumer), exactly-once (rất khó — thực tế đạt qua at-least-once + idempotency key).
  • Redis có nhiều queue option: List (at-most-once), List+LMOVE (at-least-once cơ bản), Streams (at-least-once đầy đủ), Sorted Set (delayed/priority). Pub/Sub không phải queue.
  • Redis queue phù hợp khi đã dùng Redis và throughput vừa. Kafka tốt hơn cho event streaming khổng lồ; RabbitMQ cho routing phức tạp; SQS cho AWS ecosystem.

Quiz

  1. Tại sao Pub/Sub của Redis không thể làm queue? Điều gì xảy ra nếu subscriber offline khi message được publish?
  2. Phân biệt at-most-once và at-least-once: điều gì khác nhau khi consumer crash giữa chừng? Cơ chế nào giúp at-least-once không mất message?
  3. Một hệ thống thanh toán cần "exactly-once" nhưng broker chỉ cung cấp at-least-once. Làm thế nào đạt "effectively exactly-once" ở tầng application?
  4. Bạn có một Redis instance không bật AOF. Queue chứa 10.000 task đang chờ xử lý. Redis restart bất ngờ. Điều gì xảy ra với 10.000 task đó?
  5. Khi nào nên dùng Redis Streams thay vì List đơn giản? Nêu ít nhất ba tính năng của Streams mà List không có.

Đáp án gợi ý

  1. Pub/Sub là fire-and-forget: message không được lưu lại. Nếu subscriber offline, message mất hoàn toàn — không có persistence, không có retry, không có buffer. Queue cần lưu message cho đến khi consumer nhận và xử lý xong.
  2. At-most-once: consumer lấy task ra khỏi queue ngay (RPOP), nếu crash sau đó task mất vì không có record nào còn tồn tại. At-least-once: task vẫn được tracking (PEL trong Streams) sau khi consumer nhận, chỉ xóa sau khi consumer XACK. Crash trước XACK → task được giao lại.
  3. Idempotency key: mỗi payment request có unique key (ví dụ payment_id). Trước khi xử lý, consumer check xem key này đã được xử lý chưa (database lookup). Nếu rồi → skip. Nếu chưa → xử lý + đánh dấu đã xử lý trong cùng một transaction. Duplicate từ queue sẽ bị skip ở bước check này.
  4. Toàn bộ 10.000 task mất. Redis không có AOF nghĩa là không có write-ahead log; khi restart, Redis bắt đầu từ trạng thái rỗng. Đây là lý do production queue cần bật AOF (appendfsync everysec hoặc always) hoặc ít nhất RDB snapshot.
  5. Streams so với List: (1) Acknowledgment — XACK xác nhận đã xử lý, List BRPOP không có. (2) Pending Entries List — Streams tracking message đang được xử lý, cho phép recover khi crash. (3) Consumer Group — nhiều consumer nhận task song song với tracking riêng. Thêm: (4) Replay — đọc lại từ offset bất kỳ. (5) Fan-out — nhiều consumer group độc lập đọc cùng stream.

Bài tiếp theo

Bài 54 đi vào chi tiết List queue: LPUSH/BRPOP, LMOVE để cải thiện reliability, giới hạn của at-most-once, và khi nào List đủ dùng thay vì phải dùng Streams.

Tham khảo