← Backend← Backend
BackendBackend19 Th7, 2026Jul 19, 202623 phút đọc20 min read

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 saokhi nào nên dùng async, hai messaging model cốt lõi (queuepub/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ĩaVí dụ
DecouplingProducer 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.
BufferingBroker 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ỗiNế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.
ScalabilityThêm consumer để xử lý song song cùng một queue (competing consumers).Scale từ 3 lên 30 worker để dọn backlog.
ResponsivenessTrả 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ả?Không (fire-and-forget hoặc trả lời sau)
CouplingTheo thời gian — cả hai phải “sống”Lỏng — consumer có thể offline
Phạm vi ảnh hưởng khi lỗiLan ngược lên chuỗi gọiBị chặn lại; retry sau
Latency của bên gọiBị ràng bởi callee chậm nhấtHằng số (chỉ là thao tác enqueue)
Xử lý backpressureTimeout, retry, circuit breakerTự nhiên — message dồn vào queue
Phù hợp choRead, query cần câu trả lời tức thìCommand/event, việc chậm, fan-out
Khả năng debugDễ — một trace tuyến tínhKhó 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)

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.

Pub/sub
  1. Producer
  2. Topic
  3. Subscriber A (billing)
    Subscriber B (shipping)
    Subscriber C (analytics)
Queue (point-to-point)Pub/Sub (topic)
DeliveryMột consumer mỗi messageMọi subscriber đều nhận bản sao
Mục đíchPhân phối công việcBroadcast event
Thêm consumerTăng throughput (chia tải)Thêm một reader độc lập mới
Dùng điển hìnhBackground job, task processingEvent-driven architecture, fan-out
Ví dụSQS, RabbitMQ queue, CeleryKafka 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 đổiKhi nào dùng
At-most-onceMỗ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-onceMỗ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-onceMỗ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.

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:

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:

ExchangeHành vi routing
directĐịnh tuyến tới queue có binding key khớp chính xác routing key.
topicKhớp mẫu (pattern) routing key với binding pattern (orders.*.eu, logs.#).
fanoutBroadcast 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ạnhRabbitMQKafka
Model cốt lõiMessage queue / broker (AMQP)Distributed commit log
RoutingPhong phú: exchange, binding, routing key, patternĐơn giản: topic + partition (theo key)
Vòng đời messageXóa sau ack (tạm thời)Retain theo policy; không xóa khi đọc
Replay / đọc lạiKhông (đã consume là mất)Có — seek tới offset bất kỳ, xử lý lại lịch sử
OrderingTheo từng queueTheo từng partition
Theo dõi consumptionBroker theo dõi ack từng messageConsumer tự theo dõi offset
ThroughputCao (hàng chục–trăm nghìn msg/s)Rất cao (triệu msg/s), I/O tuần tự
LatencyRất thấpThấp, nhưng tối ưu cho throughput
Model song songNhiều consumer trên một queuePartition trong một consumer group
Fan-out tới nhiều readerfanout exchange / nhiều queueNhiều consumer group (rẻ)
PriorityCó (priority queue)Không có priority message gốc
DeliveryAt-most / at-least onceAt-least / exactly-once (transaction)
BackpressurePrefetch limit, flow controlConsumer pull theo nhịp của mình
ProtocolAMQP 0-9-1 (cả MQTT, STOMP)Binary tùy biến qua TCP
Hệ sinh tháiPlugin, tooling trưởng thànhKafka Streams, Connect, ksqlDB, Schema Registry
Độ nặng vận hànhTrung bìnhNặng hơn (trước dùng ZooKeeper; nay KRaft)

Khi nào chọn cái nào

Chọn RabbitMQ khi:

Chọn Kafka khi:

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)

BrokerModelGhi chú
Redis StreamsStream kiểu log + consumer groupNhẹ, có tính bền tương đối, tuyệt nếu đã chạy Redis; kém bền hơn Kafka.
Redis Pub/SubPub/sub fire-and-forgetKhô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 JetStreamPub/sub nhẹ; JetStream thêm persistenceCực nhanh, vận hành đơn giản, hợp microservices và edge/IoT.
AWS SQSManaged queue (point-to-point)Standard (at-least-once, thứ tự best-effort) hoặc FIFO (có thứ tự, dedup). Serverless, tự scale.
AWS SNSManaged pub/subFan-out tới SQS, Lambda, HTTP, email. Thường ghép với SQS (SNS→SQS fan-out).
Google Pub/SubManaged pub/sub toàn cầuAt-least-once, push hoặc pull, tự scale, ordering key.
Apache PulsarLai log + queueMulti-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.

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.

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:

  1. Trong cùng một local DB transaction với thay đổi nghiệp vụ, chèn event vào bảng outbox.
  2. 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.

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áiBackendGhi chú
CeleryPythonRabbitMQ / RedisTrưởng thành, nhiều tính năng: retry, ETA/countdown, chord, beat scheduler.
SidekiqRubyRedisNhanh, threaded, rất phổ biến trong Rails.
BullMQNode.jsRedisQueue mạnh mẽ, rate limiting, repeatable job, flow.
RQPythonRedisLựa chọn đơn giản hơn Celery.
DramatiqPythonRabbitMQ / RedisThay thế Celery kiểu đơn giản, opinionated.
TemporalĐa ngôn ngữRiêngDurable 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

Tài liệu tham khảo

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

BenefitWhat it meansExample
DecouplingProducer 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.
BufferingThe 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 levelingBursty 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 isolationIf 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.
ScalabilityAdd more consumers to process the same queue in parallel (competing consumers).Scale from 3 to 30 workers to clear a backlog.
ResponsivenessReturn 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

DimensionSynchronous (e.g. REST/gRPC call)Asynchronous (via broker)
Caller waits for result?YesNo (fire-and-forget or reply later)
CouplingTemporal — both must be upLoose — consumer can be offline
Failure blast radiusPropagates up the call chainContained; retried later
Latency of callerBound to slowest calleeConstant (just an enqueue)
Backpressure handlingTimeouts, retries, circuit breakersNatural — messages queue up
Best forReads, queries needing an immediate answerCommands/events, slow work, fan-out
DebuggabilityEasy — one linear traceHarder — 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

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.

Pub/sub
  1. Producer
  2. Topic
  3. Subscriber A (billing)
    Subscriber B (shipping)
    Subscriber C (analytics)
Queue (point-to-point)Pub/Sub (topics)
DeliveryOne consumer per messageAll subscribers get a copy
PurposeDistribute workBroadcast events
Adding consumersScales throughput (share load)Adds a new independent reader
Typical useBackground jobs, task processingEvent-driven architecture, fan-out
ExamplesSQS, RabbitMQ queue, CeleryKafka 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.

SemanticGuaranteeTrade-offWhen to use
At-most-onceEach 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-onceEach message delivered 1+ times; never lost, may duplicate.Requires idempotent consumers to handle dupes.The pragmatic default for most systems.
Exactly-onceEach 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.

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:

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:

ExchangeRouting behaviour
directRoute to queues whose binding key exactly equals the routing key.
topicPattern-match routing key against binding patterns (orders.*.eu, logs.#).
fanoutBroadcast to all bound queues, ignoring routing key (pub/sub).
headersRoute 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

AspectRabbitMQKafka
Core modelMessage queue / broker (AMQP)Distributed commit log
RoutingRich: exchanges, bindings, routing keys, patternsSimple: topic + partition (by key)
Message lifetimeDeleted after ack (transient)Retained by policy; not deleted on read
Replay / re-readNo (once consumed, gone)Yes — seek to any offset, reprocess history
OrderingPer queuePer partition
Consumption trackingBroker tracks acks per messageConsumer tracks its own offset
ThroughputHigh (tens–hundreds k msg/s)Very high (millions msg/s), sequential I/O
LatencyVery lowLow, but tuned for throughput
Parallelism modelMultiple consumers on a queuePartitions within a consumer group
Fan-out to many readersfanout exchange / multiple queuesMultiple consumer groups (cheap)
PrioritiesYes (priority queues)No native message priority
DeliveryAt-most / at-least onceAt-least / exactly-once (transactions)
BackpressurePrefetch limit, flow controlConsumer pulls at its own pace
ProtocolAMQP 0-9-1 (also MQTT, STOMP)Custom binary over TCP
EcosystemPlugins, mature toolingKafka Streams, Connect, ksqlDB, Schema Registry
Operational weightModerateHeavier (was ZooKeeper; now KRaft)

When to use which

Choose RabbitMQ when:

Choose Kafka when:

Many organisations run both: Kafka as the streaming backbone/event log, RabbitMQ (or SQS/Celery) for command-style task queues.

Other brokers (brief)

BrokerModelNotes
Redis StreamsLog-like stream + consumer groupsLightweight, persistent-ish, great if you already run Redis; less durable than Kafka.
Redis Pub/SubFire-and-forget pub/subNo persistence — messages lost if no subscriber is connected. Good for ephemeral signals.
NATS / NATS JetStreamLightweight pub/sub; JetStream adds persistenceExtremely fast, simple ops, good for microservices and edge/IoT.
AWS SQSManaged queue (point-to-point)Standard (at-least-once, best-effort order) or FIFO (ordered, dedup). Serverless, auto-scaling.
AWS SNSManaged pub/subFan-out to SQS, Lambda, HTTP, email. Often paired with SQS (SNS→SQS fan-out).
Google Pub/SubManaged global pub/subAt-least-once, push or pull, auto-scaling, ordering keys.
Apache PulsarLog + queue hybridMulti-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.

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.

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:

  1. In the same local DB transaction as your business change, insert the event into an outbox table.
  2. 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.

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.

ToolEcosystemBackendNotes
CeleryPythonRabbitMQ / RedisMature, feature-rich: retries, ETA/countdown, chords, beat scheduler.
SidekiqRubyRedisFast, threaded, very popular in Rails.
BullMQNode.jsRedisRobust queues, rate limiting, repeatable jobs, flows.
RQPythonRedisSimpler alternative to Celery.
DramatiqPythonRabbitMQ / RedisSimpler, opinionated Celery alternative.
TemporalPolyglotOwnDurable 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

References