Menu
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Đang tải...

Bài 41 — Circuit Breaker — Resilience Pattern

Architecture Decision-Making Bài 41/60

Mở đầu — vì sao bài này quan trọng

Hãy tưởng tượng bạn đang là Kiến trúc sư trưởng của một sàn thương mại điện tử. Vào lúc 20h tối một ngày sale lớn, dịch vụ tính phí vận chuyển (shipping service) bỗng chậm đi — thay vì trả lời trong 50ms, nó mất 8 giây mỗi request. Điều tưởng chừng "chỉ là một service chậm" ấy lại kéo sập toàn bộ hệ thống trong vòng 4 phút: trang chủ trắng xóa, giỏ hàng không load, người dùng không thanh toán được. Doanh thu giờ vàng bốc hơi.

Chuyện gì đã xảy ra? Service checkout gọi sang shipping service. Vì shipping chậm, mỗi thread xử lý checkout phải đứng chờ 8 giây. Với lưu lượng cao, toàn bộ thread pool của checkout bị "kẹt" trong trạng thái chờ. Khi hết thread, checkout không nhận thêm được request nào nữa — kể cả những request chẳng liên quan gì đến vận chuyển. Rồi service gọi checkout cũng bị chờ, cũng cạn thread... Hiệu ứng domino lan ngược lên toàn hệ thống. Đây chính là cascading failure (sự cố lan truyền theo chuỗi).

Bài học hôm nay dạy bạn một trong những pattern quan trọng nhất để phòng chống thảm họa này: Circuit Breaker (cầu dao ngắt mạch). Đây không phải kiến thức "hay thì học", mà là điều kiện tiên quyết để một hệ thống phân tán tồn tại được trong thực tế. Là người ra quyết định kiến trúc, bạn cần hiểu bản chất của nó để biết khi nào cần, đặt ngưỡng ra sao, và tránh những cái bẫy khiến Circuit Breaker phản tác dụng.

Khái niệm cốt lõi

Vấn đề gốc rễ: tài nguyên là hữu hạn

Điểm mấu chốt cần khắc sâu: mọi service đều có tài nguyên hữu hạn — số thread, số connection, bộ nhớ. Khi một dependency (dịch vụ phụ thuộc) chậm hoặc chết, cái nguy hiểm không phải là "một tính năng bị lỗi", mà là tài nguyên của bạn bị giữ chặt trong lúc chờ.

Một request đang chờ downstream 30 giây nghĩa là một thread bị chiếm 30 giây. Nếu bạn có 200 thread và mỗi giây có 100 request mới gọi tới dependency đang chậm đó, chỉ trong 2 giây bạn đã cạn thread. Từ giây thứ 3 trở đi, service của bạn về cơ bản đã chết — dù bản thân code của bạn hoàn toàn khỏe mạnh. Bạn chết vì "lây" từ hàng xóm.

Circuit Breaker giải quyết đúng vấn đề này: thay vì cứ kiên nhẫn chờ một dependency đang hỏng, ta chủ động "ngắt mạch" và fail nhanh (fail fast), giải phóng tài nguyên để phục vụ những việc còn làm được.

Phép ẩn dụ cầu dao điện

Tên gọi Circuit Breaker mượn từ cầu dao trong nhà. Khi có chập điện, cầu dao tự ngắt để dòng điện quá tải không thiêu rụi cả căn nhà. Sau đó bạn có thể bật lại thử — nếu vẫn chập thì nó lại ngắt. Trong phần mềm, "chập điện" là dependency đang lỗi, và Circuit Breaker chính là cái cầu dao tự động đó.

Ba trạng thái của Circuit Breaker

Circuit Breaker là một máy trạng thái (state machine) với ba trạng thái. Hiểu rõ ba trạng thái này là hiểu 80% của pattern:

1. CLOSED (đóng — mạch thông, hoạt động bình thường) Đây là trạng thái mặc định. Mọi request được cho phép đi qua tới downstream. Circuit Breaker âm thầm đếm số lần thất bại. Chừng nào tỷ lệ lỗi còn dưới ngưỡng, nó vẫn ở CLOSED. "Đóng" ở đây hiểu theo nghĩa mạch điện đóng kín — dòng điện (request) chảy qua được.

2. OPEN (mở — mạch ngắt, chặn request) Khi tỷ lệ lỗi vượt ngưỡng cấu hình (ví dụ: 50% trong 10 request gần nhất), cầu dao "nhảy" sang OPEN. Ở trạng thái này, mọi request bị từ chối ngay lập tức mà không hề gọi xuống downstream — trả về lỗi tức thì hoặc chạy fallback. Đây chính là điểm cứu mạng: caller không còn phải chờ 8 giây nữa, nó nhận lỗi trong 1ms và giải phóng thread ngay. Circuit Breaker sẽ ở OPEN trong một khoảng thời gian định trước (ví dụ 30 giây), gọi là reset timeout.

3. HALF-OPEN (nửa mở — thăm dò) Sau khi hết thời gian OPEN, cầu dao chuyển sang HALF-OPEN. Nó cho một số ít request "thử" (trial request) đi qua để dò xem downstream đã hồi phục chưa. Nếu những request thử này thành công, cầu dao tin rằng dependency đã khỏe và chuyển về CLOSED. Nếu vẫn thất bại, nó lập tức quay lại OPEN và chờ thêm một chu kỳ nữa. Đây là cơ chế tự phục hồi (self-healing) — hệ thống tự dò và tự bật lại mà không cần con người can thiệp lúc 2h sáng.

Vòng đời điển hình

Luồng đi qua ba trạng thái thường như sau: CLOSED → (lỗi vượt ngưỡng) → OPEN → (hết reset timeout) → HALF-OPEN → (thử thành công) → CLOSED, hoặc → (thử thất bại) → OPEN. Đây là một vòng lặp tự động, liên tục thích ứng với sức khỏe của dependency.

Fallback — bạn đồng hành không thể thiếu

Circuit Breaker chỉ trả lời câu hỏi "có nên gọi không". Còn "khi không gọi thì trả về cái gì" là việc của fallback (phương án dự phòng). Khi mạch đang OPEN, thay vì ném lỗi thô cho người dùng, một fallback tốt sẽ trả về:

  • Dữ liệu cache cũ ("giá này cập nhật 5 phút trước").
  • Một giá trị mặc định hợp lý (phí ship mặc định 30.000đ khi shipping service chết).
  • Một phản hồi "degraded" — vẫn dùng được nhưng bớt tính năng.
Circuit Breaker cộng fallback chính là hiện thân của tư duy graceful degradation (suy giảm có kiểm soát): thà phục vụ được 90% giá trị còn hơn sập 100%.

Tình huống thực tế

Ví dụ 1: Sàn TMĐT và sự cố phí vận chuyển

Quay lại câu chuyện mở đầu. Giả sử một sàn TMĐT tại Việt Nam, dịch vụ checkout gọi sang shipping service của một đối tác giao hàng thứ ba (third-party) để tính phí vận chuyển real-time. Vào giờ cao điểm 20h ngày 11/11, API của đối tác quá tải, latency tăng vọt từ 60ms lên 8.000ms.

Diễn giải: Không có Circuit Breaker, mỗi request checkout giữ một thread trong 8 giây. Với ~500 request/giây và pool 300 thread, checkout cạn thread trong chưa đầy một giây. Từ đó, ngay cả người dùng chỉ muốn xem giỏ hàng cũng bị timeout. Cascading failure lan tới cả trang chủ vì trang chủ cũng có widget gọi checkout để hiện "số sản phẩm trong giỏ".

Sau sự cố, đội kỹ thuật bọc lời gọi shipping trong một Circuit Breaker với ngưỡng: mở mạch khi >50% trong 20 request gần nhất lỗi hoặc chậm quá 1 giây; reset timeout 30 giây. Kèm theo là fallback: khi mạch OPEN, hiển thị "Phí vận chuyển: tạm tính 30.000đ, cập nhật chính xác ở bước thanh toán". Lần sự cố tiếp theo, khi đối tác lại chậm, mạch nhảy OPEN sau 3 giây. Checkout không còn giữ thread chờ, người dùng vẫn mua hàng bình thường với phí tạm tính. Doanh thu giờ vàng được giữ nguyên.

Bài học: Circuit Breaker nên bọc quanh mọi lời gọi tới dependency bên ngoài mà bạn không kiểm soát được — đặc biệt là third-party. Và một fallback thông minh (phí tạm tính) còn giá trị hơn nhiều so với một trang lỗi 500.

Ví dụ 2: Netflix và Hystrix — người khai sinh phổ biến pattern này

Netflix là công ty đã đưa Circuit Breaker vào dòng chính (mainstream) với thư viện Hystrix. Kiến trúc của Netflix có hàng trăm microservice; một trang xem phim có thể kích hoạt cả chục lời gọi tới các service khác nhau (đề xuất phim, lịch sử xem, thông tin phim, avatar người dùng...).

Diễn giải: Với quy mô đó, việc một service phụ trong số hàng chục dependency bị chậm là chuyện xảy ra hằng ngày, gần như không thể tránh. Nếu không cô lập, một service đề xuất phim chậm có thể làm cả trang xem phim không load được. Netflix áp dụng Circuit Breaker cho từng dependency, kết hợp với timeout chặt và cô lập thread. Triết lý của họ: "Failure is inevitable" — sự cố là tất yếu, nên phải thiết kế để một phần hỏng không kéo sập toàn bộ. Khi service đề xuất chết, trang phim vẫn hiện, chỉ mất phần "Gợi ý cho bạn" — người dùng thường còn chẳng nhận ra.

Bài học: Ở quy mô microservice, Circuit Breaker không phải tính năng cao cấp mà là điều kiện sống còn. Càng nhiều dependency, xác suất "luôn có ít nhất một cái đang hỏng" càng tiến gần 100%. Ngày nay Hystrix đã ngừng phát triển, các lựa chọn hiện đại là Resilience4j (Java), Polly (.NET), hoặc lớp Circuit Breaker tích hợp sẵn trong service mesh như Istio/Envoy.

Ví dụ 3: Ví điện tử và bài học "half-open đặt sai"

Một ví điện tử ở Đông Nam Á gọi tới cổng thanh toán ngân hàng. Họ đã có Circuit Breaker, nhưng cấu hình sai: reset timeout chỉ 2 giây và HALF-OPEN cho phép tới 100 request thử cùng lúc.

Diễn giải: Khi cổng ngân hàng gặp sự cố kéo dài 10 phút, cứ mỗi 2 giây mạch lại sang HALF-OPEN và đẩy 100 request thử xuống một hệ thống đang thoi thóp. 100 request đó thất bại, mạch lại OPEN, 2 giây sau lại thử 100 request nữa. Vô tình họ tạo ra một dạng "tấn công" nhẹ, làm cổng ngân hàng càng khó phục hồi hơn — và log của họ ngập lỗi khiến việc chẩn đoán rối tung.

Đội kỹ thuật sửa lại: reset timeout tăng lên 30 giây, HALF-OPEN chỉ cho phép 1 request thử duy nhất, và thêm exponential backoff (giãn dần thời gian chờ mỗi lần thử lại thất bại: 30s → 60s → 120s). Sau đó, khi downstream hồi phục, hệ thống dò lại một cách nhẹ nhàng và tự chuyển về CLOSED mà không gây thêm áp lực.

Bài học: Half-open phải "rón rén" — chỉ thử với số lượng request tối thiểu. Và đừng để reset timeout quá ngắn: bạn đang cho downstream thời gian để thở, nên hãy hào phóng, kết hợp backoff để không dồn ép hệ thống đang ốm.

Hướng dẫn từng bước

Khi bạn cần đưa Circuit Breaker vào hệ thống, hãy theo trình tự sau:

Bước 1 — Xác định các "ranh giới nguy hiểm". Liệt kê mọi điểm code gọi ra ngoài process của bạn: gọi microservice khác, gọi API third-party, gọi database, gọi cache từ xa. Đây là những nơi cần cân nhắc Circuit Breaker. Ưu tiên cao nhất cho các dependency bạn không kiểm soát và các đường đi nằm trên luồng chính của người dùng.

Bước 2 — Đặt timeout trước tiên. Circuit Breaker vô dụng nếu không có timeout. Nếu bạn cho phép request chờ vô hạn, mạch sẽ không bao giờ "thấy" được lỗi kịp thời. Hãy đặt timeout dựa trên P99 latency bình thường của dependency (ví dụ P99 là 200ms thì timeout 1 giây là hợp lý). Timeout là tuyến phòng thủ đầu, Circuit Breaker là tuyến thứ hai.

Bước 3 — Chọn ngưỡng mở mạch. Quyết định điều kiện chuyển sang OPEN. Thường dùng tỷ lệ lỗi (ví dụ >50%) tính trên một cửa sổ (window) đủ lớn để tránh nhiễu — ví dụ tối thiểu 20 request. Đừng dùng "5 lỗi liên tiếp" cho service lưu lượng thấp vì dễ báo động giả.

Bước 4 — Cấu hình reset timeout và half-open. Đặt thời gian ở OPEN (ví dụ 30s), số request thử ở HALF-OPEN (bắt đầu với 1), và cân nhắc exponential backoff cho các lần thử thất bại liên tiếp.

Bước 5 — Thiết kế fallback. Với mỗi Circuit Breaker, trả lời: "Khi mạch OPEN, người dùng thấy gì?" Cache cũ, giá trị mặc định, hay phiên bản rút gọn của tính năng. Fallback tồi (ném exception) làm hỏng nửa giá trị của pattern.

Bước 6 — Thêm quan sát (observability). Đẩy metric về trạng thái mạch (CLOSED/OPEN/HALF-OPEN), số lần mở mạch, tỷ lệ fallback lên dashboard và cấu hình cảnh báo khi mạch OPEN. Một Circuit Breaker mở mà không ai biết cũng nguy hiểm gần như không có nó.

Bước 7 — Kiểm thử bằng cách chủ động gây lỗi. Dùng chaos engineering: cố tình làm dependency chậm/chết trên môi trường staging và xác nhận mạch mở đúng, fallback chạy đúng, và hệ thống tự phục hồi khi dependency khỏe lại.

Lỗi thường gặp & mẹo

Lỗi 1 — Có Circuit Breaker nhưng quên timeout. Đây là lỗi kinh điển. Nếu lời gọi downstream không có timeout, thread vẫn treo vô hạn và mạch không kịp phản ứng. Mẹo: luôn cặp đôi timeout + Circuit Breaker.

Lỗi 2 — Đặt một Circuit Breaker chung cho nhiều dependency. Nếu bạn dùng chung một cầu dao cho cả service A và service B, khi A hỏng làm mạch OPEN thì B cũng bị chặn oan. Mẹo: mỗi dependency một Circuit Breaker riêng, để lỗi được cô lập đúng nơi.

Lỗi 3 — Ngưỡng quá nhạy gây "flapping". Ngưỡng thấp làm mạch liên tục đóng-mở-đóng-mở, gây trải nghiệm chập chờn. Mẹo: dùng cửa sổ đủ lớn và tỷ lệ lỗi thay vì đếm tuyệt đối; cân nhắc số request tối thiểu trước khi cho phép mở mạch.

Lỗi 4 — Bọc cả những lỗi "nghiệp vụ". Nếu downstream trả về lỗi 404 (không tìm thấy) hay 400 (dữ liệu sai), đó là lỗi bình thường, không phải dấu hiệu downstream ốm. Nếu tính chúng vào tỷ lệ lỗi, mạch sẽ mở nhầm. Mẹo: chỉ đếm các lỗi hạ tầng (timeout, 5xx, connection refused) vào ngưỡng, bỏ qua lỗi nghiệp vụ 4xx.

Lỗi 5 — Fallback che giấu sự cố nghiêm trọng. Fallback quá "mượt" có thể khiến sự cố lớn trôi qua mà không ai biết trong nhiều ngày. Mẹo: fallback phải đi kèm metric và cảnh báo; degrade nhưng vẫn phải hét lên cho đội vận hành.

Lỗi 6 — Half-open dồn dập. Như ví dụ 3, cho quá nhiều request thử làm downstream không kịp hồi phục. Mẹo: half-open chỉ thử tối thiểu, kèm exponential backoff.

Mẹo tổng quát: Đừng tự viết Circuit Breaker từ đầu. Các thư viện đã trưởng thành (Resilience4j, Polly) hoặc service mesh (Istio/Envoy) đã xử lý các ca biên (edge case) mà bạn khó lường hết. Việc của bạn là cấu hình đúnghiểu bản chất.

Bài tập thực hành

  • Vẽ máy trạng thái. Không nhìn tài liệu, hãy vẽ lại sơ đồ ba trạng thái CLOSED / OPEN / HALF-OPEN và ghi rõ điều kiện chuyển đổi giữa chúng. Tự giải thích bằng lời tại sao cần trạng thái HALF-OPEN thay vì nhảy thẳng OPEN → CLOSED.
  • Kiểm toán hệ thống của bạn. Chọn một service bạn đang làm. Liệt kê tất cả lời gọi ra ngoài (downstream). Với mỗi cái, đánh dấu: (a) đã có timeout chưa, (b) đã có Circuit Breaker chưa, (c) fallback là gì. Bạn sẽ ngạc nhiên vì số "chỗ trống".
  • Thiết kế ngưỡng. Cho một service thanh toán có P99 latency 150ms và ~200 request/giây, hãy đề xuất: giá trị timeout, ngưỡng tỷ lệ lỗi, kích thước cửa sổ, reset timeout, và số request half-open. Viết lý do cho từng con số.
  • Thiết kế fallback. Với ba tính năng: (a) hiển thị đề xuất sản phẩm, (b) tính phí vận chuyển, (c) trừ tiền trong ví điện tử — hãy đề xuất fallback phù hợp cho mỗi cái khi mạch OPEN. Lưu ý: tính năng nào không nên có fallback tự động mà phải fail rõ ràng? (Gợi ý: việc liên quan tiền bạc.)
  • Tình huống thảo luận. Đội của bạn muốn đặt reset timeout 1 giây để "hồi phục nhanh". Hãy viết ra ba rủi ro của quyết định này và đề xuất phương án tốt hơn.

Tóm tắt

  • Vấn đề cốt lõi: dependency chậm/chết làm caller giữ chặt thread khi chờ, dẫn tới cạn tài nguyên và cascading failure — một service hỏng kéo sập cả hệ thống.
  • Circuit Breaker là cái "cầu dao" tự động: khi phát hiện downstream lỗi vượt ngưỡng, nó ngắt mạch để fail fast, giải phóng tài nguyên thay vì chờ vô ích.
  • Ba trạng thái: CLOSED (thông, đếm lỗi) → OPEN (chặn, chờ hồi phục) → HALF-OPEN (thử thăm dò) → về CLOSED nếu khỏe, hoặc lại OPEN nếu chưa.
  • Không thể thiếu ba người bạn: timeout (điều kiện cần), fallback (trả gì khi ngắt mạch — hiện thân của graceful degradation), và observability (metric + cảnh báo).
  • Là người ra quyết định kiến trúc: đặt Circuit Breaker ở mọi ranh giới gọi ra ngoài không kiểm soát được; mỗi dependency một cầu dao riêng; cấu hình ngưỡng dựa trên tỷ lệ lỗi và cửa sổ đủ lớn; half-open phải rón rén kèm backoff; chỉ đếm lỗi hạ tầng, bỏ qua lỗi nghiệp vụ; và dùng thư viện đã trưởng thành thay vì tự viết.
Circuit Breaker không làm cho dependency của bạn khỏe hơn — nó làm cho bạn sống sót khi dependency ốm. Trong thế giới phân tán nơi "sự cố là tất yếu", đó chính là ranh giới giữa một hệ thống chín chắn và một hệ thống chờ ngày sụp đổ.