Kafka & EDA: Từ lý thuyết đến thực chiến
Kafka không chỉ là message queue. Bài viết phân tích sâu Event-Driven Architecture — từ core concepts, partitioning, consumer groups đến các anti-pattern thường gặp trong thực tế.
Kafka & Event-Driven Architecture: Từ lý thuyết đến thực chiến
Bạn đã bao giờ nghe câu "chúng ta dùng Kafka" trong một buổi họp kiến trúc, nhưng khi hỏi sâu hơn thì team chỉ đang dùng nó như một message queue bình thường không? Đó là dấu hiệu của một anti-pattern phổ biến — adopt công nghệ mà không hiểu rõ mental model đằng sau nó.
Bài này không phải tutorial cài đặt Kafka. Đây là phân tích kiến trúc: tại sao EDA tồn tại, Kafka giải quyết vấn đề gì, và những gì có thể sai khi bạn áp dụng nó vào hệ thống thực tế.
Event-Driven Architecture là gì — và tại sao nó không đơn giản như bạn nghĩ
Event-Driven Architecture (EDA) là một mô hình thiết kế hệ thống trong đó các thành phần giao tiếp với nhau thông qua events — không phải qua direct call.
Nghe có vẻ đơn giản. Nhưng sự khác biệt giữa EDA và "gửi message qua queue" nằm ở semantic:
- Command: "Hãy làm X" — người gửi quan tâm kết quả
- Event: "X đã xảy ra" — người gửi không quan tâm ai lắng nghe
Đây là phân biệt quan trọng nhất. Khi bạn publish một event OrderPlaced, service phát ra event đó không biết — và không cần biết — rằng có 3 consumer đang lắng nghe: Inventory, Notification, và Analytics. Đây là temporal decoupling và behavioral decoupling cùng lúc.
So sánh với request/response truyền thống:
# Request/Response (tight coupling)
OrderService → gọi trực tiếp → InventoryService
OrderService → gọi trực tiếp → NotificationService
OrderService → gọi trực tiếp → AnalyticsService
# Event-Driven (loose coupling)
OrderService → publish → [OrderPlaced event]
↓
InventoryService (subscribe)
NotificationService (subscribe)
AnalyticsService (subscribe)
Trong mô hình thứ hai, OrderService không phụ thuộc compile-time hay runtime vào bất kỳ service nào. Thêm một consumer mới không yêu cầu thay đổi producer. Đây là Open/Closed Principle ở cấp độ kiến trúc.
Tại sao Kafka — không phải RabbitMQ hay một message broker thông thường?
RabbitMQ, ActiveMQ, và các message broker truyền thống hoạt động theo mô hình queue: message được consume rồi xóa. Consumer xử lý xong thì message biến mất.
Kafka khác về bản chất. Kafka là một distributed commit log — message (hay đúng hơn là "record") được lưu trữ theo thứ tự, có thể replay, và không bị xóa sau khi consume.
Điều này tạo ra một số properties cực kỳ mạnh:
1. Message Retention và Replayability
Kafka lưu records trong một khoảng thời gian cấu hình được (mặc định 7 ngày, nhưng có thể là vĩnh viễn với log compaction). Nghĩa là:
- Consumer mới có thể đọc lại toàn bộ history từ đầu
- Debug và audit: replay events để tái hiện trạng thái hệ thống tại một thời điểm cụ thể
- Multiple independent consumers: mỗi consumer group đọc log độc lập, không ảnh hưởng nhau
Đây là tính năng không có trong RabbitMQ. Nếu bạn cần replay — chọn Kafka.
2. Partitioning và Horizontal Scalability
Mỗi Kafka topic được chia thành nhiều partitions. Record được phân phối vào partitions dựa trên partition key (thường là một field trong message).
Topic: order-events (4 partitions)
Partition 0: [order-001, order-005, order-009, ...]
Partition 1: [order-002, order-006, order-010, ...]
Partition 2: [order-003, order-007, order-011, ...]
Partition 3: [order-004, order-008, order-012, ...]
Ordering guarantee trong Kafka chỉ được đảm bảo trong một partition. Đây là điểm cực kỳ quan trọng và hay bị hiểu nhầm.
Nếu bạn cần tất cả events của một orderId được xử lý đúng thứ tự, bạn phải dùng orderId làm partition key. Điều này đảm bảo tất cả events của cùng một order luôn vào cùng một partition — và được consume theo thứ tự.
3. Consumer Groups
Consumer groups là cơ chế scale consumers. Mỗi partition chỉ được consume bởi một consumer trong một group tại một thời điểm.
Topic: order-events (4 partitions)
Consumer Group: inventory-service (2 instances)
Instance A → Partition 0, Partition 1
Instance B → Partition 2, Partition 3
Khi bạn scale consumer group lên 4 instances, mỗi instance đảm nhận 1 partition. Đây là throughput tối đa cho 4 partitions — thêm instance thứ 5 sẽ idle.
Quy tắc vàng: số partitions quyết định mức parallelism tối đa của một consumer group. Thiết kế partitions cẩn thận từ đầu — tăng partition về sau gây re-balancing và mất ordering guarantee tạm thời.
Event Schema Design — phần dễ bị bỏ qua nhất
Khi thiết kế events, nhiều team tập trung vào infrastructure mà quên mất schema design. Đây là sai lầm tốn kém.
Thin Events vs Fat Events
Thin event chỉ chứa identifier:
{
"eventType": "OrderPlaced",
"orderId": "ord-12345",
"timestamp": "2026-05-25T10:00:00Z"
}
Fat event chứa full payload:
{
"eventType": "OrderPlaced",
"orderId": "ord-12345",
"timestamp": "2026-05-25T10:00:00Z",
"customerId": "cust-789",
"items": [...],
"totalAmount": 500000,
"shippingAddress": {...}
}
Thin events yêu cầu consumer phải query lại source service để lấy data — tạo coupling ngược. Fat events self-contained nhưng tăng message size và có thể chứa data stale nếu consumer xử lý muộn.
Lựa chọn thực tế: dùng fat events cho các field quan trọng tại thời điểm event xảy ra (snapshot state), giữ thin cho data có thể thay đổi và consumer cần real-time. Đây là event sourcing mindset — event là source of truth về điều đã xảy ra, không phải về trạng thái hiện tại.
Schema Evolution và Backward Compatibility
Kafka records là immutable. Bạn không thể sửa records đã publish. Điều này nghĩa là schema phải được quản lý cẩn thận qua thời gian.
Nguyên tắc:
- Thêm field mới: luôn optional, có default value — backward compatible
- Xóa field: break consumers đang depend vào field đó — cần deprecation period
- Đổi type: cực kỳ nguy hiểm — phải có migration strategy rõ ràng
Schema Registry (Confluent hoặc AWS Glue Schema Registry) là mandatory trong production. Nó enforce compatibility rules và lưu trữ schema evolution history.
Idempotency — vấn đề bạn sẽ gặp dù không muốn
Trong distributed systems, message có thể được deliver ít nhất một lần (at-least-once). Consumer crash sau khi xử lý nhưng trước khi commit offset sẽ re-process message khi restart.
Nếu consumer của bạn không idempotent — bạn có bug.
Pattern phổ biến: idempotency key
// Consumer xử lý OrderPlaced
async function handleOrderPlaced(event: OrderPlacedEvent) {
const idempotencyKey = `order-placed-${event.orderId}-${event.version}`;
// Check xem đã xử lý chưa
const alreadyProcessed = await redis.get(idempotencyKey);
if (alreadyProcessed) {
logger.info(`Skipping duplicate event: ${idempotencyKey}`);
return;
}
// Xử lý business logic
await inventoryService.reserveItems(event.orderId, event.items);
// Mark as processed (với TTL phù hợp)
await redis.setex(idempotencyKey, 86400, '1');
}
Hoặc dùng database upsert với unique constraint trên (eventId, consumerGroup) — không cần Redis.
Exactly-once semantics: Kafka hỗ trợ exactly-once trong một số scenarios (Kafka Streams, transactional producers), nhưng cross-system exactly-once là impossible theo distributed systems theory. Design for idempotency thay vì rely vào exactly-once delivery.
Dead Letter Queue và Error Handling Strategy
Consumer fail khi xử lý message là chuyện bình thường. Câu hỏi là: hệ thống của bạn xử lý failure như thế nào?
Naive approach: retry vô hạn → block toàn bộ partition nếu message poison (không bao giờ xử lý được được)
Production pattern: Dead Letter Topic (DLT)
Normal flow:
order-events → [Consumer] → success → commit offset
Failure flow:
order-events → [Consumer] → fail → retry (exponential backoff)
→ max retries exceeded → publish to order-events-dlt
→ commit offset (tiếp tục)
DLT cho phép:
- Partition không bị block bởi poison messages
- Engineers có thể inspect và re-process failed messages sau khi fix bug
- Alerting khi DLT có messages tăng bất thường
// Spring Kafka example (tương tự pattern áp dụng ở Node.js)
@KafkaListener(topics = "order-events")
@RetryableTopic(
attempts = "4",
backoff = @Backoff(delay = 1000, multiplier = 2),
dltTopicSuffix = "-dlt"
)
public void handleOrderEvent(OrderEvent event) {
// business logic
}
Kafka trong thực tế tại MSB-style Banking Systems
Banking systems có constraints đặc biệt: audit trail và regulatory compliance là bắt buộc.
EDA với Kafka phù hợp tốt vì:
- Immutable log: mọi state change đều có record — đáp ứng audit requirements
- Replay capability: reconstruct trạng thái tài khoản tại bất kỳ thời điểm nào
- Decoupled reporting: Analytics và Reporting consumers đọc events độc lập với core banking flow
Nhưng có những điều cần chú ý:
Transactional outbox pattern: Khi OrderService cần đồng thời update database VÀ publish event, không được dùng hai operation riêng lẻ — đây là distributed transaction problem. Thay vào đó:
-- Trong cùng một database transaction
BEGIN;
UPDATE orders SET status = 'placed' WHERE id = 'ord-12345';
INSERT INTO outbox (event_type, payload, created_at)
VALUES ('OrderPlaced', '{"orderId": "ord-12345", ...}', NOW());
COMMIT;
-- Một background worker đọc outbox và publish lên Kafka
-- Sau khi publish thành công mới xóa/mark record trong outbox
Pattern này đảm bảo database và event log luôn consistent — không có trường hợp database updated nhưng event không được publish (hoặc ngược lại).
Những Anti-Pattern Phổ Biến
Sau nhiều năm làm việc với distributed systems, đây là những anti-pattern tôi thấy lặp lại nhiều nhất:
1. Dùng Kafka như message queue đơn thuần
Nếu bạn chỉ cần point-to-point messaging, RabbitMQ đơn giản hơn và phù hợp hơn. Kafka phức tạp hơn đáng kể — infrastructure, schema management, consumer group coordination. Chỉ adopt khi bạn cần những gì Kafka unique: log retention, replay, high throughput.
2. Topic proliferation
Một topic cho mỗi loại event nhỏ → hàng trăm topics khó manage. Thay vào đó, group related events vào một topic và dùng eventType field để phân loại trong consumer. Một số team dùng pattern {domain}.{aggregate}.events — ví dụ banking.accounts.events chứa tất cả account-related events.
3. Consumer làm quá nhiều việc
Consumer đọc event → gọi 5 external APIs → update 3 databases → publish event mới → ... Đây là anti-pattern. Consumer nên làm một việc và làm tốt. Phân tách thành nhiều consumers với responsibilities rõ ràng, kết nối qua events.
4. Ignore backpressure
Consumer xử lý chậm hơn producer → lag tăng → hệ thống dần sụp đổ. Monitor consumer lag là mandatory, không optional. Alert khi lag vượt threshold, scale consumers kịp thời.
Khi nào KHÔNG dùng EDA và Kafka
EDA không phải silver bullet. Có những scenarios mà request/response đơn giản hơn và đúng hơn:
- Cần response ngay lập tức: user action cần kết quả real-time → REST/gRPC phù hợp hơn
- Hệ thống nhỏ, team nhỏ: operational overhead của Kafka không worth it cho startup 3 người
- Strong consistency requirements: nếu business rule yêu cầu "tất cả hoặc không có gì" trong một transaction cross-service → đây là distributed transaction problem, EDA không giải quyết được
EDA shine khi: high throughput, loose coupling quan trọng hơn strong consistency, nhiều consumers độc lập, audit/replay là requirement.
Kết luận
Kafka và Event-Driven Architecture không phải về công nghệ — chúng là về cách bạn nghĩ về communication giữa các thành phần hệ thống.
Những điểm cốt lõi cần nhớ:
- Event semantic khác command: "X đã xảy ra" vs "hãy làm X" — phân biệt này quyết định toàn bộ design
- Partitioning quyết định ordering và scalability: chọn partition key cẩn thận từ đầu
- Design for idempotency: at-least-once delivery là reality, không phải bug
- Outbox pattern là bắt buộc khi cần consistency giữa database và event stream
- Schema evolution cần được quản lý chủ động — dùng Schema Registry trong production
- EDA không phải luôn luôn đúng: biết khi nào nên dùng request/response đơn giản hơn
Complexity trong distributed systems không nằm ở infrastructure. Nó nằm ở việc reasoning đúng về ordering, consistency, và failure modes. Hiểu rõ những trade-off này trước khi commit vào một kiến trúc — đó là sự khác biệt giữa một hệ thống vận hành tốt và một hệ thống liên tục gây ra incidents lúc 2 giờ sáng.