Message Broker & Xử lý bất đồng bộMessage Brokers & Async Processing
Thuộc bộ kiến thức Backend Roadmap.
Tổng quan
Phần lớn hệ thống backend bắt đầu với các lời gọi synchronous kiểu request/response: client hỏi, service xử lý, client chờ kết quả. Cách này đơn giản và dễ suy luận, nhưng nó gắn chặt số phận của bên gọi vào bên bị gọi. Nếu service downstream chậm, bên gọi cũng chậm. Nếu nó chết, bên gọi lỗi. Nếu có một đợt traffic dồn dập, cả hai phải scale cùng lúc.
Asynchronous messaging phá vỡ sự gắn chặt đó. Thay vì gọi trực tiếp một service, producer ghi một message vào message broker — một middleware chuyên lưu trữ và định tuyến message — rồi trả về ngay lập tức. Một hoặc nhiều consumer đọc các message đó sau, theo nhịp độ của riêng chúng. Broker đóng vai trò như một bộ đệm (buffer), một bộ định tuyến (router), và một “bộ giảm chấn” giữa các phần của hệ thống.
Sự chuyển dịch này mở ra một họ các pattern — background job processing, event-driven architecture, streaming, và data pipeline — vốn thiết yếu để xây dựng backend có khả năng scale và resilient. Note này trình bày tại sao và khi nào nên dùng async, hai messaging model cốt lõi (queue và pub/sub), các delivery semantics, một so sánh sâu RabbitMQ vs Kafka, hệ sinh thái broker rộng hơn, và các pattern production (outbox, saga, competing consumers, fan-out) bạn cần để dùng chúng an toàn.
Note liên quan: Software Architecture (vị trí của messaging trong thiết kế tổng thể), Real-time Communication (đẩy message tới client), và Scalability & Reliability (dùng async để hấp thụ tải và sống sót qua sự cố).
Kiến thức nền tảng
Tại sao dùng async? Các lợi ích cốt lõi
| Lợi ích | Ý nghĩa | Ví dụ |
|---|---|---|
| Decoupling | Producer và consumer không cần biết về nhau, không cần cùng “sống”, không cần chung ngôn ngữ/tech stack. | Service orders phát OrderPlaced; billing, shipping, analytics mỗi bên consume độc lập. |
| Buffering | Broker giữ message trong khi consumer bắt kịp, làm mượt các đợt spike. | Flash sale sinh 10k orders/giây; consumer đều đặn rút queue ở 2k/giây. |
| Load leveling | Đầu vào giật cục trở thành công việc đều đặn, nên bạn tính công suất consumer theo tải trung bình, không theo đỉnh. | Batch email hàng đêm được đưa vào queue và gửi dần trong nhiều giờ. |
| Resilience / cô lập lỗi | Nếu consumer crash, message chờ an toàn; producer không bị ảnh hưởng. | Bộ resize ảnh chết 5 phút; upload vẫn thành công và được xử lý khi hồi phục. |
| Scalability | Thêm consumer để xử lý song song cùng một queue (competing consumers). | Scale từ 3 lên 30 worker để dọn backlog. |
| Responsiveness | Trả về cho user ngay; làm việc chậm ở background. | API nhận upload video trong 200 ms rồi transcode bất đồng bộ. |
Synchronous vs asynchronous
| Tiêu chí | Synchronous (vd REST/gRPC call) | Asynchronous (qua broker) |
|---|---|---|
| Bên gọi chờ kết quả? | Có | Không (fire-and-forget hoặc trả lời sau) |
| Coupling | Theo thời gian — cả hai phải “sống” | Lỏng — consumer có thể offline |
| Phạm vi ảnh hưởng khi lỗi | Lan ngược lên chuỗi gọi | Bị chặn lại; retry sau |
| Latency của bên gọi | Bị ràng bởi callee chậm nhất | Hằng số (chỉ là thao tác enqueue) |
| Xử lý backpressure | Timeout, retry, circuit breaker | Tự nhiên — message dồn vào queue |
| Phù hợp cho | Read, query cần câu trả lời tức thì | Command/event, việc chậm, fan-out |
| Khả năng debug | Dễ — một trace tuyến tính | Khó hơn — luồng trải theo thời gian và service |
Async không miễn phí: bạn đánh đổi sự đơn giản và tính nhất quán tức thì để lấy eventual consistency, việc debug khó hơn, và các failure mode mới (message trùng lặp, giao không đúng thứ tự, poison message). Hãy dùng nó ở nơi các lợi ích trên thực sự quan trọng; giữ lời gọi synchronous cho các read cần câu trả lời ngay.
Các thành phần (actors)
- Producer (publisher) — tạo message và gửi tới broker.
- Broker — middleware nhận, lưu trữ, định tuyến và giao message. Nó cung cấp tính durability, các quy tắc routing, và delivery guarantee.
- Consumer (subscriber / worker) — đọc message và xử lý.
- Message — một đơn vị dữ liệu tự chứa: một payload (phần thân, vd JSON/Avro/Protobuf) cộng với metadata/headers (message ID, timestamp, content type, routing key, correlation ID, v.v.).
Một message thiết kế tốt phải self-describing và có version. Ưu tiên schema tường minh (JSON Schema, Avro, Protobuf) và một schema registry để producer và consumer tiến hóa độc lập.
Khái niệm chính
Messaging model: queue vs pub/sub
Có hai model nền tảng. Đa số broker hỗ trợ một hoặc cả hai.
1. Message queue (point-to-point) — một message được giao cho đúng một consumer trong số những consumer đang lắng nghe. Nhiều consumer trên cùng một queue cạnh tranh để lấy message, qua đó phân tán công việc (pattern “competing consumers”). Đây là model của task/job queue.
Producer ──> [ Queue ] ──> Consumer A (mỗi message chỉ tới đúng một consumer)
└──> Consumer B
2. Publish/subscribe (pub/sub) — một message publish lên một topic được giao tới mọi subscriber. Mỗi subscriber nhận một bản sao riêng. Đây là model của event broadcasting / fan-out.
- Producer
- Topic
- Subscriber A (billing)Subscriber B (shipping)Subscriber C (analytics)
| Queue (point-to-point) | Pub/Sub (topic) | |
|---|---|---|
| Delivery | Một consumer mỗi message | Mọi subscriber đều nhận bản sao |
| Mục đích | Phân phối công việc | Broadcast event |
| Thêm consumer | Tăng throughput (chia tải) | Thêm một reader độc lập mới |
| Dùng điển hình | Background job, task processing | Event-driven architecture, fan-out |
| Ví dụ | SQS, RabbitMQ queue, Celery | Kafka topic, SNS, Google Pub/Sub, Redis Pub/Sub |
Lưu ý Kafka làm mờ ranh giới này: một topic là pub/sub xét theo các consumer group, nhưng trong cùng một group, các partition được chia cho các thành viên giống như competing consumers (xem bên dưới).
Delivery semantics
Điều quan trọng nhất cần hiểu về bất kỳ broker nào là nó đảm bảo gì về việc giao message.
| Semantic | Đảm bảo | Đánh đổi | Khi nào dùng |
|---|---|---|---|
| At-most-once | Mỗi message giao 0 hoặc 1 lần; có thể mất, không bao giờ trùng. | Nhanh, đơn giản nhất; có thể mất dữ liệu. | Metric, log nơi mất mát thi thoảng chấp nhận được. |
| At-least-once | Mỗi message giao 1+ lần; không mất, có thể trùng. | Cần consumer idempotent để xử lý bản trùng. | Lựa chọn mặc định thực dụng cho hầu hết hệ thống. |
| Exactly-once | Mỗi message có hiệu lực đúng một lần duy nhất. | Tốn kém, phạm vi giới hạn, dễ hiểu sai. | Sổ cái tài chính, luồng cực kỳ nhạy với trùng lặp. |
“Exactly-once” rất tinh tế. Việc giao exactly-once end-to-end qua mạng là bất khả thi trong trường hợp tổng quát. Cái mà broker thực sự cung cấp là exactly-once processing trong các ranh giới cụ thể — vd transaction của Kafka cho exactly-once cho chu trình read-process-write bên trong Kafka. Thực tế, bạn đạt được exactly-once “trên hiệu quả” bằng cách kết hợp at-least-once delivery với idempotent consumer. Đừng cho rằng “exactly-once” loại bỏ nhu cầu idempotency.
Acknowledgement (ack)
Delivery guarantee xoay quanh ack. Sau khi consumer xử lý xong một message, nó gửi ack về broker, và broker mới xóa/tiến qua message đó. Nếu consumer crash trước khi ack, broker sẽ giao lại.
- Auto-ack (ack ngay khi nhận, trước khi xử lý) → at-most-once.
- Manual ack sau khi xử lý → at-least-once.
- Một nack (negative ack) hoặc timeout báo broker giao lại hoặc đẩy sang dead-letter queue.
Về phía producer, publisher confirms (RabbitMQ) hoặc acks=all (Kafka) đảm bảo broker đã lưu bền message trước khi producer coi là đã gửi thành công.
Idempotent consumer
Vì at-least-once nghĩa là có bản trùng, consumer phải idempotent — xử lý cùng một message hai lần cho kết quả giống như một lần. Kỹ thuật:
- Dedup theo message ID — lưu các message ID đã xử lý (trong Redis/DB) và bỏ qua những cái đã thấy.
- Idempotency key — khóa tự nhiên hoặc do client cung cấp giúp thao tác write an toàn khi retry (
INSERT ... ON CONFLICT DO NOTHING, upsert). - Conditional update —
UPDATE ... WHERE status = 'pending'để một lần replay trở thành no-op.
def handle(msg):
if seen.exists(msg.id): # đã xử lý → bỏ qua
ack(msg); return
with db.transaction():
apply(msg.payload) # làm việc
seen.add(msg.id, ttl=7*DAY) # ghi lại (lý tưởng là cùng transaction)
ack(msg)
Ordering (thứ tự)
Global ordering rất tốn kém. Đa số broker chỉ đảm bảo thứ tự trong một partition (Kafka) hoặc một queue đơn với một consumer (RabbitMQ). Để giữ các message liên quan đúng thứ tự, hãy định tuyến chúng về cùng một partition/queue bằng partition key (vd userId), chấp nhận rằng các message không liên quan có thể xen kẽ. Thêm consumer song song thường phải hy sinh thứ tự chặt chẽ.
Dead-letter queue (DLQ)
Một message liên tục xử lý thất bại là một poison message — retry mãi sẽ chặn queue và lãng phí tài nguyên. Dead-letter queue là nơi message như vậy được gửi tới sau N lần thử thất bại (hoặc khi hết TTL / queue tràn). Điều này cô lập message xấu để message lành vẫn lưu thông, và cho bạn một nơi để kiểm tra, cảnh báo, và replay thủ công.
Best practice: retry vài lần (thường với exponential backoff, qua delay/retry queue), rồi đẩy DLQ. Giám sát độ sâu DLQ — DLQ phình lên là tín hiệu sự cố.
RabbitMQ vs Kafka — so sánh sâu
Đây là hai broker thống trị, xây trên các model khác nhau về bản chất. RabbitMQ là một message queue broker thông minh / consumer đơn giản (AMQP); Kafka là một distributed commit log broker đơn giản / consumer thông minh.
Model RabbitMQ (AMQP)
Producer publish tới một exchange, không phải trực tiếp tới queue. Exchange định tuyến tới các queue dựa trên binding và một routing key. Consumer đọc từ queue. Khi một message đã được ack, nó biến mất.
Producer ──> [ Exchange ] ──(binding, routing key)──> [ Queue ] ──> Consumer
Các loại exchange:
| Exchange | Hành vi routing |
|---|---|
| direct | Định tuyến tới queue có binding key khớp chính xác routing key. |
| topic | Khớp mẫu (pattern) routing key với binding pattern (orders.*.eu, logs.#). |
| fanout | Broadcast tới mọi queue đã bind, bỏ qua routing key (pub/sub). |
| headers | Định tuyến dựa trên thuộc tính header của message thay vì routing key. |
RabbitMQ mạnh về routing phức tạp, priority theo từng message, topology linh hoạt, và phân phối task độ trễ thấp. Message thường được consume rồi xóa (queue không phải một log bền có thể tua lại).
Model Kafka (log)
Kafka là một distributed, append-only, partitioned commit log. Một topic được chia thành nhiều partition; mỗi partition là một chuỗi record có thứ tự, bất biến. Producer append vào partition (chọn theo hash của key). Consumer tự theo dõi offset (vị trí) của mình và đọc tiến lên. Message được retain (theo thời gian hoặc dung lượng) bất kể đã được consume hay chưa — nên nhiều consumer có thể đọc cùng dữ liệu, và bạn có thể replay từ bất kỳ offset nào.
Partition 0: [r0 r1 r2 r3 ...] ← offset
Producer ──> [ Topic ] Partition 1: [r0 r1 r2 ...]
Partition 2: [r0 r1 ...]
Các thành viên consumer group mỗi người sở hữu một tập con partition.
Consumer group: mọi consumer trong một group chia nhau các partition của topic — mỗi partition được đọc bởi đúng một thành viên (competing consumers → song song). Các group khác nhau đọc topic độc lập (pub/sub → fan-out). Mức song song bị giới hạn bởi số partition.
Bảng so sánh
| Khía cạnh | RabbitMQ | Kafka |
|---|---|---|
| Model cốt lõi | Message queue / broker (AMQP) | Distributed commit log |
| Routing | Phong phú: exchange, binding, routing key, pattern | Đơn giản: topic + partition (theo key) |
| Vòng đời message | Xóa sau ack (tạm thời) | Retain theo policy; không xóa khi đọc |
| Replay / đọc lại | Không (đã consume là mất) | Có — seek tới offset bất kỳ, xử lý lại lịch sử |
| Ordering | Theo từng queue | Theo từng partition |
| Theo dõi consumption | Broker theo dõi ack từng message | Consumer tự theo dõi offset |
| Throughput | Cao (hàng chục–trăm nghìn msg/s) | Rất cao (triệu msg/s), I/O tuần tự |
| Latency | Rất thấp | Thấp, nhưng tối ưu cho throughput |
| Model song song | Nhiều consumer trên một queue | Partition trong một consumer group |
| Fan-out tới nhiều reader | fanout exchange / nhiều queue | Nhiều consumer group (rẻ) |
| Priority | Có (priority queue) | Không có priority message gốc |
| Delivery | At-most / at-least once | At-least / exactly-once (transaction) |
| Backpressure | Prefetch limit, flow control | Consumer pull theo nhịp của mình |
| Protocol | AMQP 0-9-1 (cả MQTT, STOMP) | Binary tùy biến qua TCP |
| Hệ sinh thái | Plugin, tooling trưởng thành | Kafka Streams, Connect, ksqlDB, Schema Registry |
| Độ nặng vận hành | Trung bình | Nặng hơn (trước dùng ZooKeeper; nay KRaft) |
Khi nào chọn cái nào
Chọn RabbitMQ khi:
- Bạn cần task/job queue và phân phối background work.
- Bạn cần logic routing phức tạp (theo topic/header).
- Bạn muốn priority theo từng message, TTL, và ack semantics linh hoạt.
- Message là các command tạm thời, xử lý một lần rồi bỏ.
- Muốn độ phức tạp vận hành thấp hơn cho khối lượng vừa phải.
Chọn Kafka khi:
- Bạn xây event streaming / một backbone event-driven.
- Bạn cần replay lịch sử hoặc có nhiều consumer độc lập cho cùng một stream.
- Bạn cần throughput rất cao và lưu trữ event bền (event log là source of truth).
- Bạn làm stream processing, analytics, log aggregation, hay CDC.
- Ordering theo key ở quy mô lớn là quan trọng.
Nhiều tổ chức chạy cả hai: Kafka làm backbone streaming/event log, RabbitMQ (hoặc SQS/Celery) cho task queue kiểu command.
Các broker khác (ngắn gọn)
| Broker | Model | Ghi chú |
|---|---|---|
| Redis Streams | Stream kiểu log + consumer group | Nhẹ, có tính bền tương đối, tuyệt nếu đã chạy Redis; kém bền hơn Kafka. |
| Redis Pub/Sub | Pub/sub fire-and-forget | Không persistence — mất message nếu không có subscriber kết nối. Tốt cho tín hiệu tạm thời. |
| NATS / NATS JetStream | Pub/sub nhẹ; JetStream thêm persistence | Cực nhanh, vận hành đơn giản, hợp microservices và edge/IoT. |
| AWS SQS | Managed queue (point-to-point) | Standard (at-least-once, thứ tự best-effort) hoặc FIFO (có thứ tự, dedup). Serverless, tự scale. |
| AWS SNS | Managed pub/sub | Fan-out tới SQS, Lambda, HTTP, email. Thường ghép với SQS (SNS→SQS fan-out). |
| Google Pub/Sub | Managed pub/sub toàn cầu | At-least-once, push hoặc pull, tự scale, ordering key. |
| Apache Pulsar | Lai log + queue | Multi-tenancy, tiered storage, geo-replication; hợp nhất streaming và queuing. |
Với cloud stack managed, SQS/SNS hoặc Google Pub/Sub loại bỏ gần hết gánh nặng vận hành và là lựa chọn mặc định rất tốt.
Streaming & event-driven architecture
Trong event-driven architecture (EDA), các service giao tiếp bằng cách phát và phản ứng với event (“một chuyện đã xảy ra”, vd PaymentReceived) thay vì phát command. Điều này tối đa hóa decoupling: bên phát không biết và không quan tâm ai consume.
- Event notification — một event mỏng (“order 123 đã thay đổi”); consumer gọi lại để lấy chi tiết.
- Event-carried state transfer — event mang theo mọi dữ liệu cần thiết, nên consumer không cần gọi lại.
- Event sourcing — event log là source of truth. State được suy ra bằng cách replay event, không lưu dưới dạng các dòng giá trị hiện tại. Điều này cho audit trail đầy đủ và “time-travel”, đổi lại là độ phức tạp và cần projection/read model. Log có thể replay của Kafka rất hợp cho việc này.
Change Data Capture (CDC) biến commit log của database (vd Postgres WAL, MySQL binlog) thành một stream các change event, thường qua công cụ như Debezium đổ vào Kafka. Nó cho phép lan truyền thay đổi dữ liệu tới hệ thống khác (search index, cache, data warehouse) mà không cần dual write — và là nền tảng của outbox pattern bên dưới.
Backpressure, throttling, và load shedding
Khi producer nhanh hơn consumer, hệ thống phải xoay xở hoặc sẽ sụp.
- Backpressure — báo hiệu ngược lên upstream để chậm lại. Consumer kiểu pull-based (Kafka) tự nhiên áp backpressure bằng cách chỉ fetch lượng mình xử lý nổi. RabbitMQ dùng prefetch (QoS) để giới hạn số message chưa ack mỗi consumer, và flow control để làm chậm publisher nhanh khi bộ nhớ cao.
- Throttling / rate limiting — cố ý giới hạn tốc độ publish của producer hoặc tốc độ xử lý của consumer, bảo vệ tài nguyên downstream (vd một third-party API mong manh).
- Load shedding — khi quá tải, cố ý loại bỏ message ưu tiên thấp để bảo vệ hệ thống (mất một số công việc không quan trọng còn hơn sập hoàn toàn). Thường ghép với priority queue.
- Load shifting / leveling — dời công việc theo thời gian: enqueue lúc đỉnh, xử lý lúc đáy. Đây là một lý do chính để dùng queue ngay từ đầu.
Hãy theo dõi queue depth / consumer lag như metric sức khỏe then chốt. Lag tăng nghĩa là consumer không theo kịp — scale out, tối ưu, throttle producer, hoặc shed load. Đồng thời hãy giới hạn queue của bạn: queue không giới hạn chỉ dời sự cố từ “service quá tải” sang “broker hết bộ nhớ”.
Các pattern
Outbox pattern
Bài toán kinh điển: bạn phải cập nhật database và publish event một cách atomic. Nếu bạn write DB rồi publish, một cú crash ở giữa sẽ mất event (vấn đề dual-write). Outbox pattern giải quyết:
- Trong cùng một local DB transaction với thay đổi nghiệp vụ, chèn event vào bảng
outbox. - Một relay riêng (một poller hoặc CDC qua Debezium) đọc các dòng outbox chưa publish và publish chúng lên broker, rồi đánh dấu đã gửi.
Vì write nghiệp vụ và insert outbox chung một transaction, chúng cùng thành công hoặc cùng thất bại. Relay cung cấp publish at-least-once tới broker.
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 42;
INSERT INTO outbox (id, topic, payload) VALUES (gen_random_uuid(), 'OrderPaid', '{"orderId":42}');
COMMIT;
-- relay sau đó: SELECT * FROM outbox WHERE published = false; publish; đánh dấu published.
Saga
Một saga quản lý một distributed transaction xuyên nhiều service mà không cần 2-phase commit toàn cục. Nó là một chuỗi các local transaction, mỗi cái publish một event kích hoạt bước tiếp theo; nếu một bước thất bại, compensating transaction hoàn tác các bước trước.
- Choreography — mỗi service phản ứng với event và phát event tiếp theo; không có coordinator trung tâm. Đơn giản nhưng luồng ngầm định, khó theo dõi.
- Orchestration — một orchestrator trung tâm bảo mỗi service làm gì và xử lý compensation. Tường minh và dễ suy luận hơn; orchestrator là một dependency.
Ví dụ (order saga): OrderCreated → giữ inventory → charge payment → ship. Nếu payment thất bại, phát compensation: nhả inventory, hủy order.
Competing consumers
Nhiều consumer đọc từ một queue để xử lý message song song. Broker giao mỗi message cho chỉ một trong số chúng, nên throughput scale theo số worker. Đây là pattern chủ lực cho job queue. Kết hợp với idempotency (do có giao lại) và lưu ý nó đánh đổi thứ tự chặt chẽ.
Fan-out
Một message tới nhiều consumer, mỗi consumer làm việc khác nhau (billing, email, analytics). Triển khai qua pub/sub topic, một fanout exchange, hoặc SNS→nhiều SQS queue. Lợi ích chính: thêm một consumer mới không cần thay đổi producer.
Task/job queue cho background work
Job queue ở tầng ứng dụng nằm trên một broker để chạy background work (gửi email, resize ảnh, tạo report). Chúng thêm sự tiện lợi cho developer: retry, scheduling, lưu kết quả, và quản lý worker.
| Công cụ | Hệ sinh thái | Backend | Ghi chú |
|---|---|---|---|
| Celery | Python | RabbitMQ / Redis | Trưởng thành, nhiều tính năng: retry, ETA/countdown, chord, beat scheduler. |
| Sidekiq | Ruby | Redis | Nhanh, threaded, rất phổ biến trong Rails. |
| BullMQ | Node.js | Redis | Queue mạnh mẽ, rate limiting, repeatable job, flow. |
| RQ | Python | Redis | Lựa chọn đơn giản hơn Celery. |
| Dramatiq | Python | RabbitMQ / Redis | Thay thế Celery kiểu đơn giản, opinionated. |
| Temporal | Đa ngôn ngữ | Riêng | Durable workflow engine (vượt xa một queue đơn thuần) cho orchestration chạy dài. |
Ví dụ (Celery):
from celery import Celery
app = Celery("tasks", broker="amqp://localhost", backend="redis://localhost")
@app.task(bind=True, max_retries=3, acks_late=True)
def send_welcome_email(self, user_id):
try:
email.send(user_id)
except TransientError as exc:
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
# producer
send_welcome_email.delay(user_id=42) # trả về ngay lập tức; chạy trong một worker
Dùng acks_late=True (ack sau khi làm việc, không phải trước) cho at-least-once semantics, giữ task idempotent, đặt max_retries kèm backoff, và định tuyến poison message sang DLQ.
Best Practices
- Mặc định dùng at-least-once + idempotent consumer. Giả định mọi message có thể tới hơn một lần và không đúng thứ tự. Thiết kế handler an toàn khi bị replay.
- Dùng dead-letter queue. Đừng bao giờ để một poison message chặn queue. Retry với backoff, rồi DLQ, rồi cảnh báo theo độ sâu DLQ.
- Làm message self-describing và có version. Kèm message ID, timestamp, type, và schema version. Dùng schema registry (Avro/Protobuf) để tiến hóa an toàn; thêm field, đừng tái sử dụng ý nghĩa field cũ.
- Giải quyết dual write bằng outbox pattern, không phải “write DB rồi publish”. Đừng bao giờ dựa vào hai hệ thống độc lập cùng thành công mà không có transaction hay CDC làm cầu nối.
- Giữ message nhỏ. Đặt payload lớn (file, ảnh) vào object storage và gửi một reference/pointer, không gửi bytes (claim-check pattern).
- Giới hạn queue và giám sát lag. Consumer lag / queue depth là dấu hiệu sinh tồn. Cảnh báo khi nó tăng; queue không giới hạn chỉ di dời sự cố.
- Chọn ordering có chủ đích. Chỉ yêu cầu thứ tự nơi thực sự cần, và giới hạn nó ở một partition/key. Global ordering giết chết tính song song.
- Thiết kế cho backpressure và quá tải. Dùng prefetch/QoS, rate limiting, và (khi hợp lý) load shedding công việc ưu tiên thấp. Quyết định bỏ cái gì trước khi “cháy nhà”.
- Đặt correlation và causation ID. Lan truyền correlation ID xuyên các luồng async để trace một request qua các service và thời gian (distributed tracing).
- Phân biệt lỗi retry được và không retry được. Retry lỗi tạm thời (timeout) với backoff; đẩy lỗi vĩnh viễn (lỗi validation) thẳng sang DLQ — đừng retry chúng.
- Ưu tiên managed broker khi có thể. SQS/SNS, Google Pub/Sub, và managed Kafka/RabbitMQ loại bỏ gánh nặng vận hành khổng lồ; chỉ tự host khi buộc phải.
- Chọn model đúng việc. Queue để phân phối công việc; pub/sub để broadcast event. RabbitMQ cho routing/task; Kafka cho streaming/replay. Đừng ép một công cụ làm mọi thứ.
- Test các đường lỗi. Cố ý kill consumer giữa chừng, chèn bản trùng, và trì hoãn message ở staging để kiểm chứng idempotency, retry, và hành vi DLQ.
Tài liệu tham khảo
- RabbitMQ — Tutorials & Concepts
- RabbitMQ — AMQP 0-9-1 Model Explained
- Apache Kafka — Documentation
- Apache Kafka — Introduction & Design
- Confluent — What is Event-Driven Architecture
- Microservices.io — Transactional Outbox & Saga patterns
- AWS — SQS vs SNS vs EventBridge messaging
- Enterprise Integration Patterns (Hohpe & Woolf)
Part of the Backend Roadmap knowledge base.
Overview
Most backend systems start with synchronous request/response calls: a client asks, a service does the work, the client waits for the answer. This is simple and easy to reason about, but it couples the caller’s fate to the callee’s. If the downstream service is slow, the caller is slow. If it is down, the caller fails. If a burst of traffic arrives, both must scale in lockstep.
Asynchronous messaging breaks that coupling. Instead of calling a service directly, a producer writes a message to a message broker — a piece of middleware that stores and routes messages — and returns immediately. One or more consumers read those messages later, at their own pace. The broker acts as a buffer, a router, and a shock absorber between parts of your system.
This shift unlocks a family of patterns — background job processing, event-driven architecture, streaming, and data pipelines — that are essential for building scalable, resilient backends. This note covers why and when to go async, the two core messaging models (queues and pub/sub), delivery semantics, a deep comparison of RabbitMQ vs Kafka, the wider ecosystem of brokers, and the production patterns (outbox, saga, competing consumers, fan-out) you need to use them safely.
Related notes: Software Architecture (where messaging fits in an overall design), Real-time Communication (pushing messages to clients), and Scalability & Reliability (using async to absorb load and survive failure).
Fundamentals
Why async? The core benefits
| Benefit | What it means | Example |
|---|---|---|
| Decoupling | Producer and consumer don’t need to know about each other, be up at the same time, or share a language/tech stack. | An orders service emits OrderPlaced; billing, shipping, and analytics each consume it independently. |
| Buffering | The broker holds messages while consumers catch up, smoothing spikes. | A flash sale produces 10k orders/sec; consumers steadily drain the queue at 2k/sec. |
| Load leveling | Bursty input becomes steady work, so you size consumers for the average load, not the peak. | Nightly batch of emails is queued and sent over hours instead of all at once. |
| Resilience / fault isolation | If a consumer crashes, messages wait safely; the producer is unaffected. | Image resizer goes down for 5 min; uploads still succeed and are processed on recovery. |
| Scalability | Add more consumers to process the same queue in parallel (competing consumers). | Scale from 3 to 30 workers to clear a backlog. |
| Responsiveness | Return to the user immediately; do slow work in the background. | API accepts a video upload in 200 ms and transcodes asynchronously. |
Synchronous vs asynchronous communication
| Dimension | Synchronous (e.g. REST/gRPC call) | Asynchronous (via broker) |
|---|---|---|
| Caller waits for result? | Yes | No (fire-and-forget or reply later) |
| Coupling | Temporal — both must be up | Loose — consumer can be offline |
| Failure blast radius | Propagates up the call chain | Contained; retried later |
| Latency of caller | Bound to slowest callee | Constant (just an enqueue) |
| Backpressure handling | Timeouts, retries, circuit breakers | Natural — messages queue up |
| Best for | Reads, queries needing an immediate answer | Commands/events, slow work, fan-out |
| Debuggability | Easy — one linear trace | Harder — flows span time and services |
Async is not free: you trade simplicity and immediate consistency for eventual consistency, harder debugging, and new failure modes (duplicate messages, out-of-order delivery, poison messages). Use it where the benefits above matter; keep synchronous calls for reads that need an answer now.
The actors
- Producer (publisher) — creates messages and sends them to the broker.
- Broker — the middleware that receives, stores, routes, and delivers messages. It provides durability, routing rules, and delivery guarantees.
- Consumer (subscriber / worker) — reads messages and processes them.
- Message — a self-contained unit of data: a payload (the body, e.g. JSON/Avro/Protobuf) plus metadata/headers (a message ID, timestamp, content type, routing key, correlation ID, etc.).
A well-designed message is self-describing and versioned. Prefer explicit schemas (JSON Schema, Avro, Protobuf) and a schema registry so producers and consumers can evolve independently.
Key Concepts
Messaging models: queue vs pub/sub
There are two foundational models. Most brokers support one or both.
1. Message queue (point-to-point) — a message is delivered to exactly one consumer among those listening. Multiple consumers on the same queue compete for messages, which spreads work (the “competing consumers” pattern). This is the model for task/job queues.
Producer ──> [ Queue ] ──> Consumer A (each message goes to exactly one)
└──> Consumer B
2. Publish/subscribe (pub/sub) — a message published to a topic is delivered to every subscriber. Each subscriber gets its own copy. This is the model for event broadcasting / fan-out.
- Producer
- Topic
- Subscriber A (billing)Subscriber B (shipping)Subscriber C (analytics)
| Queue (point-to-point) | Pub/Sub (topics) | |
|---|---|---|
| Delivery | One consumer per message | All subscribers get a copy |
| Purpose | Distribute work | Broadcast events |
| Adding consumers | Scales throughput (share load) | Adds a new independent reader |
| Typical use | Background jobs, task processing | Event-driven architecture, fan-out |
| Examples | SQS, RabbitMQ queue, Celery | Kafka topic, SNS, Google Pub/Sub, Redis Pub/Sub |
Note that Kafka blurs the line: a topic is pub/sub across consumer groups, but within a single group, partitions are distributed among members like competing consumers (see below).
Delivery semantics
The single most important thing to understand about any broker is what guarantees it makes about delivery.
| Semantic | Guarantee | Trade-off | When to use |
|---|---|---|---|
| At-most-once | Each message delivered 0 or 1 times; may be lost, never duplicated. | Fastest, simplest; data loss possible. | Metrics, logs where occasional loss is fine. |
| At-least-once | Each message delivered 1+ times; never lost, may duplicate. | Requires idempotent consumers to handle dupes. | The pragmatic default for most systems. |
| Exactly-once | Each message takes effect once and only once. | Expensive, limited scope, easy to misunderstand. | Financial ledgers, dedup-critical flows. |
“Exactly-once” is subtle. True end-to-end exactly-once delivery across a network is impossible in the general case. What brokers actually offer is exactly-once processing within specific boundaries — e.g. Kafka’s transactions give exactly-once semantics for read-process-write cycles inside Kafka. In practice, you achieve effective exactly-once by combining at-least-once delivery with idempotent consumers. Do not assume “exactly-once” removes the need for idempotency.
Acknowledgements (acks)
Delivery guarantees hinge on acks. After a consumer processes a message, it sends an ack to the broker, which then removes/advances past the message. If the consumer crashes before acking, the broker re-delivers.
- Auto-ack (ack on receive, before processing) → at-most-once.
- Manual ack after processing → at-least-once.
- A nack (negative ack) or timeout tells the broker to redeliver or route to a dead-letter queue.
On the producer side, publisher confirms (RabbitMQ) or acks=all (Kafka) ensure the broker durably persisted the message before the producer considers it sent.
Idempotent consumers
Because at-least-once means duplicates, consumers must be idempotent — processing the same message twice has the same effect as once. Techniques:
- Dedup by message ID — store processed message IDs (in Redis/DB) and skip ones you’ve seen.
- Idempotency keys — natural keys or client-supplied keys that make writes safe to retry (
INSERT ... ON CONFLICT DO NOTHING, upserts). - Conditional updates —
UPDATE ... WHERE status = 'pending'so a replay is a no-op.
def handle(msg):
if seen.exists(msg.id): # already processed → skip
ack(msg); return
with db.transaction():
apply(msg.payload) # do the work
seen.add(msg.id, ttl=7*DAY) # record it (ideally in same tx)
ack(msg)
Ordering
Global ordering is expensive. Most brokers guarantee ordering only within a partition (Kafka) or a single queue with one consumer (RabbitMQ). To keep related messages ordered, route them to the same partition/queue using a partition key (e.g. userId), accepting that unrelated messages may interleave. Adding parallel consumers usually sacrifices strict ordering.
Dead-letter queues (DLQ)
A message that repeatedly fails processing is a poison message — retrying it forever blocks the queue and wastes resources. A dead-letter queue is where such messages are sent after N failed attempts (or on TTL expiry / queue overflow). This isolates bad messages so healthy ones keep flowing, and gives you a place to inspect, alert on, and manually replay failures.
Best practice: retry a few times (often with exponential backoff, via a delay/retry queue), then DLQ. Monitor DLQ depth — a growing DLQ is an incident signal.
RabbitMQ vs Kafka — deep comparison
These are the two dominant brokers, built on fundamentally different models. RabbitMQ is a smart broker / dumb consumer message queue (AMQP); Kafka is a dumb broker / smart consumer distributed commit log.
RabbitMQ model (AMQP)
Producers publish to an exchange, not directly to a queue. The exchange routes to queues based on bindings and a routing key. Consumers read from queues. Once a message is acked, it is gone.
Producer ──> [ Exchange ] ──(binding, routing key)──> [ Queue ] ──> Consumer
Exchange types:
| Exchange | Routing behaviour |
|---|---|
| direct | Route to queues whose binding key exactly equals the routing key. |
| topic | Pattern-match routing key against binding patterns (orders.*.eu, logs.#). |
| fanout | Broadcast to all bound queues, ignoring routing key (pub/sub). |
| headers | Route based on message header attributes instead of routing key. |
RabbitMQ excels at complex routing, per-message priorities, flexible topologies, and low-latency task distribution. Messages are typically consumed and deleted (a queue is not a durable log you can rewind).
Kafka model (log)
Kafka is a distributed, append-only, partitioned commit log. A topic is split into partitions; each partition is an ordered, immutable sequence of records. Producers append to partitions (chosen by key hash). Consumers track their own offset (position) and read forward. Messages are retained (by time or size) regardless of consumption — so multiple consumers can read the same data, and you can replay from any offset.
Partition 0: [r0 r1 r2 r3 ...] ← offset
Producer ──> [ Topic ] Partition 1: [r0 r1 r2 ...]
Partition 2: [r0 r1 ...]
Consumer group members each own a subset of partitions.
Consumer groups: all consumers in a group share the topic’s partitions — each partition is read by exactly one member (competing consumers → parallelism). Different groups read the topic independently (pub/sub → fan-out). Parallelism is capped by the partition count.
Comparison table
| Aspect | RabbitMQ | Kafka |
|---|---|---|
| Core model | Message queue / broker (AMQP) | Distributed commit log |
| Routing | Rich: exchanges, bindings, routing keys, patterns | Simple: topic + partition (by key) |
| Message lifetime | Deleted after ack (transient) | Retained by policy; not deleted on read |
| Replay / re-read | No (once consumed, gone) | Yes — seek to any offset, reprocess history |
| Ordering | Per queue | Per partition |
| Consumption tracking | Broker tracks acks per message | Consumer tracks its own offset |
| Throughput | High (tens–hundreds k msg/s) | Very high (millions msg/s), sequential I/O |
| Latency | Very low | Low, but tuned for throughput |
| Parallelism model | Multiple consumers on a queue | Partitions within a consumer group |
| Fan-out to many readers | fanout exchange / multiple queues | Multiple consumer groups (cheap) |
| Priorities | Yes (priority queues) | No native message priority |
| Delivery | At-most / at-least once | At-least / exactly-once (transactions) |
| Backpressure | Prefetch limit, flow control | Consumer pulls at its own pace |
| Protocol | AMQP 0-9-1 (also MQTT, STOMP) | Custom binary over TCP |
| Ecosystem | Plugins, mature tooling | Kafka Streams, Connect, ksqlDB, Schema Registry |
| Operational weight | Moderate | Heavier (was ZooKeeper; now KRaft) |
When to use which
Choose RabbitMQ when:
- You need task/job queues and background work distribution.
- You need complex routing logic (topic/header-based).
- You want per-message priority, TTLs, and flexible ack semantics.
- Messages are transient commands to be processed once and discarded.
- Lower operational complexity for moderate volumes.
Choose Kafka when:
- You are building event streaming / an event-driven backbone.
- You need to replay history or have multiple independent consumers of the same stream.
- You need very high throughput and durable event storage (event log as source of truth).
- You are doing stream processing, analytics, log aggregation, or CDC.
- Ordering per key at scale matters.
Many organisations run both: Kafka as the streaming backbone/event log, RabbitMQ (or SQS/Celery) for command-style task queues.
Other brokers (brief)
| Broker | Model | Notes |
|---|---|---|
| Redis Streams | Log-like stream + consumer groups | Lightweight, persistent-ish, great if you already run Redis; less durable than Kafka. |
| Redis Pub/Sub | Fire-and-forget pub/sub | No persistence — messages lost if no subscriber is connected. Good for ephemeral signals. |
| NATS / NATS JetStream | Lightweight pub/sub; JetStream adds persistence | Extremely fast, simple ops, good for microservices and edge/IoT. |
| AWS SQS | Managed queue (point-to-point) | Standard (at-least-once, best-effort order) or FIFO (ordered, dedup). Serverless, auto-scaling. |
| AWS SNS | Managed pub/sub | Fan-out to SQS, Lambda, HTTP, email. Often paired with SQS (SNS→SQS fan-out). |
| Google Pub/Sub | Managed global pub/sub | At-least-once, push or pull, auto-scaling, ordering keys. |
| Apache Pulsar | Log + queue hybrid | Multi-tenancy, tiered storage, geo-replication; unifies streaming and queuing. |
For managed cloud stacks, SQS/SNS or Google Pub/Sub remove most operational burden and are excellent defaults.
Streaming & event-driven architecture
In event-driven architecture (EDA), services communicate by emitting and reacting to events (“something happened”, e.g. PaymentReceived) rather than issuing commands. This maximises decoupling: the emitter doesn’t know or care who consumes.
- Event notification — a thin event (“order 123 changed”); consumers call back for details.
- Event-carried state transfer — the event carries all needed data, so consumers don’t call back.
- Event sourcing — the event log is the source of truth. State is derived by replaying events, not stored as current-value rows. This gives a full audit trail and time-travel, at the cost of complexity and needing projections/read models. Kafka’s replayable log makes it a natural fit.
Change Data Capture (CDC) turns a database’s commit log (e.g. Postgres WAL, MySQL binlog) into a stream of change events, typically via tools like Debezium into Kafka. This lets you propagate data changes to other systems (search indexes, caches, data warehouses) without dual writes — and it underpins the outbox pattern below.
Backpressure, throttling, and load shedding
When producers outpace consumers, the system must cope or it will fall over.
- Backpressure — signalling upstream to slow down. A pull-based consumer (Kafka) naturally applies backpressure by fetching only what it can handle. RabbitMQ uses prefetch (QoS) to limit unacked messages per consumer and flow control to slow fast publishers when memory is high.
- Throttling / rate limiting — deliberately cap the rate at which producers publish or consumers process, protecting downstream resources (e.g. a fragile third-party API).
- Load shedding — under overload, deliberately drop lower-priority messages to protect the system (better to lose some non-critical work than crash entirely). Often paired with priority queues.
- Load shifting / leveling — move work in time: enqueue during peaks, process during troughs. This is a primary reason to use queues at all.
Watch queue depth / consumer lag as your key health metric. Growing lag means consumers can’t keep up — scale out, optimise, throttle producers, or shed load. Also bound your queues: unbounded queues just move the failure from “overloaded service” to “out-of-memory broker”.
Patterns
Outbox pattern
The classic problem: you must update your database and publish an event atomically. If you write to the DB then publish, a crash in between loses the event (dual-write problem). The outbox pattern solves it:
- In the same local DB transaction as your business change, insert the event into an
outboxtable. - A separate relay (a poller or CDC via Debezium) reads unpublished outbox rows and publishes them to the broker, marking them sent.
Because the business write and the outbox insert share one transaction, they succeed or fail together. The relay provides at-least-once publishing to the broker.
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = 42;
INSERT INTO outbox (id, topic, payload) VALUES (gen_random_uuid(), 'OrderPaid', '{"orderId":42}');
COMMIT;
-- relay later: SELECT * FROM outbox WHERE published = false; publish; mark published.
Saga
A saga manages a distributed transaction across services without a global 2-phase commit. It is a sequence of local transactions, each publishing an event that triggers the next step; if a step fails, compensating transactions undo the prior ones.
- Choreography — each service reacts to events and emits the next; no central coordinator. Simple but the flow is implicit and can be hard to follow.
- Orchestration — a central orchestrator tells each service what to do and handles compensation. Explicit and easier to reason about; the orchestrator is a dependency.
Example (order saga): OrderCreated → reserve inventory → charge payment → ship. If payment fails, emit compensations: release inventory, cancel order.
Competing consumers
Multiple consumers read from one queue to process messages in parallel. The broker delivers each message to only one of them, so throughput scales with the number of workers. This is the workhorse pattern for job queues. Combine with idempotency (redeliveries) and be aware it trades away strict ordering.
Fan-out
One message reaches many consumers, each doing something different (billing, email, analytics). Implemented via pub/sub topics, a fanout exchange, or SNS→multiple SQS queues. The key benefit: adding a new consumer requires no change to the producer.
Task/job queues for background work
Application-level job queues sit on top of a broker to run background work (send email, resize image, generate report). They add developer ergonomics: retries, scheduling, result storage, and worker management.
| Tool | Ecosystem | Backend | Notes |
|---|---|---|---|
| Celery | Python | RabbitMQ / Redis | Mature, feature-rich: retries, ETA/countdown, chords, beat scheduler. |
| Sidekiq | Ruby | Redis | Fast, threaded, very popular in Rails. |
| BullMQ | Node.js | Redis | Robust queues, rate limiting, repeatable jobs, flows. |
| RQ | Python | Redis | Simpler alternative to Celery. |
| Dramatiq | Python | RabbitMQ / Redis | Simpler, opinionated Celery alternative. |
| Temporal | Polyglot | Own | Durable workflow engine (beyond a simple queue) for long-running orchestration. |
Example (Celery):
from celery import Celery
app = Celery("tasks", broker="amqp://localhost", backend="redis://localhost")
@app.task(bind=True, max_retries=3, acks_late=True)
def send_welcome_email(self, user_id):
try:
email.send(user_id)
except TransientError as exc:
raise self.retry(exc=exc, countdown=2 ** self.request.retries)
# producer
send_welcome_email.delay(user_id=42) # returns instantly; runs in a worker
Use acks_late=True (ack after work, not before) for at-least-once semantics, keep tasks idempotent, set max_retries with backoff, and route poison messages to a DLQ.
Best Practices
- Default to at-least-once + idempotent consumers. Assume every message can arrive more than once and out of order. Design handlers to be safe under replay.
- Use dead-letter queues. Never let a poison message block a queue. Retry with backoff, then DLQ, then alert on DLQ depth.
- Make messages self-describing and versioned. Include a message ID, timestamp, type, and schema version. Use a schema registry (Avro/Protobuf) for safe evolution; add fields, don’t repurpose them.
- Solve dual writes with the outbox pattern, not “write DB then publish”. Never rely on two independent systems both succeeding without a transaction or CDC bridging them.
- Keep messages small. Put large payloads (files, images) in object storage and send a reference/pointer, not the bytes (claim-check pattern).
- Bound your queues and monitor lag. Consumer lag / queue depth is the vital sign. Alert on growth; unbounded queues just relocate the outage.
- Choose ordering deliberately. Only require ordering where you truly need it, and scope it to a partition/key. Global ordering kills parallelism.
- Design for backpressure and overload. Use prefetch/QoS, rate limiting, and (where appropriate) load shedding of low-priority work. Decide what to drop before you’re on fire.
- Set correlation and causation IDs. Propagate a correlation ID through async flows so you can trace a request across services and time (distributed tracing).
- Separate retryable from non-retryable errors. Retry transient failures (timeouts) with backoff; send permanent failures (validation errors) straight to the DLQ — don’t retry them.
- Prefer managed brokers when you can. SQS/SNS, Google Pub/Sub, and managed Kafka/RabbitMQ remove huge operational burden; self-host only when you must.
- Pick the model to the job. Queue for distributing work; pub/sub for broadcasting events. RabbitMQ for routing/tasks; Kafka for streaming/replay. Don’t force one tool to do everything.
- Test failure paths. Deliberately kill consumers mid-process, inject duplicates, and delay messages in staging to verify idempotency, retries, and DLQ behaviour.
References
- RabbitMQ — Tutorials & Concepts
- RabbitMQ — AMQP 0-9-1 Model Explained
- Apache Kafka — Documentation
- Apache Kafka — Introduction & Design
- Confluent — What is Event-Driven Architecture
- Microservices.io — Transactional Outbox & Saga patterns
- AWS — SQS vs SNS vs EventBridge messaging
- Enterprise Integration Patterns (Hohpe & Woolf)