Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang xây một hệ thống thương mại điện tử. Khi khách hàng đặt hàng thành công, có bao nhiêu việc phải xảy ra? Trừ tồn kho, gửi email xác nhận, cộng điểm thưởng, tạo đơn giao vận, ghi nhận doanh thu cho báo cáo, thông báo cho người bán... Nếu bạn viết tất cả những thứ này thành một chuỗi lời gọi trực tiếp bên trong hàm createOrder(), thì hàm đó sẽ phình to, chậm chạp, và mỗi lần thêm một tính năng mới (ví dụ: gửi thông báo qua Zalo) bạn lại phải sửa vào chính trái tim của luồng đặt hàng. Tệ hơn, chỉ cần dịch vụ gửi email chậm hay chết, khách hàng sẽ không đặt được hàng — dù việc gửi email chẳng liên quan gì đến việc đơn hàng có hợp lệ hay không.
Đây chính là bài toán mà Event-Driven Architecture (EDA) — kiến trúc hướng sự kiện — sinh ra để giải quyết. Thay vì bắt dịch vụ đặt hàng phải biết và gọi tất cả những dịch vụ downstream, ta để nó chỉ làm đúng một việc: xác nhận đơn hàng đã được tạo, rồi "công bố" sự thật đó ra ngoài dưới dạng một event. Ai quan tâm thì tự lắng nghe và phản ứng.
Với vai trò một kiến trúc sư hệ thống, hiểu EDA không chỉ là học thêm một mẫu thiết kế. Nó là bước ngoặt trong cách bạn tư duy về sự phụ thuộc (coupling), về thời gian (đồng bộ hay bất đồng bộ), và về việc ai chịu trách nhiệm cho việc gì. Bài này đặt nền móng khái niệm để bạn ra quyết định kiến trúc đúng — còn những chủ đề chuyên sâu như đồng bộ/bất đồng bộ (Bài 21), CQRS (Bài 33), Event Sourcing (Bài 34) hay message broker cụ thể (Bài 30–32) sẽ được đào sâu ở các bài sau.
Khái niệm cốt lõi
Event là gì?
Event (sự kiện) là một sự thật đã xảy ra trong quá khứ. Đây là định nghĩa quan trọng nhất, hãy khắc sâu nó. Một event mô tả điều gì đó đã diễn ra và không thể thay đổi được nữa.
Vì là sự thật quá khứ, tên event luôn ở thì hoàn thành (past tense):
OrderPlaced(Đơn hàng đã được đặt)PaymentCaptured(Thanh toán đã được thu)UserRegistered(Người dùng đã đăng ký)InventoryReserved(Hàng đã được giữ chỗ)
Event vs Command — ranh giới sống còn
Đây là điểm nhiều người nhầm lẫn, và cũng là phần cốt lõi của bài học này. Cả event và command đều là "message" đi qua hệ thống, nhưng bản chất khác nhau hoàn toàn.
| Tiêu chí | Command (Mệnh lệnh) | Event (Sự kiện) |
|---|---|---|
| Ý nghĩa | "Hãy làm việc này đi" | "Việc này đã xảy ra" |
| Thời | Mệnh lệnh, tương lai | Quá khứ hoàn thành |
| Tên gọi | SendEmail, ReserveInventory | EmailSent, InventoryReserved |
| Người gửi | Biết chính xác ai xử lý | Không quan tâm ai nghe |
| Số người nhận | Đúng một (1:1) | Không hoặc nhiều (0:N) |
| Kỳ vọng | Mong hành động được thực hiện | Không kỳ vọng gì cụ thể |
| Quyền từ chối | Bên nhận có thể từ chối thực thi | Không thể từ chối — nó đã xảy ra rồi |
Sự khác biệt này quyết định mức độ coupling. Với command, producer và consumer bị buộc chặt vào nhau — producer phải biết consumer tồn tại. Với event, producer hoàn toàn "mù" về consumer, và đó chính là nguồn gốc sức mạnh của EDA.
Ba thành phần của một hệ thống EDA
1. Event Producer (Publisher): Nơi phát sinh sự kiện. Trong ví dụ đầu bài, dịch vụ Order chính là producer khi nó phát ra OrderPlaced.
2. Event Broker / Event Channel: Hạ tầng trung gian nhận event và chuyển tiếp cho những ai đăng ký. Đây có thể là một message broker như Kafka, RabbitMQ, AWS SNS/SQS, hay Google Cloud Pub/Sub. Broker là "tấm đệm" giúp producer và consumer tách rời nhau về cả không gian lẫn thời gian.
3. Event Consumer (Subscriber): Nơi lắng nghe và phản ứng. Dịch vụ Email, dịch vụ Loyalty, dịch vụ Shipping — mỗi cái đều subscribe vào OrderPlaced và tự làm phần việc của mình một cách độc lập.
Hai mô hình phân phối event
Pub/Sub (Publish–Subscribe): Một event được phát ra, tất cả consumer đã đăng ký đều nhận được bản sao. Đây là mô hình phổ biến nhất cho EDA thuần túy. OrderPlaced đến tay cả Email, Loyalty, và Shipping cùng lúc.
Event Streaming: Event được ghi vào một "dòng chảy" (log) bền vững, có thứ tự, và consumer đọc từ vị trí họ muốn — thậm chí đọc lại (replay) từ đầu. Kafka là đại diện tiêu biểu. Điểm mạnh: event không mất đi sau khi đọc, cho phép thêm consumer mới xử lý lại toàn bộ lịch sử.
Lợi ích và cái giá phải trả
Lợi ích chính của EDA:
- Loose coupling: Thêm consumer mới không cần đụng vào producer. Muốn gửi thông báo Zalo khi có đơn? Chỉ cần viết một consumer mới subscribe
OrderPlaced— dịch vụ Order không hề hay biết. - Khả năng mở rộng và chịu tải: Broker hấp thụ các đợt tải đột biến. Nếu 10.000 đơn ập đến trong 1 phút, consumer xử lý theo tốc độ của mình mà không làm sập producer.
- Khả năng phục hồi (resilience): Dịch vụ Email chết không ảnh hưởng đến việc đặt hàng. Event nằm chờ trong broker, khi Email sống lại nó xử lý tiếp.
Tình huống thực tế
Tình huống 1: Sàn TMĐT Việt Nam gỡ bỏ "quả bom" trong luồng checkout
Một sàn thương mại điện tử tầm trung ở Việt Nam (giả định tên ChợViệt) ban đầu viết luồng đặt hàng theo kiểu tuần tự đồng bộ: createOrder() gọi lần lượt trừ kho → tính điểm → gửi email → tạo đơn giao vận, tất cả trong một request. Vào đợt sale 12/12, dịch vụ email (bên thứ ba) bị nghẽn, thời gian phản hồi tăng từ 200ms lên 8 giây. Hậu quả: mỗi request đặt hàng bị treo chờ email, connection pool cạn kiệt, và toàn bộ nút "Đặt hàng" ngừng hoạt động dù kho hàng và thanh toán vẫn khỏe. Tỷ lệ đặt hàng thành công rớt từ 94% xuống 40% trong 20 phút cao điểm.
Diễn giải: Đội kỹ thuật tái cấu trúc theo EDA. Giờ createOrder() chỉ làm hai việc: kiểm tra hợp lệ và ghi đơn, rồi phát event OrderPlaced vào broker. Dịch vụ Email, Loyalty, Shipping trở thành các consumer độc lập. Ở đợt sale sau, khi nhà cung cấp email lại chậm, event OrderPlaced chỉ đơn giản xếp hàng trong broker chờ xử lý — khách vẫn đặt hàng mượt mà, chỉ là email xác nhận đến chậm hơn vài phút. Tỷ lệ đặt hàng thành công giữ ở 96%.
Bài học: Hãy tách những việc không thuộc về tính đúng đắn của giao dịch ra khỏi luồng chính. Đặt hàng cần đúng; gửi email chỉ cần cuối cùng xảy ra. EDA cho phép bạn phân biệt hai loại "cần" này.
Tình huống 2: Grab và bài toán "một sự kiện, nhiều bên quan tâm"
Xét một nền tảng gọi xe kiểu Grab. Khi một chuyến đi kết thúc, sự kiện RideCompleted xảy ra. Có bao nhiêu bộ phận quan tâm đến sự thật này? Bộ phận thanh toán (trừ tiền ví khách, cộng thu nhập tài xế), bộ phận đánh giá (mở màn hình chấm sao), bộ phận khuyến mãi (cộng tiến độ "đi 10 chuyến tặng voucher"), bộ phận chống gian lận (phân tích quãng đường có bất thường không), bộ phận báo cáo (cập nhật dashboard real-time cho vận hành). Năm, sáu, rồi sẽ còn nhiều đội khác nữa trong tương lai.
Diễn giải: Nếu dịch vụ chuyến đi phải tự gọi từng dịch vụ này bằng command, nó sẽ biến thành một "God service" biết mọi thứ về mọi phòng ban — và mỗi khi một đội mới muốn tham gia, đội chuyến đi phải sửa code, deploy lại, gánh rủi ro. Với EDA, dịch vụ chuyến đi chỉ phát duy nhất một event RideCompleted chứa dữ liệu cần thiết (mã chuyến, quãng đường, giá cước, mã khách, mã tài xế). Mỗi đội tự subscribe. Đội chống gian lận mới thành lập tuần sau? Họ tự viết consumer, không ai phải xin phép đội chuyến đi.
Bài học: EDA tỏa sáng nhất khi một sự kiện có nhiều bên quan tâm và danh sách đó còn thay đổi theo thời gian. Đây là dấu hiệu kinh điển để chọn pub/sub thay vì gọi trực tiếp.
Tình huống 3: Khi EDA bị dùng sai chỗ — bài học về event "trá hình command"
Một startup fintech (giả định tên VíNhanh) hào hứng áp EDA cho mọi thứ. Họ tạo event tên ProcessPayment và đẩy vào Kafka, một consumer duy nhất lắng nghe và thực hiện thanh toán, rồi phát tiếp SendReceipt cho một consumer duy nhất khác. Vấn đề: khi thanh toán thất bại, không có cách nào trả lỗi về cho người dùng đang chờ trên màn hình, vì luồng đã "bắn rồi quên". Họ phải chắp vá thêm cơ chế polling để hỏi "xong chưa, xong chưa".
Diễn giải: ProcessPayment không phải event — nó là command đội lốt event. Tên thì hô hào ("hãy xử lý"), chỉ có đúng một consumer, và người gửi mong đợi một kết quả cụ thể. Đây là dấu hiệu điển hình của việc dùng sai công cụ. Với thao tác cần phản hồi ngay và có ngữ nghĩa yêu cầu–đáp ứng, một lời gọi đồng bộ (hoặc command qua broker có cơ chế reply) mới đúng. Sau khi sửa, VíNhanh giữ luồng thanh toán ở dạng đồng bộ, và chỉ sau khi thanh toán thành công mới phát event thật PaymentCaptured cho các bên downstream (gửi biên lai, ghi sổ) subscribe.
Bài học: Đặt tên message ở thì quá khứ và tự hỏi "mình có đang mong một câu trả lời cụ thể không?". Nếu có, đó là command chứ không phải event. Đừng ép EDA vào chỗ nó không thuộc về.
Hướng dẫn từng bước
Khi bạn cân nhắc áp dụng EDA cho một tính năng, hãy đi theo trình tự sau:
Bước 1 — Xác định sự kiện nghiệp vụ (business event). Nhìn vào miền nghiệp vụ và liệt kê những sự thật quan trọng đã xảy ra: đơn được đặt, thanh toán được thu, tài khoản bị khóa. Viết tên chúng ở thì quá khứ. Nếu không đặt được tên ở thì quá khứ, có lẽ đó không phải event.
Bước 2 — Kiểm tra "phép thử coupling". Với mỗi việc downstream, tự hỏi: producer có cần biết consumer này tồn tại để hệ thống đúng không? Nếu có (ví dụ: phải trừ kho trước khi xác nhận đơn), nó thuộc về luồng đồng bộ. Nếu không (gửi email, cộng điểm, ghi báo cáo), nó là ứng viên hoàn hảo cho consumer bất đồng bộ.
Bước 3 — Thiết kế nội dung (payload) của event. Event nên chứa đủ dữ liệu để consumer làm việc mà không phải gọi ngược lại producer. Bao gồm: mã định danh sự kiện (event ID), thời điểm xảy ra, mã thực thể liên quan, và các trường dữ liệu cốt lõi. Tránh nhồi quá nhiều — chỉ đưa những gì consumer thực sự cần.
Bước 4 — Chọn mô hình phân phối. Cần nhiều consumer nhận cùng event và có thể phát sinh thêm consumer? Chọn pub/sub. Cần lưu trữ, đọc lại lịch sử, xử lý theo thứ tự nghiêm ngặt? Nghiêng về event streaming. (Chi tiết chọn broker cụ thể ở Bài 30–32.)
Bước 5 — Thiết kế cho thất bại. Consumer xử lý event có thể lỗi. Ngay từ đầu hãy nghĩ đến: cơ chế thử lại (retry), hàng đợi cho message chết (dead-letter queue), và tính bất biến khi xử lý lặp (idempotency — sẽ học kỹ ở Bài 39). Đây không phải chuyện "để sau", vì trong EDA, xử lý trùng lặp gần như chắc chắn sẽ xảy ra.
Bước 6 — Định nghĩa hợp đồng (contract) và versioning. Event là một hợp đồng công khai giữa producer và mọi consumer. Khi thay đổi cấu trúc event, bạn có thể phá vỡ những consumer mình thậm chí không biết tồn tại. Thống nhất một schema rõ ràng và có chiến lược cho việc thay đổi nó.
Lỗi thường gặp & mẹo
Lỗi 1 — Nhầm command thành event (và ngược lại). Như tình huống VíNhanh, đây là lỗi phổ biến nhất. Mẹo: đặt tên ở thì quá khứ hoàn thành và tự hỏi "có đúng một người nhận và mình có mong kết quả không?". Nếu có, nó là command.
Lỗi 2 — Event chứa quá ít hoặc quá nhiều dữ liệu. Quá ít (chỉ có mã đơn) buộc consumer phải gọi ngược lại producer, tái tạo coupling mà EDA cố phá bỏ. Quá nhiều (nhồi cả thông tin nội bộ) khiến event khó bảo trì và rò rỉ chi tiết. Mẹo: đưa vào những gì "một người quan sát bên ngoài cần biết về sự kiện này".
Lỗi 3 — Giả định thứ tự và giao đúng-một-lần. Nhiều broker chỉ đảm bảo "at-least-once" (ít nhất một lần), nghĩa là consumer có thể nhận cùng event hai lần. Và thứ tự giữa các event có thể bị đảo. Mẹo: luôn thiết kế consumer idempotent — xử lý event OrderPlaced lần thứ hai không được cộng điểm thưởng lần nữa.
Lỗi 4 — Dùng EDA cho mọi thứ. EDA thêm độ phức tạp thật sự: hạ tầng broker, khó debug, eventual consistency. Với một CRUD app nhỏ, nội bộ một team, gọi hàm trực tiếp vẫn tốt hơn. Mẹo: chỉ chọn EDA khi bạn thực sự cần loose coupling, khả năng chịu tải bất đồng bộ, hoặc mô hình "nhiều bên quan tâm".
Lỗi 5 — Quên mất khả năng quan sát (observability). Trong EDA, một hành động của người dùng có thể lan qua năm dịch vụ. Không có công cụ theo dõi, việc "vì sao email không được gửi" trở thành cơn ác mộng. Mẹo: gắn một correlation ID (mã tương quan) vào mỗi event để lần theo toàn bộ luồng (chủ đề observability sâu hơn ở Bài 43).
Mẹo tổng quát: Bắt đầu nhỏ. Đừng chuyển cả hệ thống sang EDA một lần. Chọn một luồng có nhiều việc phụ trợ (như checkout), tách phần bất đồng bộ ra event trước, đo lường, rồi mở rộng dần.
Bài tập thực hành
Bài 1 — Phân loại message. Cho danh sách sau, hãy xác định cái nào là event, cái nào là command, và đặt lại tên cho đúng nếu cần: SendWelcomeEmail, UserSignedUp, ChargeCreditCard, OrderShipped, ReserveStock, RefundIssued. Với mỗi cái, giải thích trong một câu vì sao.
Bài 2 — Thiết kế payload. Bạn xây tính năng cho một ứng dụng giao đồ ăn. Khi tài xế nhận đơn, hãy thiết kế event OrderAccepted: đặt tên các trường dữ liệu cần có, giải thích vì sao mỗi trường cần thiết, và chỉ ra một trường bạn cố tình không đưa vào cùng lý do.
Bài 3 — Sơ đồ luồng. Vẽ (trên giấy hoặc công cụ vẽ) luồng xảy ra khi khách hàng của một sàn TMĐT thanh toán thành công. Xác định: đâu là producer, event tên gì, có những consumer nào, và consumer nào mà nếu chết cũng không làm hỏng trải nghiệm mua hàng.
Bài 4 — Phân tích quyết định. Lấy một hệ thống bạn từng làm (hoặc quen thuộc). Chỉ ra một luồng mà việc chuyển sang EDA sẽ có lợi, và một luồng mà EDA sẽ là lựa chọn sai. Viết ba câu lý giải cho mỗi trường hợp, dựa trên "phép thử coupling" ở phần hướng dẫn.
Tóm tắt
Event-Driven Architecture xoay quanh một ý tưởng đơn giản mà sâu sắc: dịch vụ giao tiếp với nhau qua event — những sự thật đã xảy ra — thay vì qua command — những mệnh lệnh yêu cầu hành động. Sự phân biệt này không chỉ là chuyện đặt tên: event ở thì quá khứ, không kỳ vọng phản hồi, có thể tới tay không hoặc nhiều người nghe; command ở thì mệnh lệnh, gửi đúng một nơi, và mong đợi hành động cụ thể.
Ba thành phần của EDA — producer, broker, consumer — cùng với hai mô hình phân phối (pub/sub và event streaming) tạo nên nền tảng cho phép hệ thống của bạn đạt được loose coupling, khả năng mở rộng, và khả năng phục hồi. Đổi lại, bạn chấp nhận eventual consistency, độ khó debug, và độ phức tạp vận hành.
Điều quan trọng nhất cần mang theo: EDA là một công cụ đánh đổi, không phải viên đạn bạc. Nó tỏa sáng khi một sự kiện có nhiều bên quan tâm và danh sách đó còn thay đổi; nó phản tác dụng khi bị ép vào những luồng yêu cầu–đáp ứng cần phản hồi tức thì. Hãy đặt tên message ở thì quá khứ, áp "phép thử coupling", và thiết kế cho thất bại ngay từ đầu. Ở các bài tiếp theo, chúng ta sẽ đi sâu vào lựa chọn giữa đồng bộ và bất đồng bộ (Bài 21), các mẫu nâng cao như CQRS (Bài 33) và Event Sourcing (Bài 34), cũng như cách chọn message broker phù hợp (Bài 30–32).