Bạn từng gặp cảnh này. Một POST /checkout lẽ ra chỉ mất 200 ms giờ liên tục timeout, và khi bạn mở trace ra thì hiểu ngay: order service gọi payment, payment gọi inventory, inventory gọi email service, email service lại gọi một bên thứ ba đang có một buổi chiều chậm chạp. Bốn service nắm tay nhau, và khách hàng thì nhìn cái spinner quay mãi chỉ vì service cuối cùng trong chuỗi đang mệt.
Sáu phần đầu của series này nói về cấu trúc: cách vẽ ranh giới bên trong codebase và giữa các service. Phần này nói về vấn đề tiếp theo xuất hiện ngay khi bạn có nhiều hơn một service — chúng nói chuyện với nhau thế nào. Event-driven architecture là câu trả lời bị hiểu lầm nhiều nhất cho câu hỏi đó: được tôn sùng như viên đạn bạc, bị sợ như sự phức tạp thừa thãi, và hiếm khi được giải thích bằng lời lẽ giản dị. Hãy sửa điều đó.
Vấn đề: những service nắm tay nhau
Khi một service gọi một service khác rồi đợi câu trả lời, ta gọi đó là synchronous (đồng bộ). Đây là bản năng đầu tiên, vì nó đọc giống một lời gọi hàm bình thường. Rắc rối nằm ở chỗ điều gì xảy ra khi bạn nối chúng thành chuỗi.
- Độ trễ cộng dồn. Nếu mỗi chặng mất 80 ms, một chuỗi bốn chặng đã là 320 ms trước khi có ai làm việc thật sự.
- Lỗi lan ra. Nếu email service chết, checkout chết theo — dù gửi hoá đơn không phải là mục đích của việc mua hàng.
- Coupling cứng lại. Order service buộc phải biết về payment, inventory và email. Thêm một phản ứng thứ năm là bạn lại phải sửa order service.
Vấn đề sâu xa là caller đang làm hai việc khác nhau cùng lúc: hoàn tất đơn hàng và điều phối tất cả những ai quan tâm đến đơn hàng. Đó không phải cùng một việc, và việc đóng đinh chúng vào nhau chính là thứ làm cái spinner quay mãi.
Hai cách để các service nói chuyện
Chỉ có đúng hai hình dạng hội thoại, và phần lớn các cuộc tranh luận về kiến trúc thực ra là tranh luận xem nên dùng cái nào ở đâu.
| Synchronous (request/response) | Asynchronous (messaging) | |
|---|---|---|
| Mô hình tư duy | Một cuộc gọi điện — hai bên cùng trên đường dây | Một lá thư gửi bưu điện — gửi bây giờ, đọc sau |
| Coupling | Caller biết callee và đợi nó | Bên gửi chỉ biết broker, không biết ai đọc |
| Khi lỗi | Callee chết → caller lỗi ngay | Bên đọc chết → message nằm chờ trong queue |
| Độ trễ | Tổng của mọi chặng | Bên gửi trả về ngay; việc chạy nền |
| Hợp nhất với | "Tôi cần câu trả lời để đi tiếp" (đọc số dư, kiểm tra token) | "Việc này đã xảy ra; ai quan tâm thì phản ứng" (đặt hàng, upload file) |
Để ý dòng cuối. Synchronous là công cụ đúng khi bạn thật sự cần câu trả lời để tiếp tục. Asynchronous là công cụ đúng khi bạn đang thông báo rằng có chuyện đã xảy ra và phản ứng của người khác là việc của họ, không phải của bạn. Event-driven architecture là thứ bạn có khi nghiêng hẳn về hình dạng thứ hai một cách có chủ đích.
Đây là cùng một checkout, trước và sau. Bản synchronous bắt khách hàng đợi tất cả mọi người:
// Trước: checkout sở hữu — và đợi — mọi thứ phía sau
async function checkout(cart) {
const order = await orders.create(cart)
await payment.charge(order) // chậm
await inventory.reserve(order) // chậm
await email.sendReceipt(order) // chậm, và không phải điểm chính
return order // khách đợi cả bốn
}
Bản event-driven hoàn tất đơn hàng rồi thông báo nó. Ai quan tâm thì phản ứng theo nhịp của riêng mình:
// Sau: checkout hoàn tất, rồi publish một sự thật và trả về
async function checkout(cart) {
const order = await orders.create(cart)
await broker.publish("orders", { type: "OrderPlaced", order })
return order // nhanh — payment, inventory, email phản ứng sau
}
Khách hàng không còn phải trả giá cho buổi chiều chậm chạp của email service. Quan trọng không kém, thêm chấm điểm gian lận ngày mai chỉ là viết một subscriber mới, không phải sửa lại checkout lần nữa.
Từ vựng, bằng lời giản dị
Đám thuật ngữ rất nhỏ một khi bạn lột bỏ lớp huyền bí khỏi nó.
- Message — bất kỳ khối dữ liệu nào một service trao cho service khác qua một người trung gian.
- Command (lệnh) — message yêu cầu một việc xảy ra:
ChargeCard. Nó có một người xử lý dự kiến và có thể bị từ chối. - Event (sự kiện) — message thông báo một việc đã xảy ra:
OrderPlaced. Đó là một sự thật ở thì quá khứ, không gửi cho riêng ai. - Broker — người trung gian giữ message và giao đi: RabbitMQ, Kafka, NATS, AWS SNS/SQS, Google Pub/Sub.
- Topic / queue — cái hộp thư có tên nơi message rơi vào. Producer ghi vào; consumer đọc ra.
- Producer / consumer — bên publish, và bên subscribe rồi phản ứng.
Phân biệt quan trọng nhất là command và event, vì nó quyết định ai là người chỉ huy. Command là "này, làm việc này đi." Event là "chuyện này đã xảy ra; làm gì bạn thấy đúng thì làm." Lẫn lộn hai thứ và bạn sẽ xây một hệ thống trông có vẻ event-driven nhưng thực chất là một đống nút điều khiển từ xa.
Ba "khẩu vị" của event
"Bọn em dùng events" có thể mang ba ý nghĩa khá khác nhau. Biết mình đang nói về cái nào sẽ tiết kiệm rất nhiều tranh cãi.
- Event notification. Một tiếng ping mỏng: "đơn 1234 vừa được đặt." Nếu consumer cần chi tiết, nó gọi ngược lại để hỏi. Payload nhỏ nhất, nhưng có thể sinh ra nhiều cuộc gọi ngược lắm lời.
- Event-carried state transfer. Event mang theo mọi thứ consumer cần — cả đơn hàng — nên không cần gọi ngược. Message nặng hơn, nhưng consumer giữ được độc lập. Đây là con ngựa thồ của hầu hết hệ thống event-driven.
- Event sourcing. Các event chính là nguồn sự thật; trạng thái hiện tại được dựng lại bằng cách phát lại chúng. Mạnh và hiếm — phần lớn team không cần, và ta sẽ xem nó như một tuỳ chọn nâng cao ở phần sau.
Hãy bắt đầu với event-carried state transfer cho dăm ba sự thật mà team khác quan tâm, và những lời gọi synchronous bình thường cho mọi thứ cần câu trả lời ngay. Bạn có thể sống một đời dài và hạnh phúc chỉ với hai thứ đó trước khi cần đụng đến event sourcing.
Broker thật sự hứa điều gì
Đây là chỗ thiện chí gặp vật lý. Broker không hứa cái thế giới sạch sẽ "đúng một lần, đúng thứ tự" mà bạn tưởng tượng. Nó hứa một điều thành thật hơn và khó chịu hơn.
- At-least-once là chuẩn mực. Broker có thể giao cùng một message hai lần — sau một cú nghẽn mạng, một lần crash, hay một lần redeliver. "Exactly once" phần lớn là chiêu marketing; thứ hệ thống thật làm là at-least-once cộng với consumer idempotent.
- Thứ tự có giới hạn. Sắp thứ tự toàn cục trên một topic rất đắt; phần lớn broker chỉ bảo đảm thứ tự trong một partition hoặc theo key. Hãy thiết kế sao cho việc message đến sai thứ tự vẫn sống sót được.
- Message lỗi cần một mái nhà. Một message cứ lỗi mãi nên rơi vào dead-letter queue thay vì chặn cả hàng đợi mãi mãi.
Hệ quả thực tế là một quy tắc đáng xăm lên người cả team: mọi consumer phải an toàn khi chạy hai lần. Tính chất đó gọi là idempotency, và nó là khác biệt giữa một hệ thống vững vàng với một hệ thống gửi cho khách ba cái hoá đơn.
// Không idempotent: giao trùng sẽ trừ tiền hai lần và gửi mail hai lần
async function onOrderPlaced(evt) {
await payment.charge(evt.amount) // chạy lại khi redeliver
await email.sendReceipt(evt.customerId)
}
// Idempotent: event id là người gác cổng. An toàn khi chạy hai lần.
async function onOrderPlaced(evt) {
if (await seen.has(evt.id)) return // đã xử lý rồi — không làm gì
await payment.charge(evt.amount)
await email.sendReceipt(evt.customerId)
await seen.add(evt.id)
}
Ai chỉ huy: choreography và orchestration
Một khi công việc trải qua nhiều service, phải có thứ gì đó điều phối nó. Có hai triết lý, và lựa chọn này định hình cách bạn sẽ debug lúc 2 giờ sáng.
- Choreography (biên đạo). Không có nhạc trưởng. Mỗi service nghe event và phản ứng, rồi lần lượt phát event của riêng mình. Order phát
OrderPlaced; payment nghe được rồi phátPaymentTaken; shipping nghe cái đó. Decoupled rất đẹp — và thật sự khó dõi theo, vì "luồng" không nằm ở bất kỳ chỗ nào duy nhất. - Orchestration (dàn nhạc). Một bộ điều phối (một "order saga") nói rõ cho từng service phải làm gì tiếp theo và theo dõi tiến độ. Dễ nhìn và dễ suy luận hơn; cái giá là bộ điều phối trở thành một thứ bạn phải sở hữu và giữ cho đơn giản.
Dùng choreography cho những phản ứng "fan-out" lỏng lẻo mà không ai cần biết toàn bộ câu chuyện (analytics, thông báo, đánh index tìm kiếm). Dùng orchestration cho một giao dịch nghiệp vụ thật sự với các bước phải cùng thành công hoặc cùng được hoàn tác — đó chính xác là bài toán saga ta sẽ mổ xẻ ở phần sau về dữ liệu.
Event-driven architecture phát huy ở đâu
- Fan-out. Một việc xảy ra và kéo theo nhiều phản ứng không liên quan. Thêm phản ứng thứ bảy không nên đồng nghĩa với sửa lại cái việc đã xảy ra.
- Việc dồn cục hoặc chậm. Encode video, sinh PDF, gửi một triệu email — đẩy lên queue và để worker gặm dần mà không bắt người dùng đợi.
- Đệm tải. Một queue hấp thụ cú spike traffic mà lẽ ra sẽ quật ngã một downstream service đồng bộ.
- Tách rời các team. Team sở hữu "orders" có thể publish sự thật mà không cần học về mọi team tiêu thụ chúng.
Khi nào ĐỪNG với tới events
Events không miễn phí, và với tới chúng quá sớm là một trong những cách phổ biến nhất để tự chế ra accidental complexity.
- Bạn cần câu trả lời ngay. "Coupon này còn hợp lệ không?" là một câu hỏi, không phải lời thông báo. Cứ gọi thẳng service.
- Đó là app hai service. Một broker, một schema registry và một dead-letter queue là quá nhiều máy móc để né một lời gọi trực tiếp giữa hai service duy nhất của bạn.
- Team chưa từng vận hành broker. Lỗi async mặc định là vô hình. Không có tracing và dashboard tốt, một queue bị kẹt là một sự cố thầm lặng mà bạn phát hiện ra qua những khách hàng giận dữ.
- Strong consistency là bắt buộc. Nếu nghiệp vụ không chịu nổi "đúng sau một hai giây," eventual consistency sẽ làm bạn đau — xem phần sau.
Kiểu hỏng là biến mọi lời gọi hàm thành event và kết thúc với một hệ thống mà logic rải rác qua hai mươi subscriber và không thể nào trace nổi. Bất đồng bộ là công cụ để tách rời những thứ thật sự độc lập — không phải mặc định cho tất cả. Nếu bạn không thể giải thích một luồng mà thiếu bảng trắng và bốn màu bút, bạn đã lạm dụng nó.
Góc nhìn thành thật theo quy mô công ty
- Solo / startup giai đoạn đầu. Gần như chắc chắn bạn chưa cần broker. Một bảng background job ngay trong database hiện có (poll bảng
jobs, đánh dấu xong) lo được 90% nhu cầu "làm việc này sau" mà không gánh chút sức nặng vận hành nào. - Scale-up đang lớn. Đưa vào một managed broker (SQS, Pub/Sub, Kafka hosted) cho hai ba điểm fan-out thật sự: order events, xử lý file, thông báo. Giữ synchronous cho mọi thứ cần câu trả lời. Đầu tư vào tracing trước khi đầu tư thêm topic.
- Doanh nghiệp lớn. Events trở thành xương sống giữa các team, với quản trị schema, xử lý dead-letter, công cụ replay, và quyền sở hữu rõ ràng cho từng topic. Phần khó không còn là công nghệ; mà là contract — thống nhất một event nghĩa là gì và không bao giờ phá vỡ nó một cách tuỳ tiện.
Bắt đầu từ thứ Hai thế nào
- Tìm một chỗ mà caller đang đợi một công việc người dùng không cần đợi — gửi email, sinh báo cáo, đồng bộ sang bên thứ ba. Đó là event đầu tiên của bạn.
- Publish một sự thật ở thì quá khứ (
OrderPlaced), không phải một command. Để email service subscribe thay vì bị gọi. - Làm consumer idempotent ngay từ ngày đầu, khoá theo event id. Hãy giả định nó sẽ được giao hai lần.
- Thêm dead-letter queue và một cảnh báo trên đó trước khi lên production. Một dead-letter queue đang phình to là dấu hiệu cảnh báo sớm nhất của bạn.
- Thêm tracing để một request có thể được dõi theo qua từng chặng. Async mà không có observability là debug khi bịt mắt.
Những điều cốt lõi
- Sync là cuộc gọi điện; async là lá thư. Dùng lời gọi synchronous khi bạn cần câu trả lời để đi tiếp, và dùng messaging khi bạn thông báo rằng có chuyện đã xảy ra.
- Command yêu cầu; event thông báo. Phân biệt command/event quyết định ai chỉ huy — hãy làm đúng nó trước mọi thứ khác.
- Broker hứa at-least-once, không phải exactly-once. Vì thế mọi consumer phải idempotent và an toàn khi chạy hai lần. Điều này không thể thương lượng.
- Choreography tách rời; orchestration làm rõ. Phản ứng fan-out hợp với choreography; giao dịch nhiều bước thật sự cần một bộ điều phối — chính là saga của phần sau.
- Events dành cho sự độc lập thật, không cho tất cả. Với tới broker trong một app hai service, hay biến mọi lời gọi thành event, là tự chế ra đúng cái phức tạp mà bạn đang muốn tránh.
Tách việc ra mới là nửa dễ. Khoảnh khắc các service ngừng dùng chung một database, một câu hỏi khó hơn xuất hiện: ai sở hữu dữ liệu, và "đúng" có nghĩa là gì khi sự thật được trải ra trên năm service? Đó là phần tiếp theo của series này.