Mở đầu — vì sao bài này quan trọng
Hãy tưởng tượng bạn đang vận hành hệ thống của một sàn thương mại điện tử lớn ở Việt Nam vào đúng ngày sale 12/12. Service checkout gọi sang service payment, service payment lại gọi sang cổng thanh toán của ngân hàng. Bình thường mọi thứ chạy mượt trong 200ms. Nhưng đến 20h tối, cổng ngân hàng bắt đầu chậm — mỗi request mất 30 giây mới trả lời. Chỉ trong vòng 90 giây, toàn bộ checkout sập, rồi kéo theo cả trang chủ, cả giỏ hàng. Người dùng không mua được gì, doanh thu giờ vàng bốc hơi.
Điều đáng nói là bản thân service của bạn không có bug. Nó chết vì một dependency ở tận đâu đó chậm đi, và nó không biết cách tự bảo vệ mình. Đây chính xác là loại sự cố mà ba pattern trong bài học hôm nay — Timeout, Retry và Circuit Breaker — sinh ra để phòng chống. Chúng là bộ ba nền tảng của "resilience engineering" (kỹ thuật khả năng phục hồi): giúp một service tiếp tục hoạt động hợp lý ngay cả khi những thứ nó phụ thuộc đang gặp trục trặc.
Trong một hệ thống phân tán, sự thật phũ phàng là: dependency của bạn sẽ chậm và sẽ lỗi — đó là chuyện khi nào, không phải có hay không. Bài này dạy bạn cách thiết kế các cuộc gọi mạng để một lỗi cục bộ không biến thành thảm họa toàn hệ thống (cascading failure — lỗi lan truyền dây chuyền).
Khái niệm cốt lõi
Timeout — chốt chặn cơ bản nhất
Timeout là giới hạn thời gian bạn sẵn sàng chờ một cuộc gọi bên ngoài trước khi từ bỏ. Nghe đơn giản, nhưng đây là nguyên nhân số một của các sự cố kiểu "chết vì chờ".
Vấn đề cốt lõi: rất nhiều HTTP client, database driver, hay cache client mặc định có timeout bằng vô hạn hoặc rất lớn. Ví dụ, một số cấu hình HttpClient cũ trong Java, hay requests trong Python nếu bạn không truyền tham số timeout, sẽ chờ mãi mãi. Khi dependency treo, mỗi request giữ một connection và một thread. Connection pool cạn kiệt, thread pool cạn kiệt, và service của bạn ngừng nhận request mới — dù CPU vẫn nhàn rỗi. Đây gọi là connection leak và thread exhaustion.
Có hai loại timeout bạn phải phân biệt:
- Connection timeout: thời gian tối đa để thiết lập kết nối TCP. Thường ngắn, ví dụ 1–3 giây. Nếu không kết nối được trong thời gian này, khả năng cao server đã chết.
- Read/response timeout (socket timeout): thời gian tối đa để nhận về phản hồi sau khi đã kết nối. Cái này phụ thuộc vào việc dependency xử lý nhanh hay chậm.
Retry — thử lại khi lỗi tạm thời
Nhiều lỗi trong hệ thống phân tán là transient (nhất thời): một gói tin rớt, một pod vừa bị Kubernetes reschedule, một node đang restart. Với những lỗi này, thử lại sau vài chục mili-giây thường thành công. Đó là lý do có Retry.
Nhưng Retry là con dao hai lưỡi. Làm sai, nó biến một sự cố nhỏ thành một cuộc "tấn công tự gây ra" (self-inflicted DDoS). Bốn nguyên tắc bắt buộc:
- Chỉ retry lỗi có thể retry được. Lỗi 503 (Service Unavailable), timeout, connection refused thì nên retry. Lỗi 400 (Bad Request), 401, 404 thì retry vô ích — request của bạn sai, thử lại 100 lần vẫn sai.
- Exponential backoff (giãn cách theo cấp số nhân). Đừng retry ngay lập tức và liên tục. Chờ 100ms, rồi 200ms, rồi 400ms... Nếu tất cả client cùng retry đồng loạt, dependency vừa hồi phục đã bị đánh sập lại ngay — hiện tượng "thundering herd" (đàn trâu giẫm đạp).
- Jitter (nhiễu ngẫu nhiên). Thêm một chút ngẫu nhiên vào thời gian chờ (ví dụ backoff ± 50%) để các client không retry cùng một thời điểm chính xác. Đây là chi tiết nhỏ nhưng cực kỳ quan trọng.
- Idempotency (tính bất biến khi lặp). Chỉ retry những thao tác an toàn khi lặp lại. Đọc dữ liệu (GET) thì thoải mái. Nhưng "trừ tiền tài khoản" mà retry mù quáng có thể trừ hai lần. Với các thao tác ghi, bạn cần idempotency key để phía server nhận biết và bỏ qua request trùng.
Circuit Breaker — cầu dao tự ngắt
Circuit Breaker (bộ ngắt mạch) mượn ý tưởng từ cầu dao điện trong nhà: khi có sự cố, nó "nhảy" để ngắt mạch, bảo vệ cả hệ thống. Khi một dependency lỗi liên tục, việc tiếp tục gọi nó vừa vô ích vừa lãng phí tài nguyên. Circuit Breaker phát hiện điều đó và ngừng gọi tạm thời, trả về lỗi ngay lập tức (fail fast) thay vì để mỗi request chờ timeout.
Nó có ba trạng thái:
- Closed (đóng — mạch thông): trạng thái bình thường, request đi qua như thường. Breaker đếm tỷ lệ lỗi. Nếu tỷ lệ lỗi vượt ngưỡng (ví dụ >50% trong 10 request gần nhất), nó chuyển sang Open.
- Open (mở — mạch ngắt): mọi request bị chặn ngay lập tức, trả về lỗi hoặc fallback mà không hề gọi dependency. Sau một khoảng thời gian chờ (ví dụ 30 giây), breaker chuyển sang Half-Open để thăm dò.
- Half-Open (nửa mở): cho phép một số ít request "thử nghiệm" đi qua. Nếu chúng thành công, dependency đã hồi phục → quay về Closed. Nếu vẫn lỗi → quay lại Open và tiếp tục chờ.
Ba pattern phối hợp với nhau
Đừng nghĩ về chúng riêng lẻ. Chúng là các lớp phòng thủ xếp chồng: Timeout đảm bảo mỗi cuộc gọi không treo mãi → Retry xử lý lỗi nhất thời cho từng cuộc gọi → Circuit Breaker quan sát bức tranh tổng thể và ngắt khi dependency thực sự "ốm nặng". Thứ tự tư duy: timeout trước, rồi mới retry (trên nền timeout hợp lý), và circuit breaker bao ngoài tất cả.
Tình huống thực tế
Ví dụ 1: Sàn TMĐT và cổng thanh toán ngân hàng
Quay lại tình huống mở đầu. Đội SRE của sàn TMĐT phân tích postmortem và phát hiện: HTTP client gọi cổng ngân hàng có read timeout mặc định là 60 giây. Khi ngân hàng chậm, mỗi request giữ một thread trong tối đa 60 giây. Với 200 request/giây đổ vào, thread pool 400 luồng cạn sạch trong chưa đầy 3 giây.
Họ sửa theo ba bước. Thứ nhất, đặt read timeout xuống 3 giây (dựa trên p99 thực tế của cổng là ~800ms, cộng biên độ). Thứ hai, thêm Circuit Breaker: nếu >50% request tới cổng lỗi trong cửa sổ 20 request, breaker mở trong 30 giây. Trong thời gian đó, checkout trả về thông báo "Cổng thanh toán đang bận, vui lòng thử lại sau ít phút" ngay lập tức — người dùng vẫn duyệt được sản phẩm, giỏ hàng vẫn hoạt động. Thứ ba, cho phép người dùng chọn cổng thanh toán khác (ví Momo, VNPay) làm fallback.
Bài học: Timeout dài là kẻ giết người thầm lặng. Và fail fast + fallback tốt hơn nhiều so với để cả hệ thống chết cùng một dependency.
Ví dụ 2: Retry storm ở một startup fintech
Một startup fintech ở TP.HCM có service notification gọi sang một nhà cung cấp SMS bên thứ ba. Họ cấu hình retry 5 lần, không backoff, không jitter — cứ lỗi là bắn lại ngay. Một ngày, nhà cung cấp SMS bị nghẽn nhẹ và trả về 503 cho khoảng 10% request trong 2 phút.
Chuyện gì xảy ra? Mỗi request lỗi bị nhân thành 6 request (1 gốc + 5 retry). Tải lên nhà cung cấp SMS tăng đột biến 6 lần đúng lúc họ đang yếu. Kết quả: nhà cung cấp chuyển từ "chậm nhẹ" sang "sập hoàn toàn" trong 5 phút. Sự cố nhỏ bị chính cơ chế retry của họ khuếch đại thành sự cố lớn.
Đội kỹ thuật khắc phục bằng exponential backoff với jitter (100ms, 200ms, 400ms, mỗi mốc ± 50% ngẫu nhiên), giảm số lần retry xuống 2, và thêm Circuit Breaker để khi nhà cung cấp thực sự sập thì ngừng bắn hẳn thay vì cố retry. Họ cũng thêm khái niệm "retry budget" — tổng lượng retry không được vượt quá 10% tổng traffic, để retry không bao giờ trở thành nguồn tải chính.
Bài học: Retry không có backoff, jitter và giới hạn là một vũ khí tự hủy. Nó biến bạn thành thủ phạm tấn công chính dependency của mình.
Ví dụ 3: Netflix và Hystrix — nguồn gốc của phong trào
Netflix là nơi phổ biến hóa Circuit Breaker qua thư viện mã nguồn mở Hystrix. Với hàng trăm microservice gọi lẫn nhau, họ nhận ra rằng chỉ cần một service phụ (ví dụ service gợi ý phim) chậm là có thể kéo sập trang chủ. Triết lý của họ: mỗi cuộc gọi ra ngoài được bọc trong một "command" có timeout, có bulkhead (khoang cách ly thread riêng), và có circuit breaker. Khi service gợi ý sập, breaker mở và trang vẫn hiển thị — chỉ là thiếu phần gợi ý cá nhân hóa, thay vì màn hình trắng.
Ngày nay Hystrix đã ngừng phát triển, nhưng di sản của nó sống trong Resilience4j (Java), Polly (.NET), và các service mesh như Istio/Envoy (áp dụng ở tầng hạ tầng, không cần sửa code).
Bài học: Với hệ thống nhiều microservice, resilience không phải tính năng thêm — nó là điều kiện sống còn. Suy giảm có kiểm soát (graceful degradation) tốt hơn sập hoàn toàn.
Hướng dẫn từng bước
Đây là quy trình thực tế để áp dụng ba pattern cho một cuộc gọi ra ngoài trong service của bạn:
Bước 1 — Liệt kê mọi dependency bên ngoài. Lập danh sách tất cả các điểm service của bạn gọi ra: database, cache, mỗi API nội bộ, mỗi API bên thứ ba, message queue. Mỗi điểm này là một điểm có thể chậm/lỗi cần bảo vệ.
Bước 2 — Đo latency thực tế của từng dependency. Lấy p50, p95, p99 từ hệ thống monitoring. Đừng đoán. Bạn cần dữ liệu này để đặt timeout đúng.
Bước 3 — Đặt timeout dựa trên dữ liệu. Nguyên tắc phổ biến: timeout ≈ p99 + biên độ (ví dụ 1.5–2x p99). Đặt cả connection timeout (ngắn, 1–3s) lẫn read timeout riêng. Tránh timeout quá dài (vô dụng) và quá ngắn (gây lỗi giả).
Bước 4 — Cân nhắc "timeout budget" xuyên suốt chuỗi gọi. Nếu request từ user có deadline 5 giây, và nó đi qua 3 service, tổng timeout của các tầng phải cộng lại nhỏ hơn 5 giây. Đừng để service A chờ B tận 10 giây trong khi user đã bỏ đi từ lâu.
Bước 5 — Thêm retry cho các thao tác idempotent. Với GET và các thao tác đọc: bật retry với exponential backoff + jitter, tối đa 2–3 lần. Với thao tác ghi: chỉ retry khi có idempotency key. Chỉ retry các mã lỗi retry-able.
Bước 6 — Bọc bằng Circuit Breaker. Cấu hình ngưỡng lỗi (ví dụ 50%), cửa sổ đo (số request hoặc khoảng thời gian), thời gian chờ ở trạng thái Open (ví dụ 30s). Định nghĩa fallback: trả cache cũ, giá trị mặc định, hay thông báo thân thiện.
Bước 7 — Instrument và giám sát. Ghi metric cho: số lần timeout, số lần retry, trạng thái breaker (đóng/mở), tỷ lệ fallback. Tạo alert khi breaker mở. Nếu breaker mở mà bạn không biết, bạn đã mất một nửa giá trị của nó.
Bước 8 — Kiểm thử bằng cách chủ động gây lỗi. Dùng chaos engineering (bài 24) hoặc đơn giản là chèn độ trễ giả vào dependency ở môi trường staging, xác nhận service của bạn phản ứng đúng: fail fast, breaker mở, fallback hoạt động.
Lỗi thường gặp & mẹo
Lỗi 1: Không đặt timeout, hoặc để mặc định. Đây là lỗi phổ biến nhất và nguy hiểm nhất. Luôn kiểm tra timeout mặc định của mọi client library — rất nhiều cái mặc định là vô hạn.
Lỗi 2: Retry những thứ không nên retry. Retry lỗi 4xx (lỗi do request của bạn) là lãng phí. Retry thao tác ghi không idempotent có thể gây trùng lặp dữ liệu — trừ tiền hai lần, tạo đơn hàng hai lần.
Lỗi 3: Retry lồng nhau nhiều tầng. Nếu tầng A retry 3 lần, mỗi lần gọi tầng B cũng retry 3 lần, và B gọi C cũng retry 3 lần — một lỗi ở C biến thành 27 request. Đây là "retry amplification". Nguyên tắc: chỉ retry ở MỘT tầng, thường là tầng gần dependency nhất, hoặc tầng ngoài cùng — không phải cả hai.
Lỗi 4: Quên jitter. Backoff không jitter vẫn gây thundering herd vì mọi client retry đồng bộ. Jitter rẻ mà cứu mạng.
Lỗi 5: Circuit breaker không có fallback ý nghĩa. Breaker mở rồi ném exception thô ra ngoài thì chỉ chuyển lỗi chậm thành lỗi nhanh. Giá trị thật nằm ở fallback — cache cũ, giá trị mặc định, chức năng suy giảm.
Lỗi 6: Cấu hình cứng, không quan sát được. Đặt các tham số này thành cấu hình động (config) để chỉnh không cần deploy lại. Và luôn export metric.
Mẹo: Đừng tự viết ba pattern này từ đầu. Dùng thư viện đã kiểm chứng: Resilience4j (Java), Polly (.NET), Tenacity (Python), gobreaker (Go). Hoặc đẩy xuống service mesh (Istio/Linkerd) để áp dụng ở tầng hạ tầng mà không đụng code ứng dụng.
Mẹo: Kết hợp với bulkhead pattern (khoang cách ly). Tách thread pool/connection pool riêng cho mỗi dependency, để khi một dependency treo, nó chỉ làm cạn pool của riêng nó chứ không kéo cả service xuống.
Bài tập thực hành
- Audit dependency: Chọn một service bạn đang làm việc (hoặc một project cá nhân). Liệt kê tất cả các cuộc gọi ra ngoài và kiểm tra timeout hiện tại của từng cái. Ghi lại cái nào đang để mặc định vô hạn.
- Tính timeout: Với một API bạn gọi, tìm p99 latency của nó. Đề xuất một giá trị timeout hợp lý và giải thích lý do bằng con số.
- Thiết kế retry: Viết pseudo-code cho hàm retry với exponential backoff + jitter, tối đa 3 lần, chỉ retry mã lỗi 503 và timeout. Đảm bảo có giới hạn tổng thời gian.
- Vẽ sơ đồ trạng thái Circuit Breaker: Vẽ ba trạng thái Closed/Open/Half-Open, ghi rõ điều kiện chuyển đổi giữa chúng và các tham số bạn sẽ chọn (ngưỡng lỗi, thời gian chờ).
- Tình huống: Service
ordercủa bạn gọi serviceinventoryđể kiểm tra tồn kho. Nếuinventorysập, bạn sẽ thiết kế fallback thế nào để người dùng vẫn đặt được hàng mà không gây rủi ro bán quá số lượng? Viết 3–5 câu trình bày phương án và trade-off của nó.
Tóm tắt
Trong hệ thống phân tán, dependency chậm và lỗi là điều chắc chắn sẽ xảy ra. Ba pattern resilience giúp một lỗi cục bộ không lan thành thảm họa toàn hệ thống:
- Timeout đảm bảo không cuộc gọi nào treo mãi mãi — luôn đặt timeout (cả connection lẫn read) cho MỌI cuộc gọi ra ngoài, dựa trên p99 thực tế. Đây là chốt chặn quan trọng nhất chống cạn kiệt thread và connection.
- Retry xử lý lỗi nhất thời — nhưng bắt buộc có exponential backoff, jitter, giới hạn số lần, chỉ retry lỗi retry-able và thao tác idempotent. Retry làm sai sẽ khuếch đại sự cố.
- Circuit Breaker phát hiện dependency "ốm nặng" và fail fast, cho nó không gian hồi phục, kèm fallback để service suy giảm có kiểm soát thay vì sập hoàn toàn.