Một buổi sáng thứ Hai, team nhận ticket: “Khi khách đặt hàng, hệ thống cần gửi email xác nhận, trừ tồn kho, tạo vận đơn, và đẩy dữ liệu sang analytics.” Giải pháp đầu tiên mà hầu hết dev nghĩ đến là gọi tuần tự, order service gọi HTTP sang inventory service, rồi gọi shipping service, rồi gọi notification service, rồi gọi analytics service. Mỗi lần gọi chờ response, xử lý lỗi, retry. Code đầy try/catch lồng nhau. Một tuần sau, shipping service chậm 3 giây vì partner API timeout, toàn bộ checkout bị kéo latency lên 5 giây. Thêm vài tuần nữa, analytics service deploy lỗi trả 500, order service retry 3 lần rồi cũng fail, khách không đặt được hàng dù lỗi nằm ở service hoàn toàn không liên quan đến flow mua hàng.
Đây là bài toán coupling kinh điển trong hệ thống phân tán. Order service không nên biết, và không cần biết, có bao nhiêu service downstream quan tâm đến sự kiện “đơn hàng được tạo”. Nó chỉ cần phát đi một tín hiệu: “có đơn hàng mới”, rồi ai cần thì tự lắng nghe. Đây là ý tưởng cốt lõi của event-driven architecture.
Nhưng “event-driven” là một thuật ngữ rộng, và trong thực tế có ít nhất ba mô hình rất khác nhau nằm dưới cùng cái tên đó. Hiểu sai mô hình nào đang dùng, hoặc tệ hơn, trộn lẫn chúng mà không nhận ra, là nguồn gốc của rất nhiều complexity không cần thiết. Bài này phân tách ba mô hình event, khi nào dùng cái nào, và những bẫy thực tế mà team hay gặp khi triển khai.
Sync call, vấn đề khởi nguồn
Trước khi nói về event, cần hiểu rõ vấn đề mà nó giải quyết. Trong kiến trúc sync call truyền thống, order service là trung tâm, nó biết danh sách mọi downstream service, gọi từng cái một (hoặc song song), chờ response, xử lý lỗi cho từng cái.
Mô hình này có ba vấn đề cấu trúc. Thứ nhất, temporal coupling, order service phải chờ tất cả downstream trả về trước khi respond cho client. Latency tổng = max(latency từng service) nếu gọi song song, hoặc tổng latency nếu gọi tuần tự. Thêm một downstream service mới là thêm latency. Thứ hai, availability coupling, nếu bất kỳ downstream nào chết, order service phải quyết định: fail cả request (ảnh hưởng user) hay ignore lỗi (mất data). Cả hai đều tệ. Thứ ba, knowledge coupling, order service phải biết danh sách downstream, biết API contract của từng service, import client library của từng service. Thêm service mới = sửa order service, deploy lại order service.
Event-driven architecture giải quyết cả ba vấn đề bằng cách đảo ngược mối quan hệ: order service không gọi ai cả, nó chỉ publish event lên broker. Ai cần thì subscribe. Order service không biết, và không cần biết, có bao nhiêu consumer.
Event notification, tín hiệu gọn nhẹ
Đây là mô hình event đơn giản nhất và phổ biến nhất. Hiểu nôm na: event notification giống như đèn báo phòng họp, đèn đỏ nghĩa là “đang bận”, ai cần biết chi tiết thì tự vào hỏi; tín hiệu nhỏ gọn, không kể hết câu chuyện. Khi có sự kiện xảy ra, producer publish một message nhỏ chứa loại sự kiện và khoá tham chiếu, không chứa toàn bộ dữ liệu. Consumer nhận event, rồi tự gọi API của producer để lấy chi tiết nếu cần.
{
"event_type": "OrderCreated",
"event_id": "evt_8f3a2b",
"timestamp": "2026-04-25T10:30:00Z",
"data": {
"order_id": "ord_12345"
}
}
Event chỉ nói “có đơn hàng mới, ID là ord_12345”. Inventory service nhận event, gọi GET /orders/ord_12345 để lấy danh sách sản phẩm và số lượng, rồi trừ tồn kho. Shipping service cũng gọi lại order service để lấy địa chỉ giao hàng. Analytics service gọi lấy toàn bộ order detail để lưu.
Ưu điểm rõ ràng nhất: event nhỏ, bandwidth thấp, producer đơn giản, chỉ cần serialize event_type + ID rồi publish. Schema event ổn định vì chỉ có ID, ít khi thay đổi. Producer hoàn toàn không biết consumer, thêm service mới chỉ cần subscribe topic, không sửa order service.
Nhưng mô hình này tạo ra coupling on read. Mọi consumer đều phải gọi lại producer để lấy dữ liệu, tức là producer phải có API đủ tốt, đủ nhanh, và luôn available khi consumer cần. Nếu order service chết đúng lúc inventory service nhận event và gọi lại, inventory service phải retry hoặc đợi, giá trị “decouple” bị giảm đi đáng kể. Hơn nữa, nếu 10 consumer cùng nhận event và cùng gọi GET /orders/{id}, order service nhận 10 request cho cùng một order, fan-in không cần thiết.
Một vấn đề tinh vi hơn: temporal inconsistency. Consumer nhận event “OrderCreated” nhưng gọi API 5 giây sau, lúc đó order có thể đã bị update (thêm item, đổi địa chỉ). Consumer nhận data khác với data tại thời điểm event xảy ra. Với notification service gửi email thì chắc không sao, nhưng với analytics service cần snapshot chính xác tại thời điểm tạo đơn thì đây là bug.
Event notification phù hợp khi consumer chỉ cần biết “có chuyện xảy ra” để trigger workflow riêng của mình, và data cần thiết ít hoặc consumer đã có sẵn context. Ví dụ: notification service chỉ cần order_id để gửi email template, không cần toàn bộ order detail.
Event-carried state transfer, event mang theo dữ liệu
Mô hình thứ hai giải quyết coupling on read bằng cách đưa toàn bộ dữ liệu cần thiết vào trong event. Consumer nhận event là có đủ thông tin để xử lý, không cần gọi lại producer.
{
"event_type": "OrderCreated",
"event_id": "evt_8f3a2b",
"timestamp": "2026-04-25T10:30:00Z",
"data": {
"order_id": "ord_12345",
"customer_id": "cust_789",
"items": [
{ "sku": "SKU-001", "qty": 2, "price_cents": 15000 },
{ "sku": "SKU-042", "qty": 1, "price_cents": 89000 }
],
"shipping_address": {
"street": "123 Nguyễn Huệ",
"city": "Hồ Chí Minh",
"country": "VN"
},
"total_cents": 119000,
"currency": "VND"
}
}
Event này “béo” hơn nhiều, nó chứa mọi thứ mà consumer có thể cần. Inventory service đọc items để trừ tồn kho. Shipping service đọc shipping_address để tạo vận đơn. Analytics service lưu nguyên event làm fact record.
Ưu điểm lớn nhất: consumer tự chủ hoàn toàn. Không cần gọi lại producer, không phụ thuộc vào availability hay latency của producer sau khi nhận event. Consumer có thể build local projection, lưu data từ event vào database riêng, query khi cần, mà không bao giờ phải gọi service khác. Đây là mức decouple cao nhất trong ba mô hình.
Nhưng trade-off cũng rõ ràng. Event size lớn hơn nhiều, với hệ thống high-throughput (hàng chục nghìn event/giây), bandwidth và storage tăng đáng kể. Schema event phức tạp hơn, mỗi lần order service thêm trường mới hoặc đổi cấu trúc, event schema thay đổi, và mọi consumer phải xử lý schema mới. Đây là lúc schema evolution trở thành vấn đề thực sự (sẽ nói kỹ ở phần sau).
Ngoài ra, data trong event là snapshot tại thời điểm publish, nó đúng tại lúc đó nhưng có thể lỗi thời sau vài giây. Nếu consumer cần data luôn mới nhất, phải kết hợp thêm event OrderUpdated để cập nhật local projection. Điều này dẫn đến consumer phải xây dựng và duy trì local materialized view, không khó nhưng thêm complexity.
Mô hình event-carried state transfer phù hợp khi consumer cần dữ liệu đầy đủ để xử lý độc lập, đặc biệt khi producer và consumer thuộc bounded context khác nhau và bạn muốn giảm thiểu runtime dependency giữa chúng.
Event sourcing, event là nguồn sự thật
Hai mô hình trên dùng event như thông báo phụ, nguồn sự thật vẫn là database hiện tại (bảng orders, bảng inventory). Event chỉ là cơ chế truyền tin. Nếu event bị mất, bạn vẫn có data trong database.
Event sourcing đảo ngược hoàn toàn: event là nguồn sự thật duy nhất. Không có bảng orders với trạng thái hiện tại. Thay vào đó, có một log append-only chứa mọi sự kiện đã xảy ra với order, theo đúng thứ tự thời gian. Trạng thái hiện tại là kết quả của việc replay toàn bộ event từ đầu.
Event 1: OrderCreated { order_id: "ord_12345", items: [...], total: 119000 }
Event 2: OrderItemAdded { order_id: "ord_12345", item: { sku: "SKU-099", qty: 1 } }
Event 3: ShippingAddressChanged { order_id: "ord_12345", new_address: {...} }
Event 4: OrderPaid { order_id: "ord_12345", payment_id: "pay_456", amount: 134000 }
Event 5: OrderShipped { order_id: "ord_12345", tracking: "VN123456789" }
Muốn biết trạng thái hiện tại của order? Replay 5 event theo thứ tự, apply từng event lên một object trống, kết quả cuối cùng là trạng thái hiện tại. Muốn biết trạng thái order tại thời điểm event 3? Replay 3 event đầu. Muốn biết order có bao nhiêu lần thay đổi địa chỉ trong lịch sử? Query event log tìm tất cả ShippingAddressChanged cho order đó.
status: shipped
total: 134000
..."] end E5 -.->|"replay → current state"| ROW
Khi event sourcing toả sáng
Audit trail hoàn chỉnh là lợi ích hiển nhiên nhất. Trong ngành tài chính, bảo hiểm, y tế, nơi quy định pháp lý yêu cầu giữ lại mọi thay đổi, event sourcing cho bạn audit trail miễn phí. Không cần bảng audit_log riêng, không cần trigger database ghi lại trạng thái cũ trước khi update. Event log chính là audit trail.
Temporal query, khả năng “du hành thời gian”, là thứ không thể có với CRUD truyền thống. “Tồn kho của SKU-001 vào lúc 14:00 ngày 20/04 là bao nhiêu?” Với CRUD, bạn chỉ biết tồn kho hiện tại trừ khi có bảng snapshot riêng. Với event sourcing, replay event đến timestamp đó là có câu trả lời.
Debugging production trở nên dễ hơn đáng kể. Khi user report “đơn hàng của tôi bị sai”, bạn replay event log của order đó, xem từng bước hệ thống đã làm gì. Không cần đoán, không cần tìm log rời rạc, event log kể lại toàn bộ câu chuyện.
Rebuild read model khi cần. Consumer sai logic xử lý? Fix logic, replay toàn bộ event từ đầu, build lại read model. Cần thêm read model mới (ví dụ: dashboard cho team product)? Subscribe từ đầu event log, replay, xong. Đây là khả năng mà CRUD không có, khi bạn UPDATE row trong database, data cũ biến mất (trừ khi có CDC hay trigger ghi log, mà bản chất cũng là một dạng event sourcing nhẹ).
Khi event sourcing gây đau đầu
Query trạng thái hiện tại chậm nếu không có snapshot hoặc read projection. Replay 10,000 event mỗi lần cần đọc trạng thái một aggregate là không khả thi cho latency yêu cầu millisecond. Giải pháp là snapshot (lưu trạng thái tại event N, replay từ N+1) hoặc CQRS read model (sẽ nói ở bài sau). Nhưng mỗi giải pháp thêm complexity.
Schema evolution phức tạp hơn CRUD. Với CRUD, đổi schema thì chạy migration ALTER TABLE, xong. Với event sourcing, event cũ trong log có schema cũ, bạn không thể ALTER event đã ghi. Phải viết upcaster (function chuyển event v1 thành v2 khi replay) hoặc giữ nhiều version event handler. Đây là chi phí vận hành thực sự mà nhiều team không tính trước.
Learning curve cao. Tư duy “không UPDATE, không DELETE, chỉ APPEND” đi ngược lại thói quen CRUD mà hầu hết developer quen thuộc. Event design, quyết định event nào cần ghi, granularity thế nào, là kỹ năng cần thời gian xây dựng. (Mình từng thấy team viết lại toàn bộ event store sau 6 tháng vì thiết kế event quá chi tiết, mỗi field change là một event riêng, replay 10,000 event chỉ để load một đơn hàng.) Team mới bắt đầu thường mắc lỗi thiết kế event quá nhỏ (event soup) hoặc quá lớn (mất đi lợi ích granular audit).
Delete khó. GDPR yêu cầu “right to be forgotten”, xoá data của user khi họ yêu cầu. Với event sourcing, event là immutable, không xoá được. Giải pháp thường dùng là crypto-shredding: encrypt PII trong event bằng key riêng cho mỗi user, khi cần “xoá” thì xoá key, event vẫn còn nhưng PII không đọc được nữa. Hoạt động nhưng thêm complexity đáng kể.
So sánh ba mô hình
Ba mô hình event không phải “tốt hơn / kém hơn”, chúng giải quyết bài toán khác nhau và phù hợp với yêu cầu khác nhau. Hiểu rõ trade-off giúp chọn đúng mô hình cho đúng context.
để lấy data] N3[Source of truth: DB] end subgraph "Event-Carried State Transfer" C1[Fat event: full data snapshot] C2[Consumer tự chủ
không callback] C3[Source of truth: DB] end subgraph "Event Sourcing" S1[Domain event: mô tả thay đổi] S2[Append-only log] S3[Source of truth: Event log] end
Event notification giữ event nhỏ nhất, schema ổn định nhất, nhưng consumer phụ thuộc producer khi cần data. Event-carried state transfer cho consumer tự chủ nhưng event lớn hơn và schema phức tạp hơn. Event sourcing thay đổi hoàn toàn cách lưu trữ, event là sự thật, state là derived, cho nhiều khả năng mới nhưng đi kèm complexity vận hành cao hơn.
Trong thực tế, mình thấy hầu hết team nên bắt đầu với event notification hoặc event-carried state transfer. Chỉ chuyển sang event sourcing khi giá trị audit trail, temporal query, hoặc rebuild read model thực sự biện minh được complexity thêm vào. “Event sourcing mọi thứ” là anti-pattern phổ biến ở team mới tiếp cận, phấn khích với khái niệm nhưng chưa lường hết chi phí vận hành.
Schema evolution, vấn đề không tránh được
Bất kể mô hình nào, khi hệ thống phát triển, schema event sẽ thay đổi. Order event ban đầu không có trường discount_code, ba tháng sau product yêu cầu thêm. Trường shipping_address ban đầu là string, sau cần tách thành object có street, city, country. Đây là thực tế không tránh được, và cách xử lý schema evolution quyết định hệ thống event-driven có bền vững không.
Backward và forward compatibility
Hai khái niệm cốt lõi khi nói về schema evolution. Backward compatible nghĩa là consumer mới (hiểu schema v2) vẫn đọc được event cũ (schema v1), đây là yêu cầu bắt buộc vì event cũ đã tồn tại trong log, không thể sửa. Forward compatible nghĩa là consumer cũ (hiểu schema v1) vẫn đọc được event mới (schema v2) mà không crash, quan trọng khi deploy consumer và producer không đồng thời (rolling update).
Quy tắc thực dụng: thêm trường mới với giá trị mặc định thì backward compatible, consumer cũ ignore trường mới, consumer mới đọc trường mới hoặc dùng default khi đọc event cũ. Xoá trường bắt buộc hoặc đổi type trường thì breaking change, phải tạo event type mới (ví dụ OrderCreatedV2) hoặc dùng upcaster.
Schema registry
Với hệ thống lớn, dùng format serialization có schema, Avro hoặc Protobuf, kết hợp schema registry (Confluent Schema Registry, Apicurio, AWS Glue Schema Registry) giúp enforce compatibility tự động. Khi producer register schema mới, registry kiểm tra compatibility rule (backward, forward, full) và reject nếu schema mới breaking.
Avro đặc biệt phù hợp cho event-driven vì nó hỗ trợ schema evolution natively, reader dùng reader schema, writer dùng writer schema, Avro tự map trường và fill default cho trường mới. Protobuf cũng tốt nhờ field numbering, thêm trường mới với number mới, consumer cũ skip trường không biết.
JSON phổ biến vì đơn giản nhưng không có schema enforcement, consumer phải tự validate, và breaking change chỉ phát hiện khi runtime crash. Với team nhỏ và event ít, JSON đủ dùng. Khi hệ thống lớn lên, investment vào Avro/Protobuf + schema registry trả lại giá trị rất nhanh.
Upcaster cho event sourcing
Với event sourcing, event cũ tồn tại mãi trong log. Khi schema thay đổi, thay vì migrate data (không thể, event immutable), viết upcaster: function nhận event v1 và trả về event v2 tương đương. Khi replay event log, mỗi event đi qua chuỗi upcaster trước khi đến event handler.
def upcast_order_created_v1_to_v2(event_v1):
return {
**event_v1,
"schema_version": 2,
"data": {
**event_v1["data"],
"discount_code": None, # trường mới, default None
"shipping_address": parse_address_string(
event_v1["data"]["shipping_address"]
), # string → object
},
}
Chuỗi upcaster phải được test kỹ, sai upcaster là corrupt toàn bộ state khi replay. Mỗi lần schema thay đổi, thêm một upcaster, chuỗi dài dần. Đây là chi phí vận hành thực sự của event sourcing mà team cần tính trước.
Idempotent consumer, tại sao bắt buộc
Trong hệ thống message queue, delivery guarantee phổ biến nhất là at-least-once, broker đảm bảo message được giao ít nhất một lần, nhưng có thể giao nhiều lần. Lý do: khi consumer nhận message và xử lý xong nhưng chưa kịp ACK (acknowledge), broker nghĩ message chưa được xử lý và giao lại. Consumer nhận message lần thứ hai, xử lý lần thứ hai, trừ tồn kho hai lần, gửi hai email, tạo hai vận đơn.
Exactly-once delivery ở mức network là bất khả thi trong distributed system (bài toán Two Generals). Kafka cung cấp exactly-once semantics qua idempotent producer + transactional consumer, nhưng chỉ trong phạm vi Kafka ecosystem, khi consumer ghi ra database bên ngoài Kafka, vẫn cần idempotency ở application level.
Giải pháp thực dụng nhất: mỗi event có event_id duy nhất. Consumer lưu event_id đã xử lý vào bảng processed_events. Trước khi xử lý event mới, kiểm tra event_id đã tồn tại chưa, nếu rồi thì skip. Đơn giản, hiệu quả, hoạt động ở mọi quy mô.
BEGIN;
-- Kiểm tra đã xử lý chưa
SELECT 1 FROM processed_events WHERE event_id = 'evt_8f3a2b';
-- Nếu chưa: xử lý và ghi lại
INSERT INTO processed_events (event_id, processed_at)
VALUES ('evt_8f3a2b', NOW());
UPDATE inventory SET qty = qty - 2 WHERE sku = 'SKU-001';
COMMIT;
Quan trọng: INSERT vào processed_events và business logic phải nằm trong cùng transaction. Nếu tách ra, có khoảng hở giữa “ghi đã xử lý” và “thực sự xử lý”, crash giữa chừng dẫn đến data inconsistent.
Bảng processed_events sẽ phình to theo thời gian. Dọn dẹp bằng cách xoá record cũ hơn retention period của message queue, nếu queue giữ message tối đa 7 ngày thì record cũ hơn 7 ngày không bao giờ cần kiểm tra lại. Đặt index trên event_id để lookup nhanh.
Có cách khác là thiết kế operation idempotent by nature, ví dụ thay vì qty = qty - 2 (không idempotent, chạy hai lần trừ 4), dùng qty = X với X là giá trị tuyệt đối được tính sẵn, hoặc dùng version/sequence check. Nhưng không phải operation nào cũng dễ thiết kế idempotent by nature, nên bảng processed_events vẫn là safety net phổ quát nhất.
Event ordering, không đơn giản như tưởng
Khi order service publish OrderCreated rồi OrderPaid, consumer phải nhận OrderCreated trước OrderPaid, xử lý payment cho order chưa tồn tại là lỗi. Ordering nghe tự nhiên nhưng trong distributed system, đảm bảo ordering có chi phí.
Total ordering vs causal ordering
Total ordering đảm bảo mọi consumer thấy mọi event theo đúng một thứ tự, giống như một hàng đợi đơn. Đạt được bằng cách dùng single partition, tất cả event vào cùng một partition, broker giao theo thứ tự ghi. Nhưng single partition là bottleneck throughput, chỉ một consumer xử lý tại một thời điểm.
Causal ordering (hay partial ordering) yếu hơn nhưng đủ cho hầu hết use case: event liên quan đến cùng entity phải theo thứ tự, nhưng event của entity khác nhau có thể xử lý song song. Đạt được bằng partition key, tất cả event của order_id=ord_12345 vào cùng partition, event của order_id=ord_67890 vào partition khác. Trong cùng partition, thứ tự được đảm bảo. Giữa các partition, không có ordering guarantee, nhưng không cần, vì hai order khác nhau không có quan hệ nhân quả.
Kafka dùng partition key để route message, set partition key = order_id là cách phổ biến nhất để đảm bảo causal ordering. RabbitMQ có consistent hash exchange cho mục đích tương tự. SQS FIFO queue dùng message group ID.
Quy tắc thực dụng: dùng entity ID làm partition key, chấp nhận causal ordering thay vì total ordering. Chỉ khi nào bạn cần ordering giữa các entity khác nhau (hiếm trong thực tế) mới cần total ordering, và lúc đó cân nhắc kỹ throughput trade-off.
Ordering và consumer parallelism
Cần hiểu rõ: ordering chỉ được đảm bảo trong cùng partition và khi một consumer xử lý partition đó. Nếu hai consumer instance cùng đọc một partition (ít khi xảy ra với consumer group đúng cách), ordering có thể vỡ.
Kafka consumer group đảm bảo mỗi partition chỉ gán cho một consumer trong group, ordering safe. Nhưng khi consumer lag nhiều và bạn muốn tăng parallelism, thêm consumer instance không giúp nếu số partition ít, Kafka không thể gán nhiều consumer cho một partition. Plan số partition đủ lớn từ đầu (nhưng không quá lớn, mỗi partition tốn resource ở broker).
CQRS, bạn đồng hành tự nhiên của event sourcing
Command Query Responsibility Segregation tách mô hình ghi (command) và mô hình đọc (query) thành hai hệ thống riêng. Với event sourcing, command side ghi event vào event store. Query side subscribe event, build read model (materialized view) tối ưu cho query pattern cụ thể.
Ví dụ: command side lưu event log cho order (append-only, không có index phức tạp). Query side build bảng order_summary có đầy đủ trường cần hiển thị trên UI, status, tổng tiền, ngày tạo, tên khách, denormalized, index tốt, query nhanh. Khi cần thêm dashboard mới cho team product, build thêm read model từ cùng event stream, không ảnh hưởng command side.
CQRS giải quyết vấn đề “query trạng thái hiện tại chậm” của event sourcing, nhưng thêm complexity: read model có thể eventually consistent với command side (có delay giữa lúc event ghi và lúc read model cập nhật). Với nhiều ứng dụng, delay vài trăm millisecond là chấp nhận được. Với ứng dụng cần strong consistency (ví dụ: sau khi tạo order, redirect sang trang order detail phải thấy order ngay), cần kỹ thuật bổ sung, read-your-own-writes, hoặc đọc trực tiếp từ command side cho request ngay sau write.
CQRS sẽ được phân tích kỹ hơn ở bài tiếp theo trong series.
Anti-pattern phổ biến
Event soup
Publish quá nhiều event granular, OrderFieldXUpdated, OrderFieldYUpdated, mỗi thay đổi nhỏ là một event riêng. Consumer phải aggregate hàng chục event để hiểu một hành động nghiệp vụ đơn giản. Log phình to, debug khó vì phải ghép nhiều event mới thấy bức tranh đầy đủ.
Ngược lại, event quá lớn, OrderChanged chứa toàn bộ order mới, mất đi lợi ích biết chính xác cái gì thay đổi. Consumer phải diff state cũ và mới để tìm thay đổi.
Heuristic tốt: mỗi event nên tương ứng với một hành động nghiệp vụ có ý nghĩa, “khách thêm sản phẩm vào đơn”, “khách thanh toán”, “admin huỷ đơn”. Không phải mỗi field change, không phải toàn bộ entity dump.
Dùng event cho request-reply
Publish event “HãyTính GiáChoTôi” rồi subscribe topic khác chờ “ĐâyLàGiá”, bản chất đây là RPC qua message queue. Chậm hơn HTTP call trực tiếp (thêm latency serialize/deserialize, broker routing), khó debug hơn (không có request-response correlation rõ ràng), và dễ mất message hơn.
Event-driven phù hợp cho thông báo (fire and forget, không cần response) và stream processing (chuỗi transformation). Nếu cần response đồng bộ, dùng HTTP/gRPC, đó là công cụ đúng cho bài toán đó. Trộn lẫn paradigm chỉ thêm complexity.
Bỏ qua schema versioning
Publish event dạng JSON không có version, không có schema registry. Ba tháng sau, thêm trường mới, đổi tên trường cũ, consumer cũ crash vì deserialize fail. Hoặc tệ hơn: không crash nhưng xử lý sai vì interpret data sai.
Mỗi event nên có trường schema_version tối thiểu. Tốt hơn là dùng Avro/Protobuf với schema registry. Chi phí setup ban đầu nhỏ so với chi phí debug production khi schema break.
Không monitor consumer lag
Consumer lag là khoảng cách giữa event mới nhất producer ghi và event mới nhất consumer đã xử lý. Lag tăng liên tục nghĩa là consumer không kịp xử lý, có thể do bug, do throughput không đủ, hoặc do downstream chậm. Nếu không monitor, lag tích luỹ cho đến khi event cũ bị xoá khỏi retention, mất data vĩnh viễn.
Kafka expose consumer lag qua JMX metrics. Đặt alert khi lag vượt ngưỡng, ngưỡng cụ thể tuỳ SLO của hệ thống, nhưng “lag tăng liên tục trong 30 phút” thường là dấu hiệu cần can thiệp.
Khi nào chọn mô hình nào
Quyết định không phải “event sourcing hay không event sourcing” một cách toàn cục. Trong cùng hệ thống, các bounded context khác nhau có thể dùng mô hình khác nhau.
Order context, nơi cần audit trail mọi thay đổi, cần khả năng temporal query “trạng thái đơn hàng lúc 3 giờ chiều hôm qua”, cần debug “tại sao đơn này bị sai”, event sourcing có giá trị rõ ràng. Product catalog, nơi admin CRUD sản phẩm, không cần lịch sử chi tiết, query phức tạp trên trạng thái hiện tại, CRUD truyền thống + event notification cho downstream đơn giản và hiệu quả hơn.
Notification service cần biết “có đơn mới” để gửi email, event notification với thin event đủ dùng, không cần fat event vì email template lấy data từ template service riêng. Analytics service cần snapshot đầy đủ tại thời điểm sự kiện, event-carried state transfer phù hợp hơn vì analytics không muốn phụ thuộc vào API order service mỗi lần ingest data.
Nguyên tắc chung: bắt đầu với mô hình đơn giản nhất (notification), nâng cấp khi yêu cầu bắt buộc. Event sourcing là investment, nó cho nhiều khả năng nhưng đi kèm chi phí vận hành. Đừng event-source mọi thứ vì “có thể cần sau này”. Hãy event-source khi bạn biết rõ giá trị nó mang lại, audit, temporal query, rebuild projection, và team sẵn sàng cho complexity thêm vào.
Infrastructure pattern thực tế
Outbox pattern
Một vấn đề kinh điển: order service cần ghi order vào database VÀ publish event lên broker. Nếu ghi DB thành công nhưng publish event fail (broker chết), database có order nhưng downstream không biết. Nếu publish trước rồi ghi DB sau, event đã giao nhưng order không tồn tại trong DB khi consumer callback.
Outbox pattern giải quyết: ghi order VÀ event vào cùng database, trong cùng transaction (bảng outbox). Một process riêng (poller hoặc CDC, Change Data Capture) đọc bảng outbox và publish lên broker. Vì DB transaction đảm bảo atomicity, hoặc cả hai ghi thành công, hoặc cả hai rollback.
BEGIN;
INSERT INTO orders (id, customer_id, total) VALUES ('ord_12345', 'cust_789', 119000);
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
VALUES ('evt_8f3a2b', 'Order', 'ord_12345', 'OrderCreated', '{"order_id":"ord_12345",...}');
COMMIT;
Debezium (CDC cho Kafka) là công cụ phổ biến nhất cho outbox pattern, nó đọc transaction log của database (PostgreSQL WAL, MySQL binlog) và publish thay đổi lên Kafka topic. Không cần polling, latency thấp, không tốn resource query bảng outbox liên tục.
Dead letter queue
Khi consumer không thể xử lý event sau nhiều lần retry (data invalid, bug logic, dependency chết), event cần đi đâu đó thay vì block queue mãi mãi. Dead letter queue (DLQ) là nơi chứa event “thất bại”, team có thể review, fix bug, rồi replay event từ DLQ.
DLQ phải được monitor, event nằm trong DLQ là event chưa được xử lý, tức là data inconsistency đang tồn tại. Đặt alert khi DLQ có message. Review DLQ hàng ngày hoặc hàng tuần tuỳ criticality.
Observability cho hệ thống event-driven
Debug hệ thống event-driven khó hơn sync call vì flow không tuyến tính, event publish rồi consumer xử lý bất đồng bộ, có thể vài giây hoặc vài phút sau. Correlation trở nên cực kỳ quan trọng.
Mỗi event phải chứa trace_id (hoặc correlation_id), cùng giá trị với request gốc tạo ra event. Khi consumer xử lý event, tiếp tục propagate trace_id vào log và span. Nhờ đó, từ một request ban đầu, bạn trace được toàn bộ hành trình xuyên qua broker, consumer, downstream service, dù flow là async.
Metric quan trọng cần theo dõi: event publish rate (producer đang ghi bao nhiêu event/giây), consumer processing rate (consumer xử lý bao nhiêu event/giây), consumer lag (khoảng cách giữa hai rate), error rate ở consumer (bao nhiêu phần trăm event xử lý lỗi), DLQ size. Dashboard RED (Rate, Errors, Duration) cho consumer giống như cho HTTP service, nhưng “request” ở đây là event.
Log ở consumer phải chứa event_id, event_type, trace_id, và partition/offset (nếu dùng Kafka). Khi debug, bạn cần biết “event nào, từ partition nào, offset bao nhiêu, xử lý lúc nào, kết quả gì”. Thiếu bất kỳ trường nào là debug mò.
Chọn mô hình event không phải lựa chọn một lần cho toàn hệ thống, notification cho service cần decouple đơn giản, state transfer cho service cần tự chủ dữ liệu, sourcing cho domain thực sự cần audit trail đầy đủ. Đừng event-source mọi thứ vì hype; hãy event-source khi bạn biết rõ giá trị cụ thể nó mang lại, và đã nghĩ kỹ về upcaster, schema evolution, và GDPR trước khi commit.