Mở đầu — vì sao bài này quan trọng
Có một câu hỏi tưởng đơn giản nhưng lại là ngã ba đường của rất nhiều quyết định kiến trúc: "Khi dịch vụ A cần dịch vụ B làm việc gì đó, A nên đợi B trả lời ngay (sync) hay chỉ gửi thông điệp đi rồi tiếp tục làm việc khác (async)?".
Đừng xem nhẹ câu hỏi này. Tôi đã chứng kiến nhiều đội kỹ thuật ở Việt Nam biến một hệ thống đang chạy tốt thành một mớ hỗn độn chỉ vì chọn sai cách giao tiếp giữa các thành phần. Có đội thì lạm dụng async đến mức không ai debug nổi luồng nghiệp vụ; có đội thì cứng nhắc gọi sync khắp nơi khiến toàn hệ thống sập theo dây chuyền khi chỉ một dịch vụ nhỏ chậm đi.
Đây là bài về quyết định: sync hay async, khi nào chọn cái nào, và cái giá phải trả cho mỗi lựa chọn. Chúng ta không đi sâu vào chi tiết event-driven architecture (đó là bài trước — Bài 20), cũng không bàn về message broker cụ thể nào (Kafka, RabbitMQ sẽ ở các bài sau). Bài này tập trung vào khung ra quyết định: đứng trước một tương tác giữa hai thành phần, bạn cân nhắc điều gì để chọn đúng?
Là người ra quyết định kiến trúc, bạn phải trả lời được câu hỏi này một cách có lý lẽ — không phải "vì ai cũng làm vậy" mà vì bạn hiểu rõ trade-off. Đó chính là mục tiêu của bài học hôm nay.
Khái niệm cốt lõi
Sync — Giao tiếp đồng bộ (Request-Response)
Trong mô hình đồng bộ, bên gọi (caller) gửi một request và dừng lại chờ cho đến khi nhận được response. Điển hình là một lời gọi HTTP REST hoặc gRPC: dịch vụ Order gọi API dịch vụ Payment, và luồng xử lý của Order bị "khóa" (blocked) cho tới khi Payment trả về kết quả thành công hay thất bại.
Đặc điểm quan trọng của sync:
- Mô hình tư duy đơn giản. Bạn gọi hàm, bạn nhận kết quả, bạn xử lý tiếp. Nó giống hệt cách chúng ta viết code thông thường, chỉ khác là lời gọi đi qua mạng. Điều này khiến sync rất dễ đọc, dễ hiểu, dễ đào tạo người mới.
- Thất bại rõ ràng ngay lập tức. Nếu Payment lỗi, Order biết ngay: có exception, có mã lỗi HTTP 500, có timeout. Bạn có thể phản ứng tức thì — thử lại, báo lỗi cho người dùng, rollback.
- Có ràng buộc thời gian (temporal coupling). Cả hai bên phải "sống" và sẵn sàng cùng lúc. Nếu Payment đang bảo trì, Order không thể hoàn thành. Đây là điểm yếu chí mạng.
- Consistency mạnh dễ đạt hơn. Vì bạn biết kết quả ngay, bạn dễ đảm bảo trạng thái nhất quán trong một luồng nghiệp vụ.
Async — Giao tiếp bất đồng bộ (Fire-and-Forget / Event-Driven)
Trong mô hình bất đồng bộ, bên gọi gửi một thông điệp (message/event) đi — thường qua một message broker hoặc queue — rồi tiếp tục làm việc khác ngay lập tức, không chờ kết quả. Bên nhận sẽ xử lý thông điệp đó vào một thời điểm sau, có thể là mili-giây, có thể là vài phút sau.
Đặc điểm quan trọng của async:
- Tách rời về thời gian (temporal decoupling). Bên nhận không cần phải sẵn sàng ngay lúc bên gửi gửi đi. Nếu dịch vụ nhận đang bảo trì, thông điệp nằm trong queue chờ, xử lý sau. Hệ thống chịu lỗi tốt hơn nhiều.
- Khả năng hấp thụ tải (buffering / back-pressure). Khi lượng request tăng đột biến, queue đóng vai trò "hồ chứa", giúp bên nhận xử lý theo nhịp của mình thay vì bị đè bẹp.
- Mô hình tư duy phức tạp hơn. Luồng nghiệp vụ bị chia nhỏ, phân tán qua nhiều handler. Bạn phải nghĩ về thứ tự thông điệp, xử lý trùng lặp (idempotency — Bài 39), và trạng thái trung gian.
- Thất bại âm thầm. Nếu bên nhận xử lý lỗi, bên gửi không hề biết. Bạn phải xây dựng cơ chế riêng để phát hiện: dead-letter queue, monitoring, alerting.
- Eventual consistency là mặc định. Trạng thái hệ thống sẽ nhất quán "sau cùng", nhưng có một khoảng thời gian dữ liệu chưa đồng bộ.
Điểm mấu chốt: câu hỏi bạn phải tự hỏi
Không có lựa chọn nào "tốt hơn" tuyệt đối. Câu hỏi quyết định là: "Bên gọi có THỰC SỰ cần kết quả để làm việc tiếp không?"
- Nếu câu trả lời là "Có, tôi không thể đi tiếp nếu không biết kết quả" — nghiêng về sync. Ví dụ: kiểm tra thẻ tín dụng có đủ tiền không trước khi xác nhận đơn hàng.
- Nếu câu trả lời là "Không, tôi chỉ cần báo cho hệ thống biết việc này đã xảy ra" — nghiêng về async. Ví dụ: gửi email xác nhận, cập nhật thống kê, đánh index cho tìm kiếm.
Tình huống thực tế
Ví dụ 1 — Tiki: đặt hàng và trừ kho
Hãy hình dung luồng đặt hàng của một sàn thương mại điện tử như Tiki vào dịp sale 12/12. Khi khách bấm "Đặt hàng", hệ thống phải làm nhiều việc: kiểm tra tồn kho, trừ tiền/tạo yêu cầu thanh toán, ghi nhận đơn, gửi email xác nhận, cộng điểm thành viên, cập nhật gợi ý sản phẩm, đẩy dữ liệu sang hệ thống phân tích.
Đội kiến trúc phân loại từng bước:
- Kiểm tra tồn kho và tạo đơn: sync. Khách đang đứng chờ, và nếu hết hàng thì không thể cho đặt. Bên gọi bắt buộc phải biết kết quả ngay. Nếu bước này thất bại, khách phải được báo ngay lập tức.
- Gửi email xác nhận, cộng điểm, cập nhật gợi ý, đẩy sang analytics: async. Không việc nào trong số này cần hoàn thành để khách thấy màn hình "Đặt hàng thành công". Chúng được phát đi dưới dạng event
OrderPlaced, và các dịch vụ khác tự lắng nghe rồi xử lý.
Bài học: Đừng để công việc "nền" (background work) làm chậm luồng mà người dùng đang chờ. Chỉ giữ sync những bước thực sự quyết định kết quả trả về cho khách.
Ví dụ 2 — MoMo: giao dịch chuyển tiền
Bây giờ đảo ngược tình huống. Ở một ví điện tử như MoMo, khi người dùng A chuyển 500.000đ cho người dùng B, luồng này KHÔNG thể async ở phần cốt lõi.
Tại sao? Vì đây là một tương tác mà bên gọi bắt buộc phải biết kết quả để đi tiếp: tiền có bị trừ khỏi tài khoản A không, có được cộng vào B không, có đủ số dư không. Nếu bạn làm async kiểu "gửi lệnh chuyển tiền vào queue rồi báo thành công ngay", bạn sẽ gặp thảm họa: người dùng thấy "chuyển thành công" nhưng thực tế lệnh còn nằm trong queue, và nếu lỗi thì tiền đã "biến mất" trong mắt người dùng. Trong lĩnh vực tài chính, đây là điều không thể chấp nhận.
Vậy nên phần lõi — trừ tiền A, cộng tiền B, ghi sổ giao dịch — được xử lý sync với consistency mạnh. Người dùng chờ, nhưng họ nhận được câu trả lời chắc chắn: thành công hay thất bại, có thể tin được.
Tuy nhiên, MoMo vẫn dùng async cho những phần ngoại vi: gửi thông báo push cho B, gửi SMS, ghi log phục vụ báo cáo chống gian lận, cập nhật số dư hiển thị ở các nơi khác. Những việc này chậm vài giây không sao.
Bài học: Với các giao dịch tiền bạc hoặc bất cứ thứ gì đòi hỏi tính đúng đắn tuyệt đối tại thời điểm phản hồi, sync ở phần lõi là lựa chọn an toàn. Đừng vì "async cho scalable" mà đánh đổi tính đúng đắn của tiền.
Ví dụ 3 — Startup logistics: cái bẫy "async cho mọi thứ"
Một câu chuyện cảnh báo. Một startup giao vận ở TP.HCM (gọi là công ty X) có đội kỹ thuật rất mê microservices và event-driven. Họ quyết định: "Mọi giao tiếp giữa dịch vụ đều async qua message broker để hệ thống scalable".
Kết quả sau 6 tháng:
- Khi tài xế nhận đơn, thông tin đơn được publish qua 7 dịch vụ khác nhau bằng chuỗi event nối tiếp. Khi khách hàng gọi tổng đài hỏi "đơn của tôi đâu rồi", nhân viên nhìn vào hệ thống mà không biết đơn đang ở trạng thái nào — vì mỗi dịch vụ có một bản trạng thái riêng, và chúng đang "eventual consistency" chưa đồng bộ.
- Debug một luồng nghiệp vụ trở thành cơn ác mộng: một request đơn giản phải lần theo 7 event qua 7 log khác nhau, không có trace xuyên suốt.
- Có lần một event bị lỗi handler, rơi vào dead-letter queue mà không ai để ý suốt 3 ngày, khiến 2.000 đơn không được gán tài xế.
Bài học: Async không miễn phí. Nó mua cho bạn khả năng chịu lỗi và scale, nhưng bán đi tính đơn giản, khả năng quan sát (observability) và tính nhất quán tức thời. Dùng async ở nơi không cần thiết là tự chuốc lấy độ phức tạp mà không nhận lại giá trị.
Hướng dẫn từng bước
Đây là quy trình tôi khuyên bạn áp dụng khi đứng trước một tương tác giữa hai thành phần:
Bước 1 — Xác định bên gọi có cần kết quả để đi tiếp không. Hỏi thẳng: "Nếu không có response này, bên gọi có thể hoàn thành công việc của nó không?". Nếu KHÔNG thể tiếp tục → sync là ứng viên mạnh. Nếu vẫn tiếp tục được → async là ứng viên mạnh.
Bước 2 — Xác định có người dùng đang chờ không. Nếu đây là luồng tương tác trực tiếp (người dùng bấm nút và chờ), hãy đếm tổng thời gian của các lời gọi sync trong luồng. Mọi bước sync đều cộng vào thời gian chờ. Đẩy những bước không cần thiết sang async để giảm latency cảm nhận.
Bước 3 — Đánh giá yêu cầu về tính nhất quán. Nghiệp vụ này có chấp nhận eventual consistency không? Với tiền bạc, tồn kho tại điểm bán, xác thực — thường cần consistency mạnh, nghiêng sync. Với thống kê, thông báo, đồng bộ dữ liệu phụ — eventual consistency là ổn, nghiêng async.
Bước 4 — Đánh giá đặc tính tải và khả năng chịu lỗi. Bên nhận có hay bị quá tải, hay chậm, hay cần bảo trì không? Nếu có, async với queue giúp cách ly và hấp thụ tải. Nếu bên gọi sync trực tiếp một dịch vụ hay chậm, bạn đang tạo ra điểm sập dây chuyền (cascading failure).
Bước 5 — Nếu chọn sync, bổ sung lá chắn. Sync không có nghĩa là gọi trần trụi. Luôn kèm timeout hợp lý (đừng để chờ vô hạn), retry có kiểm soát, và circuit breaker (Bài 41) để không kéo sập cả hệ thống khi một dịch vụ chết.
Bước 6 — Nếu chọn async, bổ sung cơ chế an toàn. Async đòi hỏi: idempotency ở phía nhận (xử lý trùng thông điệp mà không sai), dead-letter queue cho thông điệp lỗi, monitoring và alerting để biết khi có việc kẹt, và một chiến lược cho việc "làm sao người dùng/hệ thống biết việc đã xong".
Bước 7 — Ghi lại quyết định. Với những tương tác quan trọng, hãy viết ADR (Bài 4) ghi rõ: chọn sync hay async, lý do, và trade-off đã chấp nhận. Sáu tháng sau sẽ có người hỏi "tại sao chỗ này async?" — bạn muốn có câu trả lời.
Lỗi thường gặp & mẹo
Lỗi 1 — Coi async là "phiên bản nâng cấp" của sync. Rất nhiều kỹ sư nghĩ async luôn tốt hơn vì "không blocking, scalable hơn". Sai. Async là một trade-off, không phải nâng cấp. Nó thêm độ phức tạp vận hành đáng kể.
Lỗi 2 — "Async giả" (blocking trên async). Gửi thông điệp async rồi ngồi... chờ kết quả bằng cách poll liên tục. Bạn nhận đủ nhược điểm của async lẫn sync mà không có ưu điểm nào. Nếu cuối cùng vẫn phải chờ, hãy cân nhắc dùng sync cho gọn.
Lỗi 3 — Sync chuỗi dài (chained sync calls). Dịch vụ A gọi sync B, B gọi sync C, C gọi sync D. Latency cộng dồn, và xác suất lỗi nhân lên. Chỉ cần D chậm là cả chuỗi treo. Đây là công thức tạo cascading failure.
Lỗi 4 — Quên timeout khi gọi sync. Một lời gọi sync không timeout là quả bom hẹn giờ. Khi dịch vụ đích treo, thread của bạn treo theo, thread pool cạn kiệt, và dịch vụ của bạn cũng chết. Luôn đặt timeout.
Lỗi 5 — Bỏ quên xử lý lỗi trong async. "Fire-and-forget" dễ trở thành "fire-and-forget-forever". Không dead-letter queue, không alert — và thông điệp lỗi biến mất trong im lặng. Hãy thiết kế đường đi cho thông điệp thất bại ngay từ đầu.
Mẹo — Mô hình lai (hybrid) thường là câu trả lời đúng. Trong một luồng nghiệp vụ thực tế, bạn hiếm khi chọn thuần sync hoặc thuần async. Giữ sync cho phần lõi cần phản hồi ngay, đẩy phần nền sang async. Cả ba ví dụ trên đều là hybrid.
Mẹo — Nghĩ theo góc nhìn người dùng cuối. Câu hỏi "người dùng có chờ không, và họ chờ được bao lâu" thường quyết định ranh giới sync/async chính xác hơn mọi lý thuyết.
Bài tập thực hành
Hãy lấy một luồng nghiệp vụ bạn quen thuộc — ví dụ "người dùng đăng ký tài khoản mới trên một ứng dụng". Luồng này có thể gồm: tạo bản ghi user, hash mật khẩu và lưu, gửi email xác thực, tạo hồ sơ mặc định, gửi thông báo cho đội sales, ghi log analytics, cấp phát quà tặng chào mừng.
Yêu cầu:
- Liệt kê từng bước, và với mỗi bước hãy quyết định sync hay async, kèm lý do dựa trên 4 bước đầu của quy trình (bên gọi có cần kết quả không? người dùng có chờ không? yêu cầu consistency? đặc tính tải?).
- Ước lượng latency mà người dùng phải chờ nếu làm tất cả sync tuần tự, rồi so với phương án hybrid của bạn. Cho con số cụ thể (giả định thời gian mỗi bước).
- Với mỗi bước bạn chọn async, ghi rõ: nếu bước đó thất bại, hệ thống phát hiện bằng cách nào và xử lý ra sao?
- Viết một ADR ngắn (khoảng 1 trang) cho quyết định "gửi email xác thực nên là async", nêu rõ lý do và trade-off đã chấp nhận.
Tóm tắt
- Sync (request-response): bên gọi chờ kết quả. Ưu điểm là đơn giản, thất bại rõ ràng, dễ đảm bảo consistency mạnh. Nhược điểm là ràng buộc thời gian, latency cộng dồn, dễ tạo cascading failure.
- Async (fire-and-forget/event): bên gọi gửi rồi đi tiếp. Ưu điểm là tách rời thời gian, chịu lỗi và hấp thụ tải tốt. Nhược điểm là phức tạp hơn, thất bại âm thầm, eventual consistency.
- Câu hỏi quyết định cốt lõi: "Bên gọi có thực sự cần kết quả để đi tiếp không?" và "Người dùng có đang đứng chờ không?".
- Với tiền bạc, tồn kho, xác thực — thường nghiêng sync ở phần lõi. Với thông báo, thống kê, đồng bộ dữ liệu phụ — nghiêng async.
- Sync cần lá chắn: timeout, retry có kiểm soát, circuit breaker. Async cần an toàn: idempotency, dead-letter queue, monitoring.
- Thực tế, mô hình lai (hybrid) — sync cho lõi, async cho phần nền — thường là câu trả lời đúng nhất.
- Async không phải phiên bản nâng cấp của sync; nó là một trade-off. Chỉ dùng khi bạn thực sự cần và sẵn sàng trả giá về độ phức tạp vận hành.