Service order vừa tạo đơn hàng xong cần gửi email xác nhận, trừ tồn kho, cập nhật analytics, và thông báo cho warehouse. Nếu order service gọi trực tiếp 4 service kia bằng HTTP, một cái chậm hoặc sập thì cả request bị ảnh hưởng — user chờ 10 giây rồi thấy timeout, trong khi đơn hàng thực ra đã được tạo thành công trong database.

Đây là lúc message broker xuất hiện — order service publish event “order.created”, các service khác subscribe và xử lý bất đồng bộ. Order service không cần biết ai đang lắng nghe, không cần chờ ai xử lý xong. Nghe đơn giản, nhưng thực tế triển khai message broker có rất nhiều gotcha mà mình phải trải qua mới hiểu: message bị mất, message xử lý hai lần, message đến sai thứ tự, consumer chết giữa chừng. Bài này đi qua các khái niệm cốt lõi và gotcha thực tế khi dùng message broker.


Tại sao không gọi HTTP trực tiếp

Gọi HTTP đồng bộ giữa các service hoạt động tốt khi số service ít và mọi thứ đều nhanh. Nhưng khi hệ thống phát triển, nó tạo ra ba vấn đề.

Coupling thời gian. Service A gọi service B — nếu B chậm 5 giây, A cũng chậm 5 giây. Nếu B sập, A phải retry hoặc trả lỗi cho user. Mỗi dependency đồng bộ thêm vào là thêm một điểm failure ảnh hưởng trực tiếp đến latency và availability của service gọi.

Fan-out phức tạp. Khi một sự kiện cần thông báo cho 5 service, service gốc phải biết danh sách tất cả service cần gọi, retry từng cái nếu fail, handle timeout từng cái. Thêm service mới lắng nghe event → phải sửa code service gốc. Đây là coupling hành vi — service gốc biết quá nhiều về hệ thống xung quanh.

Spike traffic. Flash sale tạo 10,000 order trong 1 phút. Nếu mỗi order gọi HTTP đến 4 service, đó là 40,000 request đồng thời đến các downstream service. Inventory service không scale kịp → cascade failure. Message broker cho phép buffering — order event nằm trong queue, inventory service xử lý theo tốc độ của nó, không bị overwhelm.

Tất nhiên, message broker không phải silver bullet. Nó thêm infrastructure phải vận hành, thêm complexity debugging (event đi đâu, xử lý chưa, lỗi ở đâu), và thêm latency (event không được xử lý ngay lập tức). Chọn message broker khi lợi ích decoupling và buffering vượt qua chi phí complexity.


Hai mô hình: point-to-point vs pub/sub

Point-to-point (queue)

Mỗi message được một và chỉ một consumer xử lý. Producer gửi message vào queue, một consumer lấy ra xử lý, message bị xoá khỏi queue. Nếu có nhiều consumer trên cùng queue, message được phân phối giữa chúng — mỗi message chỉ đến một consumer (competing consumers pattern).

Dùng khi: mỗi task chỉ cần xử lý một lần. Ví dụ: resize ảnh, gửi email, generate PDF. Bạn có 10 worker cùng đọc từ queue “resize-image” — mỗi ảnh chỉ được resize một lần bởi một worker.

RabbitMQ classic queue, AWS SQS, Redis BRPOP là ví dụ point-to-point.

Pub/sub (topic)

Producer publish message vào topic, tất cả subscriber nhận bản copy. Thêm subscriber mới không ảnh hưởng producer — producer không biết (và không cần biết) ai đang lắng nghe.

Dùng khi: một event cần trigger nhiều hành động khác nhau. “Order created” cần email service gửi mail, inventory service trừ hàng, analytics service ghi metric — mỗi service là một subscriber độc lập, xử lý cùng event theo cách riêng.

Kafka topic, RabbitMQ exchange + multiple queues, AWS SNS + SQS là ví dụ pub/sub.

Trong thực tế, nhiều hệ thống kết hợp cả hai: pub/sub để broadcast event, rồi mỗi subscriber có queue riêng (consumer group) để xử lý point-to-point trong nhóm consumer của mình. Kafka consumer group là pattern này — nhiều consumer group subscribe cùng topic (pub/sub), trong mỗi group thì partition được phân phối giữa consumer (point-to-point).


Delivery semantics — phần quan trọng nhất

Đây là phần mà mình thấy nhiều dev hiểu sai nhất, và sai ở đây gây mất data hoặc xử lý trùng trên production.

At-most-once

Message được gửi một lần, không retry. Nếu consumer fail giữa chừng hoặc network lỗi, message mất. Ưu điểm: đơn giản, nhanh, không duplicate. Nhược điểm: chấp nhận mất message.

Dùng khi mất message chấp nhận được — ví dụ metrics sampling, log shipping không critical, notification “nice-to-have”. Nếu mất 1 trong 1000 log line không ảnh hưởng dashboard, at-most-once đủ tốt.

At-least-once

Message được retry cho đến khi consumer acknowledge thành công. Nếu consumer xử lý xong nhưng ack bị mất (network timeout), broker gửi lại → consumer nhận message lần thứ hai. Ưu điểm: không mất message. Nhược điểm: có thể duplicate.

Đây là delivery semantic phổ biến nhất trong production vì mất message thường nguy hiểm hơn xử lý trùng. Nhưng nó đòi hỏi consumer phải idempotent — xử lý cùng message hai lần cho cùng kết quả. Mình sẽ nói kỹ hơn ở phần idempotent consumer.

Exactly-once

Mỗi message được xử lý đúng một lần — không mất, không trùng. Nghe lý tưởng, nhưng trong hệ thống phân tán, exactly-once rất khó đạt được end-to-end. Kafka Transactions hỗ trợ exactly-once trong phạm vi Kafka (produce + consume + produce trong cùng transaction), nhưng khi consumer ghi vào database bên ngoài, bạn vẫn cần idempotency ở application layer.

Lời khuyên thực tế: design cho at-least-once + idempotent consumer. Đây là pattern đáng tin cậy nhất mà không phải phụ thuộc vào exactly-once semantics của broker — vốn có nhiều ràng buộc và overhead.


Acknowledgment và consumer failure

Khi consumer nhận message từ broker, nó cần nói cho broker biết “tôi đã xử lý xong” — đây là acknowledgment (ack). Timing của ack quyết định behaviour khi consumer crash.

Ack trước khi xử lý (auto-ack). Consumer nhận message, ack ngay, rồi mới xử lý. Nếu consumer crash giữa chừng — message đã bị ack, broker coi như đã xử lý xong, message mất. Đây là at-most-once.

Ack sau khi xử lý (manual ack). Consumer nhận message, xử lý xong, rồi mới ack. Nếu consumer crash giữa chừng — message chưa được ack, broker gửi lại cho consumer khác. Đây là at-least-once — message không mất nhưng có thể được xử lý hai lần.

Mình luôn dùng manual ack cho bất kỳ thứ gì quan trọng. Auto-ack chỉ dùng cho message mà mất cũng không sao.

Một gotcha mình từng gặp: consumer xử lý message mất 30 giây (gọi external API chậm), nhưng broker có visibility timeout 10 giây. Sau 10 giây chưa thấy ack, broker nghĩ consumer chết, gửi message cho consumer khác. Consumer gốc xử lý xong, ack — nhưng consumer thứ hai cũng đang xử lý. Kết quả: message bị xử lý hai lần. Fix bằng cách tăng visibility timeout lớn hơn max processing time, hoặc consumer renew timeout trong quá trình xử lý.


Dead letter queue — nơi message hỏng đến

Khi consumer không thể xử lý message — parse lỗi, business logic fail, dependency timeout — message cần đi đâu? Nếu chỉ retry liên tục, message lỗi sẽ block queue (poison message), consumer xử lý mãi message đó mà không process được message mới.

Dead letter queue (DLQ) là queue riêng chứa message mà consumer đã thử xử lý nhiều lần (ví dụ 3-5 lần) mà vẫn fail. Sau N lần retry, message được chuyển sang DLQ thay vì retry tiếp. Consumer queue chính tiếp tục xử lý message tiếp theo, không bị block.

DLQ cần monitoring — alert khi có message mới vào DLQ, vì đó là dấu hiệu có vấn đề: bug trong consumer code, schema message thay đổi không backward compatible, hoặc external dependency lỗi. Mình set alert “DLQ count > 0 trong 5 phút” cho mọi queue critical — bất kỳ message nào vào DLQ đều cần investigation.

Message trong DLQ cần có đủ context để debug: message gốc, error message, timestamp, retry count, consumer instance. Không có context thì DLQ chỉ là nơi message đến chết mà không ai biết tại sao.

Quy trình xử lý DLQ: investigate root cause → fix code hoặc data → replay message từ DLQ về queue chính. Nếu broker hỗ trợ (SQS có “redrive” từ DLQ), replay đơn giản. Nếu không, viết script đọc DLQ và publish lại vào queue chính.


Ordering guarantee — phức tạp hơn tưởng

“Message A được publish trước message B, consumer sẽ nhận A trước B.” Nghe hiển nhiên, nhưng trong distributed system, ordering là thứ đắt đỏ và thường không được đảm bảo mặc định.

Global ordering

Mọi message trên topic/queue được xử lý theo đúng thứ tự publish. Rất đắt — thường yêu cầu single partition/queue, tức chỉ một consumer xử lý tại một thời điểm. Throughput bị giới hạn bởi tốc độ một consumer.

Mình gần như không bao giờ cần global ordering. Nếu bạn nghĩ mình cần, hãy tự hỏi: “thực sự cần ordering giữa TẤT CẢ message, hay chỉ cần ordering cho message liên quan đến cùng entity?”

Partition ordering (per-key ordering)

Message cùng key (ví dụ cùng order_id hoặc user_id) được gửi vào cùng partition, và trong partition thì thứ tự được đảm bảo. Message khác key có thể ở partition khác, xử lý song song, không cần ordering giữa chúng.

Đây là pattern phổ biến nhất và thường là đủ. Kafka dùng partition key cho việc này — cùng key luôn vào cùng partition. RabbitMQ có consistent hashing exchange cho pattern tương tự.

Ví dụ: event order.created, order.paid, order.shipped cho order #123 cần xử lý đúng thứ tự. Dùng order_id làm partition key — tất cả event của order #123 vào cùng partition, consumer xử lý theo thứ tự. Event của order #124 có thể ở partition khác, xử lý song song.

Khi ordering bị phá vỡ

Ngay cả với partition ordering, có tình huống ordering bị phá vỡ mà ít ai lường trước.

Consumer fail và retry — message A được xử lý, fail, retry. Trong khi đó message B (cùng partition) được consumer khác xử lý thành công. Message A retry thành công sau đó. Kết quả: B trước A dù A publish trước. Fix bằng cách đảm bảo retry giữ nguyên consumer hoặc stop processing partition cho đến khi retry thành công.

Rebalance consumer group — khi consumer join/leave group, Kafka rebalance partition. Trong quá trình rebalance, có thể message đang xử lý bị reassign. Consumer mới bắt đầu từ offset cuối, message đang xử lý dở bị bỏ qua hoặc xử lý lại.

Mình hay nói: nếu logic phụ thuộc vào ordering, hãy thiết kế để chịu được out-of-order — check timestamp hoặc version number trước khi apply, reject message cũ hơn state hiện tại. Idempotency + version check mạnh hơn ordering guarantee trong hầu hết trường hợp.


Idempotent consumer — tại sao bắt buộc

At-least-once delivery nghĩa là consumer sẽ nhận message trùng. Nếu consumer không idempotent, “trừ tồn kho 1 sản phẩm” chạy hai lần → trừ 2 sản phẩm. “Gửi email xác nhận” chạy hai lần → user nhận 2 email.

Có vài pattern implement idempotency cho consumer.

Deduplication bằng message ID. Mỗi message có unique ID (UUID). Consumer ghi ID vào bảng “processed_messages” sau khi xử lý. Trước khi xử lý message mới, check ID đã tồn tại chưa — nếu có thì skip. Đơn giản, hoạt động tốt, nhưng bảng dedup cần cleanup định kỳ (xoá record cũ hơn N ngày).

Idempotent operation. Thiết kế operation để chạy nhiều lần cho cùng kết quả. SET inventory = 5 là idempotent — chạy 10 lần vẫn kết quả 5. SET inventory = inventory - 1 không idempotent — chạy 2 lần trừ 2. Đổi operation sang “set inventory cho order #123 = 4” (absolute value thay vì delta) biến nó thành idempotent.

Optimistic locking với version. Message chứa version number. Consumer check version trước khi update — nếu version trong DB đã cao hơn hoặc bằng version trong message, skip. Pattern này cũng handle out-of-order message — message cũ hơn tự động bị skip.

Mình thường dùng deduplication bằng message ID cho đơn giản, kết hợp với version check cho entity có state machine phức tạp.


So sánh nhẹ: Kafka, RabbitMQ, SQS

Không có “message broker tốt nhất” — mỗi cái fit cho use case khác nhau.

RabbitMQ là message broker truyền thống, hỗ trợ nhiều protocol (AMQP, MQTT, STOMP), routing linh hoạt (exchange types: direct, topic, fanout, headers). Tốt cho: task queue, RPC pattern, routing phức tạp. Message bị xoá sau khi ack — không replay được. Throughput trung bình (hàng chục nghìn msg/s trên single node).

Kafka là distributed log — message được lưu trên disk theo thứ tự, consumer đọc bằng offset. Message không bị xoá sau khi consume — có thể replay bằng cách seek offset. Throughput rất cao (hàng trăm nghìn đến triệu msg/s). Tốt cho: event streaming, event sourcing, ETL pipeline, audit log. Phức tạp hơn RabbitMQ để vận hành — partition, replication, consumer group rebalance đều cần hiểu.

AWS SQS là managed queue — không cần vận hành, auto-scale, pay per message. Throughput cao (gần như không giới hạn với standard queue). Standard queue không đảm bảo ordering, FIFO queue đảm bảo nhưng throughput thấp hơn (300 msg/s hoặc 3000 msg/s với batching). Tốt cho: task queue trên AWS, khi không muốn vận hành broker.

Quy tắc ngón tay cái của mình: dùng SQS nếu đang trên AWS và chỉ cần task queue đơn giản. Dùng RabbitMQ nếu cần routing phức tạp hoặc đã có expertise. Dùng Kafka nếu cần event log dạng replay được, throughput rất cao, hoặc đã có Kafka cluster.


Schema evolution — message thay đổi theo thời gian

Khi bạn thêm field mới vào event, consumer cũ đọc message mới sẽ ignore field lạ — OK. Nhưng khi bạn xoá field hoặc đổi type, consumer cũ sẽ crash — không OK.

Backward compatible changes (an toàn): thêm field mới với default value, thêm new event type. Consumer cũ ignore field/event chưa biết.

Breaking changes (nguy hiểm): xoá field, đổi type field, đổi ý nghĩa field. Consumer cũ sẽ fail khi nhận message mới.

Pattern mà mình dùng: schema registry (Confluent Schema Registry cho Kafka, hoặc custom registry). Producer register schema trước khi publish, consumer validate message theo schema. Registry enforce backward compatibility — reject schema mới nếu nó break consumer cũ.

Nếu không dùng schema registry, ít nhất hãy version event: order.created.v1, order.created.v2. Consumer subscribe version nó hiểu, migrate dần sang version mới.


Monitoring message system

Message broker cần monitoring riêng — không chỉ CPU/memory của broker mà còn health của toàn bộ pipeline.

Consumer lag là metric quan trọng nhất — khoảng cách giữa message mới nhất được publish và message mới nhất được consumer xử lý. Lag tăng nghĩa là consumer không xử lý kịp tốc độ publish. Lag tăng liên tục là dấu hiệu cần scale consumer hoặc optimize processing time.

DLQ depth — số message trong dead letter queue. Bất kỳ message nào trong DLQ đều cần investigation.

Processing time per message — consumer mất bao lâu để xử lý một message. Nếu tăng đột ngột, có thể downstream service chậm hoặc có bug.

Publish rate vs consume rate — nếu publish rate vượt consume rate liên tục, lag sẽ tăng. Đây là signal cần scale consumer.

Mình set alert cho: consumer lag > threshold (tuỳ SLO, thường vài phút), DLQ depth > 0, consumer processing time P99 tăng gấp đôi so với baseline.


Anti-pattern hay gặp

Dùng message broker cho request-response. Publish message rồi chờ response message — đây là RPC qua message broker. Nó thêm latency, thêm complexity, mà HTTP call đồng bộ đơn giản hơn và nhanh hơn. Message broker dành cho async processing, không phải thay thế HTTP.

Message quá lớn. Nhét cả file 10 MB vào message — broker phải lưu trữ, truyền tải, deserialize. Tốt hơn: gửi file lên S3, message chỉ chứa S3 URL. Message nên nhỏ — vài KB là lý tưởng.

Không có DLQ. Message fail retry vô hạn, block consumer. Hoặc message bị drop silently, mất data. Luôn có DLQ + alert.

Consumer không idempotent. “Chắc message không bao giờ duplicate đâu” — sai. At-least-once delivery LUÔN có thể duplicate. Thiết kế consumer idempotent từ đầu, đừng đợi đến khi thấy duplicate trên production.

Schema thay đổi không backward compatible. Xoá field hoặc đổi type — consumer cũ crash. Luôn thêm field mới (additive changes), không xoá field cũ, dùng version nếu cần breaking change.


Tóm tắt

Message broker giải quyết coupling thời gian, fan-out phức tạp, và spike traffic — nhưng thêm complexity vận hành và debugging. Chọn khi lợi ích decoupling và buffering rõ ràng, không phải vì “microservice nên dùng message queue”.

At-least-once + idempotent consumer là pattern đáng tin cậy nhất cho production. Ack sau khi xử lý (manual ack), không ack trước. DLQ cho message fail — alert khi DLQ có message, investigate root cause, replay sau khi fix.

Ordering chỉ đảm bảo per-key (partition ordering) — đừng phụ thuộc global ordering. Thiết kế consumer chịu được out-of-order bằng version check. Schema evolution phải backward compatible — thêm field OK, xoá field nguy hiểm.

Monitor consumer lag, DLQ depth, và processing time. Consumer lag tăng liên tục = cần scale consumer hoặc optimize. Message broker không phải “cài xong quên” — nó cần attention tương đương database.


Tham khảo