Giao diện
Asynchronous Messaging — Giao tiếp bất đồng bộ
Black Friday, 23:47. Order Service đang xử lý 50K đơn hàng mỗi phút. Payment Service chỉ chịu nổi 5K/min. Không có message queue — cascade failure lan từ Payment sang Order, rồi sang toàn bộ hệ thống, đúng thời điểm doanh thu cao nhất năm. Có Kafka đứng giữa — Order Service tiếp tục nhận đơn bình thường, message xếp hàng trong partition, Payment Service xử lý theo tốc độ của mình. Không ai chết. Không ai mất dữ liệu.
Decoupling không phải là một kỹ thuật hay ho để khoe trong tech talk. Nó là ranh giới giữa hệ thống scale được và hệ thống sụp đổ dưới tải thực tế. Bài viết này đi sâu vào ba mô hình messaging chính — message queue, pub/sub, event streaming — cùng các vấn đề thực chiến mà bạn sẽ gặp khi vận hành chúng: delivery semantics, dead letter queue, backpressure, và consumer group rebalancing.
Bức tranh tư duy
Hãy hình dung hệ thống bưu điện với ba cấp độ phục vụ.
Message Queue (Point-to-Point) hoạt động như gửi thư đến MỘT người cụ thể. Bạn bỏ thư vào hòm, bưu tá chuyển đến đúng địa chỉ. Nếu có nhiều bưu tá (competing consumers), ai rảnh thì lấy thư tiếp theo — nhưng mỗi lá thư chỉ được giao MỘT lần.
Pub/Sub giống hệt dịch vụ đặt báo. Tòa soạn (publisher) phát hành ấn phẩm, ai đăng ký (subscriber) thì nhận. Tòa soạn không cần biết có bao nhiêu người đọc, người đọc không cần biết ai viết. Fan-out tự nhiên.
Event Streaming (Kafka) là cuộn phim quay lại được. Mọi sự kiện được ghi vào log theo thứ tự, lưu trữ trong thời gian cấu hình (retention). Consumer mới có thể "tua lại" đọc từ đầu, consumer cũ đọc tiếp từ vị trí đã dừng (offset). Đây là điểm khác biệt căn bản so với message queue truyền thống — message không bị xóa sau khi đọc.
Khi nào phép so sánh bưu điện sai? Kafka partition không có tương đương trực tiếp trong bưu điện. Partition giống như nhiều băng chuyền song song — message cùng partition key luôn đi cùng một băng, nhưng các băng hoạt động độc lập. Không có khái niệm nào trong bưu điện truyền thống ánh xạ chính xác cơ chế này.
Cốt lõi kỹ thuật
1. Message Queue — Point-to-Point
Producer gửi message vào queue, broker lưu trữ, consumer lấy ra xử lý. Mỗi message được giao cho đúng MỘT consumer — đây là competing consumers pattern. Khi một consumer acknowledge message, broker xóa message đó khỏi queue.
RabbitMQ và Amazon SQS là hai đại diện phổ biến nhất. RabbitMQ sử dụng giao thức AMQP, hỗ trợ routing phức tạp qua exchange (direct, topic, fanout, headers). SQS là managed service của AWS, đơn giản hơn nhưng scale gần như vô hạn.
Khi nào chọn Message Queue? Task distribution — mỗi task chỉ cần xử lý một lần. Email sending, image processing, background job execution.
2. Pub/Sub Pattern
Publisher gửi message đến topic, broker nhân bản và giao cho TẤT CẢ subscriber đang đăng ký topic đó. Publisher không biết (và không cần biết) ai đang lắng nghe.
Fan-out là đặc tính cốt lõi: một event order.created có thể đồng thời trigger payment processing, inventory update, analytics tracking, và email notification — mỗi service nhận bản copy riêng, xử lý độc lập.
Google Cloud Pub/Sub, AWS SNS, và Redis Pub/Sub là các implementation phổ biến. RabbitMQ cũng hỗ trợ pub/sub qua fanout exchange.
3. Event Streaming — Kafka
Kafka không phải message queue. Kafka là distributed commit log. Sự khác biệt này quyết định mọi thứ.
Topic là đơn vị tổ chức logic — tương đương một "kênh" message. Mỗi topic được chia thành nhiều partition. Mỗi partition là một append-only log có thứ tự. Message được gán offset tăng dần trong partition.
Consumer Group là cơ chế Kafka dùng để phân phối tải. Mỗi partition trong một topic chỉ được gán cho MỘT consumer trong cùng consumer group. Nhiều consumer group khác nhau đọc cùng topic thì mỗi group nhận bản copy đầy đủ — đây là cách Kafka kết hợp point-to-point và pub/sub.
Retention — message không bị xóa sau khi đọc. Broker giữ message theo thời gian (mặc định 7 ngày) hoặc theo dung lượng. Consumer mới join có thể đọc lại từ offset bất kỳ.
4. Delivery Semantics
Ba mức đảm bảo, mỗi mức có trade-off riêng:
At-most-once — message có thể bị mất, không bao giờ bị xử lý trùng. Producer gửi xong không chờ ack. Nhanh nhất, nhưng chỉ phù hợp cho dữ liệu không quan trọng (metrics, logs không critical).
At-least-once — message không bao giờ bị mất, nhưng có thể bị xử lý nhiều lần. Producer retry khi không nhận ack. Consumer phải idempotent để xử lý duplicate. Đây là mức phổ biến nhất trong production.
Exactly-once — mỗi message được xử lý đúng một lần. Nghe hoàn hảo, nhưng thực tế là "effectively once". Kafka đạt được bằng cách kết hợp idempotent producer + transactional messaging + consumer offset commit trong cùng một transaction. Chi phí: throughput giảm, latency tăng, complexity tăng đáng kể.
Thực tế production
Đa số hệ thống chọn at-least-once + idempotent consumer thay vì exactly-once. Lý do: đơn giản hơn, nhanh hơn, và kết quả cuối cùng tương đương — miễn consumer xử lý duplicate đúng cách.
5. Dead Letter Queue (DLQ)
Khi consumer không thể xử lý một message sau N lần retry — message bị lỗi format, dữ liệu tham chiếu không tồn tại, logic nghiệp vụ từ chối — message đó trở thành poison message. Nếu không có DLQ, poison message chặn toàn bộ queue (head-of-line blocking).
DLQ là queue riêng nơi poison message bị đẩy sang sau khi vượt quá retry limit. Workflow chuẩn:
Nguyên tắc vận hành DLQ:
- Luôn có alerting trên DLQ — message vào DLQ nghĩa là có lỗi cần xử lý
- Log đầy đủ context: message gốc, lỗi ở retry nào, stack trace
- Xây dựng tooling cho replay — sau khi fix bug, replay message từ DLQ về main queue
- Monitor DLQ size như một SLA metric
6. Backpressure
Khi producer nhanh hơn consumer, message tích tụ trong broker. Consumer lag — khoảng cách giữa offset mới nhất và offset consumer đang đọc — là metric quan trọng nhất để đo backpressure.
Pull model (Kafka): Consumer chủ động kéo message theo tốc độ của mình. Backpressure tự nhiên — consumer chậm thì kéo ít hơn, lag tăng nhưng không ai bị overwhelm. Trade-off: cần polling, có thể gây latency nhỏ khi queue trống.
Push model (RabbitMQ): Broker đẩy message đến consumer. Nhanh hơn khi tải thấp, nhưng cần prefetch count để kiểm soát — giới hạn số message chưa ack mà broker gửi cho mỗi consumer. Prefetch quá cao = consumer bị overwhelm. Prefetch quá thấp = throughput không tối ưu.
Chiến lược xử lý backpressure:
| Chiến lược | Mô tả | Khi nào dùng |
|---|---|---|
| Scale consumers | Thêm consumer instance vào consumer group | Lag tăng đều, throughput per-consumer đã tối ưu |
| Increase partitions | Thêm partition để tăng parallelism | Số consumer > số partition hiện tại |
| Rate limiting producers | Giới hạn tốc độ gửi từ phía producer | Consumer không thể scale thêm |
| Message batching | Gom nhiều message thành batch | Network là bottleneck |
| Drop non-critical | Bỏ message không quan trọng khi lag quá cao | Metrics, logs có thể mất mà không ảnh hưởng nghiệp vụ |
Bảng so sánh tổng quan
| Tiêu chí | RabbitMQ | Kafka | Amazon SQS |
|---|---|---|---|
| Mô hình | Message queue + pub/sub | Distributed commit log | Managed message queue |
| Ordering | Per-queue (FIFO mode) | Per-partition | FIFO queue (limited) |
| Replay | Không (message bị xóa sau ack) | Có (retention-based) | Không |
| Throughput | Hàng chục nghìn msg/s | Hàng triệu msg/s | Hàng nghìn msg/s (standard) |
| Routing | Exchange + binding key rất linh hoạt | Topic + partition key | Đơn giản |
| Use case chính | Task queue, RPC, routing phức tạp | Event streaming, log aggregation, CQRS | Serverless, decouple AWS services |
| Vận hành | Tự quản lý hoặc managed | Tự quản lý hoặc Confluent/MSK | Fully managed |
Thực chiến
Scenario 1: Order Processing Pipeline
Một e-commerce platform cần xử lý đơn hàng qua nhiều service: payment, inventory, notification. Yêu cầu: mỗi service xử lý độc lập, failure ở notification không ảnh hưởng payment, và hệ thống phải xử lý được spike traffic.
Thiết kế partition key: Sử dụng order_id làm partition key. Mọi event liên quan đến cùng đơn hàng đi vào cùng partition → đảm bảo thứ tự xử lý trong phạm vi một đơn hàng.
Idempotent consumer là bắt buộc. Payment Service có thể nhận cùng order.created event hai lần (do retry, rebalancing). Giải pháp: lưu event_id đã xử lý vào database, check trước khi thực hiện thanh toán.
python
# Idempotent consumer pattern — pseudocode
def handle_order_created(event):
if db.exists("processed_events", event.id):
log.info(f"Duplicate event {event.id}, skipping")
return
with db.transaction():
process_payment(event.order_id, event.amount)
db.insert("processed_events", {
"event_id": event.id,
"processed_at": now()
})
consumer.commit_offset()Lưu ý: processed_events table cần TTL hoặc cleanup job — không lưu vĩnh viễn. TTL nên dài hơn Kafka retention period để tránh false negative khi consumer replay.
Scenario 2: Event Sourcing + CQRS
Event Sourcing lưu trạng thái hệ thống dưới dạng chuỗi event thay vì snapshot hiện tại. Thay vì lưu balance = 1000, ta lưu: deposited 500, deposited 700, withdrawn 200. Trạng thái hiện tại là kết quả replay toàn bộ event.
CQRS (Command Query Responsibility Segregation) tách write model (nhận command, sinh event) và read model (materialized view tối ưu cho query). Kafka đóng vai trò trung gian — event store và transport layer.
Khi nào KHÔNG dùng Event Sourcing:
- Nghiệp vụ đơn giản, CRUD thuần túy — overhead không đáng
- Team chưa có kinh nghiệm với eventual consistency — debug rất khó
- Yêu cầu delete dữ liệu vĩnh viễn (GDPR right to be forgotten) — cần crypto shredding hoặc event rewriting, cả hai đều phức tạp
Khi nào NÊN dùng:
- Audit trail là yêu cầu bắt buộc (fintech, healthcare)
- Cần temporal query — "trạng thái tài khoản lúc 14:00 ngày hôm qua là gì?"
- Write và read có tải khác nhau đáng kể — CQRS cho phép scale độc lập
Sai lầm điển hình
❌ Consumer không idempotent
Đây là lỗi phổ biến nhất và nguy hiểm nhất. Trong at-least-once delivery (mặc định của hầu hết hệ thống), consumer PHẢI xử lý duplicate an toàn. Không có idempotency = charge khách hàng hai lần, gửi hai email xác nhận, trừ kho hai lần.
Fix: Lưu event_id hoặc sử dụng idempotency key. Kiểm tra trước khi xử lý. Đây không phải optimization — đây là yêu cầu correctness.
❌ Dùng Kafka như database
Kafka là log, không phải query engine. Không có index, không có query by field, không có update hay delete record. Nếu bạn thấy mình đang scan toàn bộ topic để tìm một message cụ thể — bạn đang dùng sai tool.
Fix: Kafka làm transport + event store. Dữ liệu cần query đẩy vào database phù hợp (PostgreSQL, Elasticsearch, Redis) qua consumer.
❌ Một partition duy nhất cho ordering toàn cục
Ordering guarantee trong Kafka chỉ tồn tại trong phạm vi partition. Đặt mọi thứ vào một partition để có "global ordering" = bottleneck khổng lồ. Throughput bị giới hạn bởi tốc độ xử lý của MỘT consumer.
Fix: Partition theo entity ID (order_id, user_id). Ordering trong phạm vi entity thường là đủ. Nếu thực sự cần global ordering — đánh giá lại liệu yêu cầu đó có hợp lý không.
❌ Không có Dead Letter Queue
Poison message chặn consumer, consumer restart liên tục, message tích tụ, lag tăng phi mã. Đến khi ops team phát hiện, hàng triệu message đã backlog.
Fix: Luôn cấu hình DLQ + max retry count. Alert khi message vào DLQ. Xây tooling để review và replay.
❌ Không monitor consumer lag
Consumer lag tăng = consumer đang xử lý chậm hơn producer. Không monitor = bạn không biết hệ thống đang tụt hậu cho đến khi lag trở thành hàng giờ, và dữ liệu "real-time" thực ra đã trễ 3 tiếng.
Fix: Monitor consumer lag bằng Kafka consumer group metrics (records-lag-max), alert khi lag vượt threshold. Burrow, Kafka Exporter + Prometheus, hoặc Confluent Control Center đều là lựa chọn tốt.
Under the Hood
Kafka Partition Model
Partition là đơn vị parallelism cơ bản của Kafka. Khi producer gửi message với partition key, Kafka hash key đó (murmur2 hash mặc định) rồi mod với số partition để xác định partition đích.
partition = hash(key) % num_partitionsMọi message cùng key luôn đi vào cùng partition → ordering guarantee cho các event liên quan. Message không có key được round-robin qua các partition.
Mỗi partition là một file append-only trên disk. Kafka viết tuần tự (sequential I/O), đây là lý do Kafka nhanh hơn nhiều so với random I/O — sequential write trên HDD đạt hàng trăm MB/s, ngang với RAM random access.
Quy tắc vàng: Số consumer trong một consumer group không nên vượt quá số partition. Consumer thừa sẽ idle. Muốn tăng parallelism → tăng partition (nhưng partition không giảm được, nên planning trước).
Consumer Group Rebalancing
Rebalancing xảy ra khi:
- Consumer mới join group
- Consumer rời group (crash, shutdown)
- Partition được thêm vào topic
- Consumer không gửi heartbeat trong thời gian
session.timeout.ms
Eager Rebalancing (mặc định cũ): Revoke TẤT CẢ partition khỏi TẤT CẢ consumer, rồi reassign lại. Trong thời gian rebalancing, KHÔNG consumer nào xử lý message — stop-the-world pause.
Cooperative Rebalancing (KIP-429): Chỉ revoke partition bị ảnh hưởng. Consumer giữ partition không thay đổi tiếp tục xử lý bình thường. Rebalancing diễn ra dần dần qua nhiều round.
Sticky Assignor cố gắng giữ partition assignment ổn định nhất có thể giữa các lần rebalancing — giảm số partition bị di chuyển, giảm thời gian "catch up" sau rebalancing.
Rebalancing storm
Nếu consumer xử lý message quá chậm (lâu hơn max.poll.interval.ms), broker coi consumer đã chết → trigger rebalancing → consumer bị revoke partition → xử lý dở dang → rejoin → lại bị timeout → rebalancing storm. Fix: tăng max.poll.interval.ms hoặc giảm max.poll.records.
At-least-once vs Exactly-once — Chi tiết
Idempotent Producer (Kafka >= 0.11): Producer gán sequence number cho mỗi message. Broker detect duplicate bằng <producer_id, sequence> tuple. Kết quả: mỗi message được ghi vào log đúng một lần, ngay cả khi producer retry. Enable bằng enable.idempotence=true.
Transactional Messaging: Producer bọc nhiều write (sang nhiều topic/partition) trong một transaction. Hoặc tất cả thành công, hoặc tất cả rollback. Consumer có thể chọn isolation.level=read_committed để chỉ đọc message đã committed.
Consumer Offset Management: Exactly-once end-to-end yêu cầu consumer commit offset VÀ persist kết quả xử lý trong CÙNG MỘT transaction. Nếu dùng Kafka Streams, framework xử lý việc này. Nếu tự implement, cần lưu offset cùng kết quả trong database (không commit offset về Kafka).
Tại sao gọi là "effectively once"? Vì side effect bên ngoài Kafka (gọi API, gửi email, charge credit card) không nằm trong transaction boundary. Kafka đảm bảo message được xử lý đúng một lần trong phạm vi Kafka — nhưng nếu consumer gọi external API rồi crash trước khi commit, API đã nhận request nhưng Kafka sẽ deliver lại message.
Backpressure Mechanics
Pull model (Kafka): Consumer gọi poll() chủ động. Tham số max.poll.records giới hạn số message mỗi lần poll. Consumer xử lý xong batch hiện tại mới poll tiếp. Tốc độ xử lý do consumer quyết định.
Push model (RabbitMQ): Broker đẩy message đến consumer qua channel. prefetch_count (QoS) giới hạn số unacknowledged message. Khi đạt limit, broker dừng push cho consumer đó cho đến khi nhận ack.
Consumer lag metric — khoảng cách giữa log-end-offset (message mới nhất) và consumer-offset (message consumer đã đọc). Lag = 0 nghĩa là consumer real-time. Lag tăng liên tục = cần scale consumer hoặc optimize processing.
Checklist ghi nhớ
✅ Checklist triển khai
Message Design
- [ ] Message có schema rõ ràng (Avro, Protobuf, hoặc JSON Schema)
- [ ] Mỗi message có unique ID để hỗ trợ idempotency
- [ ] Partition key được chọn dựa trên entity cần ordering
- [ ] Message size hợp lý (Kafka mặc định giới hạn 1MB)
- [ ] Schema evolution strategy: backward/forward compatible
Consumer Patterns
- [ ] Consumer idempotent — xử lý duplicate an toàn
- [ ] Consumer commit offset SAU khi xử lý thành công
- [ ]
max.poll.interval.msphù hợp với thời gian xử lý thực tế - [ ] Consumer group ID có naming convention rõ ràng
Operations & Monitoring
- [ ] Consumer lag được monitor và alert
- [ ] DLQ được cấu hình cho mọi consumer
- [ ] Retention policy phù hợp với yêu cầu replay
- [ ] Partition count được planning dựa trên throughput dự kiến
- [ ] Replication factor >= 3 cho production
Error Handling
- [ ] Retry policy với exponential backoff
- [ ] Max retry count trước khi chuyển vào DLQ
- [ ] DLQ có alerting và tooling để review/replay
- [ ] Circuit breaker cho external dependency calls trong consumer
- [ ] Graceful shutdown: commit offset trước khi tắt consumer
Bài tập luyện tập
🧠 Quiz — Kafka hay RabbitMQ?
Cho các scenario sau, chọn messaging system phù hợp nhất:
Scenario A: Hệ thống cần gửi email xác nhận sau khi user đăng ký. Mỗi email chỉ gửi một lần, không cần replay, không cần ordering phức tạp.
Scenario B: Platform e-commerce cần stream real-time order events đến 5 downstream services (payment, inventory, analytics, recommendation, notification). Cần khả năng replay khi thêm service mới.
Scenario C: Microservice A cần gọi Service B theo pattern request-reply với timeout 5 giây.
- [ ] A: Kafka, B: RabbitMQ, C: Kafka
- [x] A: RabbitMQ, B: Kafka, C: RabbitMQ
- [ ] A: SQS, B: SQS, C: Kafka
- [ ] A: Kafka, B: Kafka, C: RabbitMQ
Giải thích: A → RabbitMQ/SQS (task queue đơn giản, competing consumers). B → Kafka (nhiều consumer group, cần replay, high throughput). C → RabbitMQ (hỗ trợ RPC pattern với reply-to queue + correlation ID).
Bài 2: DLQ Strategy cho Payment Service — Intermediate
Yêu cầu: Thiết kế retry + DLQ strategy cho Payment Service xử lý order.created events. Payment có thể fail vì: (1) gateway timeout, (2) insufficient funds, (3) invalid card, (4) internal error.
Câu hỏi thiết kế:
- Loại lỗi nào nên retry? Loại nào nên chuyển thẳng vào DLQ?
- Retry policy như thế nào? (interval, max attempts, backoff strategy)
- DLQ message cần chứa thông tin gì để ops team debug?
- Khi nào replay message từ DLQ? Workflow replay ra sao?
💡 Gợi ý
- Phân loại lỗi: transient (retry-able) vs permanent (DLQ ngay)
- Gateway timeout → transient. Insufficient funds → permanent (cần user action)
✅ Lời giải
- Retry: Gateway timeout (transient), internal error (retry 3 lần rồi DLQ)
- DLQ ngay: Insufficient funds (business logic, cần user action), invalid card (bad data)
- Retry policy: Exponential backoff: 1s → 5s → 25s, max 3 attempts
- DLQ metadata: original message, error type, stack trace, attempt count, timestamp, correlation ID
- Replay workflow: Ops fix root cause → replay từ DLQ → monitor success rate
Bài 3: Tính toán Partition Count — Advanced
Context: Một event streaming platform nhận 100K events/giây. Mỗi consumer xử lý được 2K events/giây (bao gồm database write).
Câu hỏi:
- Cần tối thiểu bao nhiêu partition để xử lý hết throughput?
- Nếu mỗi consumer cần 2GB RAM, cần tối thiểu bao nhiêu RAM cho consumer group?
- Bạn có nên đặt đúng 50 partition không? Tại sao nên hoặc không nên dư?
- Nếu thêm consumer group thứ hai (analytics), throughput requirement thay đổi thế nào?
💡 Gợi ý
min_partitions = total_throughput / consumer_throughput- Nên thêm 20-30% headroom cho peak traffic
✅ Lời giải
100K / 2K = 50 partitionstối thiểu50 consumers × 2GB = 100GB RAMtối thiểu- Không nên đặt đúng 50 — thêm 20-30% headroom → 60-65 partitions cho peak traffic và rebalancing
- Consumer group thứ hai đọc độc lập — Kafka replicate data, không chia sẻ throughput. Tổng partition count không đổi, chỉ cần thêm consumer instances cho group mới