Event-Driven Architecture: Message, Event & Giao Tiếp Bất Đồng Bộ

Trong kiến trúc request/response truyền thống, các component gọi nhau trực tiếp và chờ phản hồi. Service A gọi Service B, rồi gọi Service C, và toàn bộ chuỗi phải available ngay lúc đó. Điều này tạo ra tight coupling, cascading failure, và hệ thống sập dưới tải.
Event-driven architecture đảo ngược mô hình này. Thay vì bảo component khác phải làm gì, một component thông báo chuyện gì đã xảy ra. Các component khác phản ứng với thông báo đó một cách độc lập, theo tốc độ riêng, mà producer không cần biết chúng tồn tại.
Sự tách biệt này là thứ làm cho event-driven system có khả năng phục hồi, scalable, và mở rộng được — nhưng nó cũng tạo ra thách thức về eventual consistency, xử lý lỗi, và debug đòi hỏi thiết kế cẩn thận.
Trong bài viết này, chúng ta sẽ đề cập:
✅ Event vs command vs query — các building block cơ bản
✅ Event notification vs event-carried state transfer
✅ Message broker: Kafka, RabbitMQ, Redis Streams
✅ Pub/sub vs point-to-point messaging
✅ Event choreography vs orchestration
✅ Eventual consistency và compensation
✅ Idempotency và exactly-once processing
✅ Event schema evolution và versioning
✅ Dead letter queue và xử lý lỗi
✅ Event storming như kỹ thuật thiết kế
✅ Triển khai thực tế với Kafka và Spring Boot
Event vs Command vs Query
Trước khi đi sâu vào kiến trúc, hãy làm rõ ba loại message cơ bản. Phân biệt đúng là cực kỳ quan trọng — trộn lẫn chúng dẫn đến hệ thống rối và khó hiểu.
Event: "Chuyện Gì Đó Đã Xảy Ra"
Một event là thông báo rằng điều gì đó đã xảy ra trong quá khứ. Nó bất biến, thực tế, và được đặt tên ở thì quá khứ.
OrderPlaced ← Đơn hàng đã được đặt
PaymentProcessed ← Thanh toán đã được xử lý
UserRegistered ← Người dùng đã đăng ký
InventoryDepleted ← Tồn kho đã hếtThuộc tính chính của event:
- Bất biến — bạn không thể thay đổi quá khứ; event là sự thật
- Thì quá khứ — đặt tên theo chuyện đã xảy ra
- Producer không quan tâm ai đang lắng nghe — không, một, hay năm mươi consumer
- Không mong đợi response — fire and forget
Command: "Làm Cái Này"
Một command là yêu cầu ai đó làm điều gì. Nó hướng đến receiver cụ thể, đặt tên ở dạng mệnh lệnh, và mong đợi kết quả.
PlaceOrder ← "Ê Order Service, đặt đơn hàng này"
ProcessPayment ← "Ê Payment Service, thu tiền thẻ này"
SendEmail ← "Ê Email Service, gửi email này"
ReserveInventory ← "Ê Inventory Service, giữ hàng này"Thuộc tính chính của command:
- Có hướng — gửi đến handler cụ thể
- Mệnh lệnh — đặt tên như chỉ thị
- Có thể bị từ chối — handler có thể không chấp nhận
- Đúng một handler — không broadcast đến nhiều consumer
Query: "Cho Biết"
Một query là yêu cầu thông tin. Nó không thay đổi state — hoàn toàn là read.
GetOrderDetails ← "Chi tiết đơn hàng 123 là gì?"
ListUserOrders ← "User này có những đơn hàng nào?"
CheckInventory ← "Còn bao nhiêu widget trong kho?"Tại Sao Điều Này Quan Trọng
Sự nhầm lẫn xảy ra khi developer dùng event như command hoặc ngược lại:
Khi bạn publish PleaseProcessPayment, bạn đã tạo command ngụy trang thành event. Order Service vẫn đang bảo Payment Service phải làm gì — chỉ là gián tiếp. Khi bạn publish OrderPlaced, Order Service đơn giản nêu một sự thật. Payment Service tự quyết định phản ứng bằng cách xử lý thanh toán.
Sự phân biệt này thúc đẩy loose coupling: producer không biết và không quan tâm chuyện gì xảy ra tiếp theo.
Event Pattern: Notification vs State Transfer
Không phải tất cả event đều mang cùng lượng thông tin. Có hai cách tiếp cận khác nhau cơ bản về nội dung bên trong event.
Event Notification
Event notification là mỏng — nó cho bạn biết chuyện gì đã xảy ra và cung cấp vừa đủ thông tin để xác định chủ thể. Nếu bạn cần chi tiết, bạn gọi lại.
{
"type": "OrderPlaced",
"orderId": "order-789",
"timestamp": "2026-03-02T10:30:00Z"
}Consumer nhận event này và nghĩ: "Một đơn hàng đã được đặt. Để tôi gọi API Order Service để lấy chi tiết cần thiết."
Ưu điểm:
- Event nhỏ và nhanh khi publish
- Source of truth nằm ở service gốc
- Không có rủi ro data cũ trong event
Nhược điểm:
- Tạo runtime coupling — consumer phải gọi lại producer
- Producer phải available khi consumer xử lý event
- Nhiều network call hơn
Event-Carried State Transfer
Event có state transfer là béo — nó mang tất cả data mà consumer cần, để họ không phải gọi lại.
{
"type": "OrderPlaced",
"orderId": "order-789",
"userId": "user-123",
"items": [
{"productId": "prod-1", "name": "Widget", "quantity": 2, "price": 29.99},
{"productId": "prod-2", "name": "Gadget", "quantity": 1, "price": 49.99}
],
"totalAmount": 109.97,
"shippingAddress": {
"street": "123 Main St",
"city": "Springfield",
"zipCode": "62704"
},
"timestamp": "2026-03-02T10:30:00Z"
}Ưu điểm:
- Không runtime coupling — consumer hoàn toàn tự chủ
- Producer có thể offline khi consumer xử lý event
- Consumer có thể build local read model (CQRS pattern)
- Ít network call hơn
Nhược điểm:
- Event lớn hơn tiêu tốn nhiều bandwidth và storage hơn
- Data có thể cũ nếu event được publish cách đây nhiều giờ
- Schema evolution phức tạp hơn với payload lớn
Chọn Cái Nào?
| Kịch bản | Notification | State Transfer |
|---|---|---|
| Consumer cần 1-2 field | ✅ | Over-engineered |
| Consumer cần full entity | Thêm call | ✅ |
| Producer uptime đảm bảo | ✅ | ✅ |
| Producer có thể offline | Rủi ro | ✅ |
| Build local read model | ❌ | ✅ |
| Hạn chế bandwidth | ✅ | Nặng |
Nguyên tắc thực tế: Bắt đầu với event-carried state transfer cho command xuyên service boundary. Dùng thin notification cho internal domain event trong cùng service.
Message Broker
Event cần infrastructure để di chuyển từ producer đến consumer. Message broker là middleware lưu trữ, định tuyến, và giao message giữa các service.
Apache Kafka
Kafka là distributed event streaming platform được thiết kế cho xử lý event throughput cao, fault-tolerant, và bền vững.
Khái niệm chính:
- Topic — danh mục event được đặt tên (như
order-events,user-events) - Partition — topic được chia thành partition cho parallelism; event cùng key đi vào cùng partition (đảm bảo thứ tự)
- Consumer group — mỗi group nhận mỗi message đúng một lần; nhiều group xử lý cùng topic độc lập
- Retention — event được lưu trữ trong khoảng thời gian có thể cấu hình (ngày, tuần, hoặc mãi mãi với compaction)
- Offset — mỗi consumer theo dõi vị trí của mình; có thể replay từ bất kỳ điểm nào
Kafka phù hợp nhất cho:
- Event stream throughput cao (hàng triệu event/giây)
- Event sourcing và replay
- Build pipeline data real-time
- Trường hợp thứ tự event quan trọng
// Kafka producer — Spring Boot
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public void publishOrderPlaced(Order order) {
OrderPlacedEvent event = new OrderPlacedEvent(
UUID.randomUUID().toString(), // eventId
order.getId(),
order.getUserId(),
order.getItems(),
order.getTotalAmount(),
Instant.now()
);
// Key = orderId → tất cả event cho cùng order vào cùng partition
kafkaTemplate.send("order-events", order.getId(), event);
}
}
// Kafka consumer — Spring Boot
@Component
public class PaymentEventConsumer {
@KafkaListener(topics = "order-events", groupId = "payment-service")
public void handleOrderPlaced(OrderPlacedEvent event) {
log.info("Xử lý thanh toán cho order: {}", event.getOrderId());
paymentService.processPayment(
event.getUserId(),
event.getTotalAmount(),
event.getOrderId()
);
}
}RabbitMQ
RabbitMQ là message broker truyền thống xây trên giao thức AMQP. Nó tập trung vào message routing, acknowledgment, và delivery guarantee.
Khái niệm chính:
- Exchange — nhận message và route chúng đến queue dựa trên rule
- Queue — lưu trữ message cho đến khi được consume
- Binding — rule kết nối exchange với queue (routing key, pattern)
- Acknowledgment — consumer xác nhận đã xử lý; message chưa acknowledge sẽ được gửi lại
- Exchange type — direct (routing key chính xác), fanout (broadcast), topic (pattern matching), headers
RabbitMQ phù hợp nhất cho:
- Task queue với yêu cầu routing phức tạp
- Pattern request/reply
- Priority queue
- Khi bạn cần message-level acknowledgment
- Throughput thấp hơn nhưng routing phong phú hơn
Kafka vs RabbitMQ
| Tính năng | Kafka | RabbitMQ |
|---|---|---|
| Mô hình | Distributed log | Message queue |
| Throughput | Rất cao (hàng triệu/giây) | Cao (hàng chục nghìn/giây) |
| Lưu trữ message | Cấu hình được (ngày/tuần/mãi mãi) | Cho đến khi consume |
| Replay | Có (đọc lại từ bất kỳ offset) | Không (consume = mất) |
| Thứ tự | Per partition | Per queue |
| Routing | Topic + partition key | Exchange + routing key + binding |
| Consumer model | Pull (consumer poll) | Push (broker giao) |
| Phù hợp cho | Event streaming, event sourcing | Task queue, routing phức tạp |
Redis Streams
Redis Streams là giải pháp nhẹ được tích hợp sẵn trong Redis. Lý tưởng cho pattern event-driven đơn giản hơn mà không cần operational overhead của Kafka hay RabbitMQ.
# Producer — thêm event vào stream
XADD order-events * type OrderPlaced orderId order-789 amount 109.97
# Consumer — đọc từ stream (consumer group)
XREADGROUP GROUP payment-service consumer-1 COUNT 10 BLOCK 5000 STREAMS order-events >Redis Streams phù hợp nhất cho:
- Pattern event đơn giản với throughput thấp đến trung bình
- Khi bạn đã dùng Redis cho caching
- Prototype hệ thống event-driven trước khi commit vào Kafka
Pub/Sub vs Point-to-Point
Hai pattern messaging cơ bản quyết định cách message đi từ producer đến consumer.
Point-to-Point (Queue)
Mỗi message được giao đến đúng một consumer. Nhiều consumer cạnh tranh lấy message từ cùng queue (competing consumer pattern).
Use case:
- Phân phối task — chia việc cho các worker
- Gửi email — mỗi email được gửi một lần bởi một worker
- Xử lý ảnh — mỗi ảnh được xử lý bởi một worker
- Bất kỳ job nào mà xử lý trùng lặp là lãng phí
Publish/Subscribe (Pub/Sub)
Mỗi message được giao đến tất cả subscriber. Mỗi consumer group nhận bản sao của mọi message.
Use case:
- Event broadcast — nhiều hệ thống cần cùng event
- OrderPlaced → payment, inventory, notification, analytics đều phản ứng
- Real-time update — nhiều dashboard cần cùng data
- Audit logging — mọi event được ghi lại cho compliance
Mô Hình Kết Hợp Của Kafka
Kafka kết hợp cả hai pattern một cách thanh lịch bằng consumer group:
- Giữa các group → pub/sub — cả Payment và Notification group đều nhận mọi event
- Trong một group → point-to-point — mỗi partition được consume bởi đúng một thành viên
Nghĩa là bạn có pub/sub (nhiều service phản ứng) với load balancing sẵn có (nhiều instance của mỗi service chia sẻ công việc).
Choreography vs Orchestration
Khi một business process trải rộng nhiều service, bạn cần phối hợp các bước. Có hai cách: choreography (phi tập trung, event-driven) và orchestration (tập trung, command-driven).
Choreography: Nhảy Mà Không Cần Đạo Diễn
Trong choreography, mỗi service lắng nghe event và tự quyết định làm gì tiếp theo. Không có coordinator trung tâm — service phản ứng với nhau như vũ công theo nhạc.
// Choreography — mỗi service phản ứng độc lập
// Order Service publish event
@Service
public class OrderService {
public Order createOrder(CreateOrderCommand cmd) {
Order order = orderRepository.save(Order.create(cmd));
eventPublisher.publish(new OrderPlacedEvent(order));
return order;
}
@EventListener
public void onPaymentProcessed(PaymentProcessedEvent event) {
Order order = orderRepository.findById(event.getOrderId());
order.confirm();
orderRepository.save(order);
eventPublisher.publish(new OrderConfirmedEvent(order));
}
}
// Inventory Service phản ứng với OrderPlaced
@Component
public class InventoryEventHandler {
@KafkaListener(topics = "order-events")
public void onOrderPlaced(OrderPlacedEvent event) {
inventoryService.reserve(event.getItems());
eventPublisher.publish(new InventoryReservedEvent(event.getOrderId()));
}
}
// Payment Service phản ứng với InventoryReserved
@Component
public class PaymentEventHandler {
@KafkaListener(topics = "inventory-events")
public void onInventoryReserved(InventoryReservedEvent event) {
paymentService.charge(event.getOrderId());
eventPublisher.publish(new PaymentProcessedEvent(event.getOrderId()));
}
}Ưu điểm:
- Loose coupling — service không biết về nhau
- Dễ thêm consumer mới mà không thay đổi service hiện tại
- Không có single point of failure (không có coordinator trung tâm)
Nhược điểm:
- Khó thấy toàn bộ flow — logic phân tán khắp các service
- Khó theo dõi process đang kẹt ở đâu
- Chuỗi event vòng tròn có thể tạo vòng lặp vô tận
- Compensating transaction phức tạp
Orchestration: Theo Nhạc Trưởng
Trong orchestration, một orchestrator trung tâm (saga coordinator) gọi từng service theo trình tự, xử lý flow logic tại một nơi.
// Orchestration — saga định nghĩa toàn bộ flow
@Component
public class CreateOrderSaga {
public void execute(CreateOrderCommand command) {
try {
// Bước 1: Tạo order
Order order = orderService.createOrder(command);
// Bước 2: Giữ chỗ tồn kho
inventoryService.reserve(order.getItems());
// Bước 3: Xử lý thanh toán
Payment payment = paymentService.charge(
order.getUserId(), order.getTotalAmount()
);
// Bước 4: Xác nhận order
orderService.confirm(order.getId(), payment.getId());
// Bước 5: Gửi thông báo
notificationService.sendOrderConfirmation(order);
} catch (InventoryException e) {
// Bù đắp: hủy order
orderService.cancel(command.getOrderId());
throw e;
} catch (PaymentException e) {
// Bù đắp: giải phóng inventory, hủy order
inventoryService.release(command.getItems());
orderService.cancel(command.getOrderId());
throw e;
}
}
}Ưu điểm:
- Visibility đầy đủ — toàn bộ flow ở một chỗ
- Dễ thêm xử lý lỗi và compensation
- Dễ theo dõi trạng thái hiện tại của process
- Test đơn giản
Nhược điểm:
- Coordinator trung tâm là potential single point of failure
- Coupling chặt hơn — orchestrator biết về tất cả service
- Có thể trở thành "God object" khi flow phình to
Khi Nào Dùng Cái Nào
| Yếu tố | Choreography | Orchestration |
|---|---|---|
| Số bước | 2-3 bước | 4+ bước |
| Complexity xử lý lỗi | Đơn giản | Phức tạp |
| Cần visibility process | Thấp | Cao |
| Team autonomy | Service phát triển độc lập | Team orchestrator phối hợp |
| Thêm bước mới | Dễ (thêm consumer mới) | Cần thay đổi orchestrator |
| Debug | Khó (trace event xuyên service) | Dễ (check state orchestrator) |
Nguyên tắc thực tế: Dùng choreography cho flow event đơn giản, loose-coupled (notification, analytics). Dùng orchestration cho process đa bước business-critical nơi visibility và xử lý lỗi quan trọng (order fulfillment, payment workflow).
Eventual Consistency và Compensation
Trong event-driven system, data là eventually consistent — các service khác nhau có thể có view khác nhau về sự thật tại bất kỳ thời điểm nào. Đây là trade-off cơ bản bạn phải chấp nhận, không phải chống lại.
Tại Sao Không Strong Consistency?
Trong monolith với single database, bạn có ACID transaction:
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- Cả hai update thành công hoặc cả hai fail. Luôn nhất quán.Trong distributed system với database riêng per service, bạn không thể làm điều này xuyên service boundary. Bạn có hai lựa chọn:
- Distributed transaction (2PC) — phối hợp commit xuyên database. Không scale, block khi failure, tránh trong thực tế.
- Eventual consistency với compensation — mỗi service commit locally, và nếu có gì sai ở downstream, bạn compensate (hoàn tác) các bước trước đó.
Compensation: Hoàn Tác Những Gì Đã Làm
Khi một bước trong saga fail, bạn cần hoàn tác các bước trước. Những operation hoàn tác này gọi là compensating transaction.
Quan trọng: Compensating transaction không phải lúc nào cũng là hoàn tác thực sự. Bạn không thể "un-send" email hay "un-charge" thẻ tín dụng ngay lập tức. Compensation là semantic inverse — gửi email hủy, hoàn tiền, v.v.
// Ví dụ compensation
public class CompensatingActions {
// Action gốc // Compensation
// createOrder() → cancelOrder()
// reserveInventory() → releaseInventory()
// chargePayment() → refundPayment()
// sendConfirmationEmail() → sendCancellationEmail()
// awardLoyaltyPoints() → revokeLoyaltyPoints()
}Thiết Kế Cho Eventual Consistency
Pattern UI:
- Hiển thị trạng thái "Đang xử lý..." thay vì xác nhận tức thì
- Dùng optimistic UI update với background sync
- Hiển thị order status dạng timeline: Đã đặt → Đã xác nhận → Đã giao
Pattern data:
- Chấp nhận rằng các service tạm thời có view khác nhau
- Dùng timestamp và versioning để phát hiện data cũ
- Thiết kế operation idempotent (phần tiếp theo)
Idempotency: Xử Lý Message An Toàn
Trong distributed system, message có thể được giao nhiều hơn một lần. Network timeout có thể gây retry dù lần đầu đã thành công. Consumer crash sau khi xử lý nhưng trước khi acknowledge nghĩa là message được giao lại.
Idempotency nghĩa là xử lý cùng message hai lần cho cùng kết quả như xử lý một lần.
Tại Sao Message Được Giao Nhiều Lần
Chiến Lược Cho Idempotency
Chiến lược 1: Idempotency Key (Phổ Biến Nhất)
Bao gồm event ID duy nhất. Theo dõi event nào đã được xử lý.
@KafkaListener(topics = "order-events")
public void handleOrderPlaced(OrderPlacedEvent event) {
// Kiểm tra đã xử lý chưa
if (processedEventRepository.existsById(event.getEventId())) {
log.info("Event {} đã xử lý, bỏ qua", event.getEventId());
return;
}
// Xử lý event
paymentService.charge(event.getUserId(), event.getTotalAmount());
// Đánh dấu đã xử lý (phải cùng transaction với side effect)
processedEventRepository.save(new ProcessedEvent(event.getEventId()));
}Chiến lược 2: Natural Idempotency
Thiết kế operation tự nhiên idempotent — chạy hai lần cho cùng kết quả như chạy một lần.
// ❌ KHÔNG idempotent — chạy hai lần cộng 100 hai lần
account.setBalance(account.getBalance() + 100);
// ✅ Idempotent — chạy hai lần cho cùng kết quả
account.setBalance(500); // Set giá trị tuyệt đối
// ✅ Idempotent — conditional update
UPDATE orders SET status = 'CONFIRMED'
WHERE id = ? AND status = 'PENDING'; -- Chỉ update nếu còn PENDINGChiến lược 3: Database Constraint
Dùng unique constraint để ngăn xử lý trùng lặp ở tầng database.
-- Unique constraint ngăn xử lý trùng
CREATE TABLE processed_payments (
order_id VARCHAR(255) PRIMARY KEY, -- Không thể insert hai lần
payment_id VARCHAR(255) NOT NULL,
processed_at TIMESTAMP DEFAULT NOW()
);
-- Insert sẽ fail nếu trùng
INSERT INTO processed_payments (order_id, payment_id)
VALUES ('order-789', 'payment-456');At-Least-Once vs At-Most-Once vs Exactly-Once
| Guarantee | Ý nghĩa | Rủi ro | Khi nào dùng |
|---|---|---|---|
| At-most-once | Giao một lần, không retry | Mất message | Metric, analytics không critical |
| At-least-once | Retry đến khi acknowledge | Trùng lặp | Phần lớn business event (với idempotency) |
| Exactly-once | Giao đúng một lần | Tốn kém | Transaction tài chính (Kafka transaction) |
Thực tế: Đa số hệ thống dùng at-least-once delivery với idempotent consumer. Exactly-once thực sự rất tốn kém và phức tạp — Kafka hỗ trợ trong Kafka transaction, nhưng exactly-once xuyên hệ thống đòi hỏi thiết kế cẩn thận.
Event Schema Evolution
Event là contract giữa producer và consumer. Khi hệ thống phát triển, event schema thay đổi. Xử lý những thay đổi này mà không làm hỏng consumer là cực kỳ quan trọng.
Vấn Đề
// Version 1: OrderPlaced event
{
"orderId": "order-789",
"amount": 109.97
}
// Version 2: Thêm field currency
{
"orderId": "order-789",
"amount": 109.97,
"currency": "USD" ← Field mới
}
// Version 3: Đổi tên amount → totalAmount
{
"orderId": "order-789",
"totalAmount": 109.97, ← Đổi tên!
"currency": "USD"
}Nếu consumer cũ mong đợi amount nhưng event mới có totalAmount, chúng sập.
Quy Tắc Schema Compatibility
Backward compatible (an toàn deploy consumer trước):
- ✅ Thêm field optional mới
- ✅ Thêm field mới với giá trị mặc định
- ❌ Xóa field
- ❌ Đổi tên field
- ❌ Thay đổi kiểu field
Forward compatible (an toàn deploy producer trước):
- ✅ Xóa field optional
- ✅ Thêm field mới (consumer bỏ qua field không biết)
- ❌ Thêm field bắt buộc
- ❌ Thay đổi kiểu field
Full compatible (an toàn deploy theo bất kỳ thứ tự):
- ✅ Thêm field optional với giá trị mặc định
- Cơ bản là vậy thôi — full compatibility rất hạn chế
Schema Registry
Dùng schema registry (như Confluent Schema Registry cho Kafka) để enforce compatibility rule tự động.
// Avro schema evolution — backward compatible
// Version 1
{
"type": "record",
"name": "OrderPlacedEvent",
"fields": [
{"name": "orderId", "type": "string"},
{"name": "amount", "type": "double"}
]
}
// Version 2 — backward compatible (field mới có default)
{
"type": "record",
"name": "OrderPlacedEvent",
"fields": [
{"name": "orderId", "type": "string"},
{"name": "amount", "type": "double"},
{"name": "currency", "type": "string", "default": "USD"}
]
}Mẹo Schema Evolution Thực Tế
- Luôn thêm, không bao giờ xóa — thêm field mới với default; deprecate field cũ nhưng giữ lại
- Không bao giờ đổi tên — thêm field mới và điền cả field cũ lẫn mới trong giai đoạn chuyển tiếp
- Dùng schema registry — kiểm tra compatibility tự động bắt breaking change trong CI
- Version event của bạn — bao gồm field
schemaVersionđể consumer xử lý nhiều version
// Xử lý nhiều version event
public void handleOrderPlaced(JsonNode event) {
int version = event.has("schemaVersion")
? event.get("schemaVersion").asInt()
: 1; // Mặc định v1 cho event cũ
switch (version) {
case 1:
processV1(event); // Dùng "amount"
break;
case 2:
processV2(event); // Dùng "totalAmount" + "currency"
break;
default:
log.warn("Schema version không biết: {}", version);
processLatest(event); // Cố gắng tốt nhất
}
}Dead Letter Queue và Xử Lý Lỗi
Chuyện gì xảy ra khi consumer không thể xử lý message? Có thể data bị malformed, dependency bị sập, hoặc có bug trong logic xử lý. Bạn không thể bỏ message — và cũng không thể retry mãi mãi.
Dead Letter Queue (DLQ)
Dead letter queue là queue riêng biệt nơi message đến sau khi đã fail xử lý một số lần nhất định.
// Spring Kafka — Dead Letter Queue configuration
@Configuration
public class KafkaConfig {
@Bean
public ConcurrentKafkaListenerContainerFactory<String, String>
kafkaListenerContainerFactory() {
ConcurrentKafkaListenerContainerFactory<String, String> factory =
new ConcurrentKafkaListenerContainerFactory<>();
// Retry 3 lần với backoff
factory.setCommonErrorHandler(new DefaultErrorHandler(
new DeadLetterPublishingRecoverer(kafkaTemplate),
new FixedBackOff(1000L, 3) // 1s delay, 3 retry
));
return factory;
}
}
// Consumer — failure tự động retry rồi gửi vào DLQ
@KafkaListener(topics = "order-events")
public void handleOrderEvent(OrderEvent event) {
// Nếu throw 3 lần, message đi vào "order-events.DLT"
processOrder(event);
}
// DLQ consumer — để điều tra và retry thủ công
@KafkaListener(topics = "order-events.DLT")
public void handleDeadLetter(ConsumerRecord<String, String> record) {
log.error("Dead letter nhận được. Key: {}, Value: {}, Error: {}",
record.key(), record.value(),
new String(record.headers().lastHeader("kafka_dlt-exception-message").value())
);
alertService.notifyOncall("Dead letter trong order-events", record);
}Phân Loại Lỗi và Chiến Lược
Không phải tất cả lỗi đều giống nhau. Chiến lược xử lý lỗi nên phân biệt giữa chúng:
| Loại lỗi | Ví dụ | Chiến lược |
|---|---|---|
| Tạm thời | Database timeout, network blip | Retry với exponential backoff |
| Poison message | JSON malformed, data không hợp lệ | Gửi vào DLQ ngay lập tức |
| Business error | Không đủ tiền, sản phẩm hết hàng | Publish failure event, trigger compensation |
| Bug | NullPointerException, logic error | Gửi vào DLQ, fix code, reprocess |
@KafkaListener(topics = "order-events")
public void handleOrderEvent(OrderEvent event) {
try {
processOrder(event);
} catch (TransientException e) {
// Throw lại — để retry mechanism xử lý
throw e;
} catch (ValidationException e) {
// Poison message — log và skip (không retry)
log.error("Event không hợp lệ, bỏ qua: {}", event, e);
deadLetterService.send(event, e);
} catch (InsufficientFundsException e) {
// Business error — publish compensation event
eventPublisher.publish(new PaymentFailedEvent(event.getOrderId(), e.getMessage()));
}
}Event Storming: Khám Phá Event
Làm sao bạn tìm ra hệ thống cần những event nào? Event storming là kỹ thuật workshop cộng tác được Alberto Brandolini tạo ra, giúp team khám phá domain event, command, và aggregate.
Quy Trình
- Tập hợp đúng người — domain expert, developer, product manager
- Sticky note cam — viết domain event (thì quá khứ) lên sticky cam và đặt trên timeline
- Sticky note xanh — xác định command (hành động) trigger mỗi event
- Sticky note vàng — xác định actor (user, hệ thống) phát hành command
- Sticky note vàng nhạt — xác định aggregate (entity) xử lý command và tạo event
- Sticky note hồng — đánh dấu hotspot (nhầm lẫn, bất đồng, phức tạp)
- Xác định bounded context — nhóm event và aggregate liên quan thành bounded context
Output Event Storming → Kiến Trúc
Bounded context bạn khám phá ánh xạ trực tiếp đến ranh giới microservice. Event trở thành message chạy giữa các service. Command trở thành API endpoint trong mỗi service.
| Artifact Event Storming | Ánh Xạ Đến |
|---|---|
| Bounded context | Microservice |
| Domain event | Message/event trong broker |
| Command | API endpoint |
| Aggregate | Domain entity |
| Actor | User role hoặc external system |
| Policy ("Khi X, thì Y") | Event handler / consumer |
Triển Khai Thực Tế: Hệ Thống Xử Lý Order
Hãy xây một hệ thống xử lý order event-driven hoàn chỉnh với Spring Boot và Kafka.
Định Nghĩa Event
// Base event class
public abstract class DomainEvent {
private final String eventId;
private final String eventType;
private final Instant timestamp;
private final int schemaVersion;
protected DomainEvent(String eventType) {
this.eventId = UUID.randomUUID().toString();
this.eventType = eventType;
this.timestamp = Instant.now();
this.schemaVersion = 1;
}
}
// Event cụ thể
public class OrderPlacedEvent extends DomainEvent {
private final String orderId;
private final String userId;
private final List<OrderItem> items;
private final BigDecimal totalAmount;
public OrderPlacedEvent(String orderId, String userId,
List<OrderItem> items, BigDecimal totalAmount) {
super("OrderPlaced");
this.orderId = orderId;
this.userId = userId;
this.items = items;
this.totalAmount = totalAmount;
}
}
public class PaymentProcessedEvent extends DomainEvent {
private final String orderId;
private final String paymentId;
private final BigDecimal amount;
private final PaymentStatus status; // SUCCESS, FAILED
// constructor...
}
public class InventoryReservedEvent extends DomainEvent {
private final String orderId;
private final List<ReservedItem> reservedItems;
// constructor...
}Producer: Order Service
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
public Order placeOrder(PlaceOrderRequest request) {
// 1. Validate và tạo order locally
Order order = Order.create(
request.getUserId(),
request.getItems()
);
order = orderRepository.save(order);
// 2. Publish event — consumer phản ứng độc lập
kafkaTemplate.send(
"order-events",
order.getId(), // Partition key = orderId
new OrderPlacedEvent(
order.getId(),
order.getUserId(),
order.getItems(),
order.getTotalAmount()
)
);
log.info("Order đã đặt và event đã publish: {}", order.getId());
return order;
}
@KafkaListener(topics = "payment-events", groupId = "order-service")
public void onPaymentResult(PaymentProcessedEvent event) {
Order order = orderRepository.findById(event.getOrderId())
.orElseThrow();
if (event.getStatus() == PaymentStatus.SUCCESS) {
order.confirm(event.getPaymentId());
log.info("Order đã xác nhận: {}", order.getId());
} else {
order.cancel("Thanh toán thất bại: " + event.getReason());
log.warn("Order bị hủy do thanh toán thất bại: {}", order.getId());
}
orderRepository.save(order);
}
}Consumer: Payment Service
@Service
public class PaymentEventHandler {
private final PaymentService paymentService;
private final ProcessedEventRepository processedEvents;
private final KafkaTemplate<String, DomainEvent> kafkaTemplate;
@KafkaListener(topics = "order-events", groupId = "payment-service")
@Transactional
public void onOrderPlaced(OrderPlacedEvent event) {
// Kiểm tra idempotency
if (processedEvents.existsById(event.getEventId())) {
log.info("Event đã xử lý: {}", event.getEventId());
return;
}
try {
// Xử lý thanh toán
Payment payment = paymentService.charge(
event.getUserId(),
event.getTotalAmount()
);
// Publish success event
kafkaTemplate.send("payment-events", event.getOrderId(),
new PaymentProcessedEvent(
event.getOrderId(),
payment.getId(),
event.getTotalAmount(),
PaymentStatus.SUCCESS
)
);
} catch (PaymentException e) {
// Publish failure event
kafkaTemplate.send("payment-events", event.getOrderId(),
new PaymentProcessedEvent(
event.getOrderId(),
null,
event.getTotalAmount(),
PaymentStatus.FAILED
)
);
}
// Đánh dấu event đã xử lý
processedEvents.save(new ProcessedEvent(event.getEventId()));
}
}Consumer: Notification Service
@Component
public class NotificationEventHandler {
private final EmailService emailService;
private final UserClient userClient;
@KafkaListener(
topics = {"order-events", "payment-events"},
groupId = "notification-service"
)
public void handleEvent(DomainEvent event) {
switch (event.getEventType()) {
case "OrderPlaced" -> handleOrderPlaced((OrderPlacedEvent) event);
case "PaymentProcessed" -> handlePayment((PaymentProcessedEvent) event);
}
}
private void handleOrderPlaced(OrderPlacedEvent event) {
User user = userClient.getUser(event.getUserId());
emailService.send(
user.getEmail(),
"Đã Nhận Đơn Hàng",
"Đơn hàng " + event.getOrderId() + " của bạn đã được nhận!"
);
}
private void handlePayment(PaymentProcessedEvent event) {
// Chỉ gửi email khi thành công — failure xử lý riêng
if (event.getStatus() == PaymentStatus.SUCCESS) {
// Lấy user info và gửi xác nhận...
}
}
}Infrastructure: Kafka Configuration
# application.yml — Spring Boot Kafka config
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: all # Chờ tất cả replica acknowledge
retries: 3
properties:
enable.idempotence: true # Kafka producer idempotency
consumer:
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
auto-offset-reset: earliest
enable-auto-commit: false # Manual commit cho reliability
properties:
spring.json.trusted.packages: "com.example.events"Anti-Pattern Phổ Biến
1. Event Soup
Publish event cho mọi thứ — kể cả chi tiết implementation nội bộ — tạo ra nhiễu khiến không thể hiểu hệ thống.
// ❌ Event soup — quá chi tiết
UserTableRowInserted
OrderValidationStarted
InventoryDatabaseQueryExecuted
CacheInvalidated
LogFileRotated// ✅ Domain event có ý nghĩa
UserRegistered
OrderPlaced
PaymentProcessed
InventoryDepletedQuy tắc: Chỉ publish event đại diện cho sự kiện business có ý nghĩa mà service khác thực sự quan tâm.
2. Async Everything
Không phải mọi thứ đều nên async. Nếu user đang chờ response và operation mất 50ms, async hóa chỉ thêm complexity mà không có benefit.
// ❌ Over-engineering: async cho lookup đơn giản
User click "View Profile" → publish ViewProfileRequested event
→ Profile Service consume event → publish ProfileLoaded event
→ API Gateway consume event → trả về cho user
// Kết quả: 500ms latency thay vì 50ms, cộng thêm eventual consistency headache
// ✅ Thực dụng: sync cho read, async cho write
GET /users/123 → gọi sync trực tiếp → 50ms response
POST /orders → publish OrderPlaced event → "Đang xử lý..." response3. Temporal Coupling Qua Event
Dùng event để tạo chuỗi synchronous ngụy trang thành async:
// ❌ Chuỗi sync ngụy trang thành event
OrderService publish "PleaseValidateUser" event
→ chờ "UserValidated" event (blocking!)
→ rồi publish "PleaseCheckInventory" event
→ chờ "InventoryChecked" event (blocking!)Nếu bạn đang chờ response, cứ gọi synchronous. Event dành cho fire-and-forget và phản ứng độc lập.
4. Không Version Event
Thay đổi event schema mà không versioning làm hỏng consumer một cách im lặng:
// Tuần 1: {orderId: "123", amount: 100}
// Tuần 2: {orderId: "123", totalAmount: 100} ← Breaking change!
// Consumer cũ crash: field "amount" là nullLuôn version schema của bạn. Luôn dùng schema registry trong production.
Tóm Tắt
Event-driven architecture tách biệt các service bằng cách giao tiếp qua event thay vì gọi trực tiếp. Điều này tạo ra hệ thống có khả năng phục hồi, scalable, và mở rộng được — với cái giá là complexity tăng lên về consistency và debug.
Khái niệm cốt lõi:
- Event thông báo chuyện gì đã xảy ra (thì quá khứ, bất biến)
- Command yêu cầu hành động (mệnh lệnh, có hướng)
- Query yêu cầu thông tin (read-only)
Event pattern:
- Event notification — event mỏng, consumer gọi lại để lấy chi tiết
- Event-carried state transfer — event béo, consumer tự chủ
Message broker:
- Kafka — event streaming throughput cao với replay
- RabbitMQ — routing linh hoạt với message-level acknowledgment
- Redis Streams — nhẹ, tốt cho pattern đơn giản
Coordination pattern:
- Choreography — phi tập trung, service phản ứng độc lập (2-3 bước)
- Orchestration — saga coordinator tập trung quản lý flow (4+ bước)
Reliability pattern:
- Idempotency — xử lý mỗi event đúng một lần bằng idempotency key
- Dead letter queue — bắt message fail để điều tra
- Schema evolution — thêm field với default, không bao giờ xóa hay đổi tên
Khi nào dùng event-driven architecture:
- Nhiều service cần phản ứng với cùng business event
- Bạn cần temporal decoupling (producer/consumer không cần available đồng thời)
- Bạn đang xây hệ thống cần scale read và write độc lập
- Bạn cần audit trail mọi thứ đã xảy ra
Khi nào KHÔNG dùng event-driven architecture:
- CRUD operation đơn giản với user mong đợi synchronous
- Hệ thống nhỏ nơi complexity không đáng
- Khi bạn không có infrastructure cho message broker và monitoring
Event đại diện cho ngôn ngữ của business. Xác định đúng event, kiến trúc sẽ theo sau.
Tiếp Theo Trong Chuỗi Kiến Trúc Phần Mềm
Đây là bài viết thứ 6 trong 12 bài của chuỗi Software Architecture Patterns:
- ✅ ARCH-1: Software Architecture Patterns Roadmap
- ✅ ARCH-2: Monolithic Architecture
- ✅ ARCH-3: Layered (N-Tier) Architecture
- ✅ ARCH-4: MVC, MVP & MVVM Patterns
- ✅ ARCH-5: Microservices Architecture
- ✅ ARCH-6: Event-Driven Architecture (bài viết này)
- 🔜 ARCH-7: CQRS & Event Sourcing
- 🔜 ARCH-8: Hexagonal Architecture (Ports & Adapters)
- 🔜 ARCH-9: Clean Architecture
- 🔜 ARCH-10: Domain-Driven Design (DDD)
- 🔜 ARCH-11: Serverless & Function-as-a-Service
- 🔜 ARCH-12: Choosing the Right Architecture
Bài viết liên quan:
📬 Đăng ký nhận bản tin
Nhận những bài viết mới nhất qua email mỗi tuần. Không spam, hủy đăng ký bất cứ lúc nào.
Chúng tôi tôn trọng quyền riêng tư của bạn. Hủy đăng ký bất cứ lúc nào.
💬 Comments
Đăng nhập để bình luận
Chúng tôi sẽ không đăng bất cứ điều gì mà không có sự cho phép của bạn.