Trong hệ phân tán, một service phía sau sập không chỉ hỏng chính nó — nó kéo theo cả hệ thống. Khi service B chết, mọi lời gọi từ service A tới B chờ timeout (vài giây), goroutine của A dồn ứ, A cạn tài nguyên, rồi service C gọi A cũng sập theo. Đây là sập dây chuyền (cascading failure). Circuit breaker là cầu dao chống điều này: theo dõi lỗi, và khi service B rõ ràng đang chết, nó ngắt — chặn ngay các lời gọi thay vì để chúng chờ timeout vô ích. Bài này dựng một circuit breaker ba trạng thái chạy được và đo thật giá trị fail-fast.

Ba trạng thái

Circuit breaker là một máy trạng thái ba pha, mô phỏng đúng cầu dao điện:

  • Closed (đóng): bình thường, cho lời gọi qua, đếm lỗi liên tiếp.
  • Open (mở): service đang lỗi, chặn ngay mọi lời gọi (fail-fast), không gọi service.
  • HalfOpen (nửa mở): sau một thời gian nghỉ, cho vài lời gọi thăm dò xem service đã hồi phục chưa.
func (b *Breaker) Goi(fn func() error) error {
	if b.state == Open {
		if time.Since(b.moLuc) > b.nghi {
			b.state = HalfOpen // hết nghỉ -> thử thăm dò
		} else {
			return ErrMo // CHẶN NGAY, không tốn thời gian chờ timeout
		}
	}
	err := fn() // mới thực gọi
	// ... cập nhật trạng thái theo err ...
}

Ảnh chụp đoạn mã Go nền tối minh hoạ circuit breaker tự ngắt lời gọi tới service đang chết, ba trạng thái const Closed State iota bình thường cho lời gọi qua Open đang lỗi chặn ngay không gọi HalfOpen thử lại cho vài lời gọi thăm dò Closed N lỗi liên tiếp Open hết nghỉ HalfOpen HalfOpen thành công Closed HalfOpen lỗi Open, Open chặn nhanh không gọi service lỗi func b con trỏ Breaker Goi fn func error error nếu b state bằng Open nếu time Since b moLuc lớn hơn b nghi b state bằng HalfOpen hết nghỉ thăm dò else return ErrMo chặn ngay không tốn thời gian chờ timeout err bằng fn mới thực gọi, đếm lỗi mở khi vượt ngưỡng đóng khi thành công nếu err khác nil b loiLienTiep cộng cộng nếu b state bằng HalfOpen hoặc b loiLienTiep lớn hơn bằng b nguong b state bằng Open b moLuc bằng time Now mở mở lại return err b loiLienTiep bằng 0 b state bằng Closed thành công reset return nil, vì sao cần service phía sau chết mọi lời gọi chờ timeout chậm dồn tải breaker mở chặn ngay caller không kẹt service được nghỉ tránh lỗi lan truyền cascading failure qua cả hệ thống tự thử lại HalfOpen khi service có thể đã hồi phục

Hình 1: Ba trạng thái Closed/Open/HalfOpen. Ở Open, Goi trả lỗi ngay không gọi service; hết thời gian nghỉ chuyển sang HalfOpen. Đếm lỗi liên tiếp: đủ ngưỡng thì mở; thành công thì reset về Closed.

Đo thật: chuyển trạng thái

Chạy thật (Go 1.23) với ngưỡng 3 lỗi, nghỉ 300ms:

--- Giai đoạn 1: service lỗi liên tục ---
  lần 1: state=CLOSED    err=service chết   (đếm lỗi)
  lần 2: state=CLOSED    err=service chết
  lần 3: state=OPEN      err=service chết   (đủ 3 lỗi -> MỞ)
  lần 4: state=OPEN      err=circuit breaker ĐANG MỞ (chặn nhanh)
  lần 5: state=OPEN      err=circuit breaker ĐANG MỞ (chặn nhanh)
--- Giai đoạn 2: chờ qua nghỉ, thử lại ---
  sau nghỉ, gọi thành công: state=CLOSED (hồi phục)

Hai lỗi đầu vẫn CLOSED (đang đếm). Lỗi thứ 3 đạt ngưỡng → OPEN. Từ lần 4, breaker không gọi service nữa mà trả lỗi "chặn nhanh" luôn. Sau 300ms nghỉ, một lời gọi thành công đưa breaker về CLOSED — tự hồi phục.

Đo thật: fail-fast cứu hệ thống

Giá trị thật của circuit breaker lộ ra khi service chết chậm — mỗi lời gọi phải chờ timeout mới biết lỗi. Benchmark với service lỗi sau 50ms:

Ảnh chụp bảng kết quả đo thật nền tối 3 lỗi liên tiếp OPEN chặn nhanh hết nghỉ hồi phục fail-fast nhanh hơn 500.000x, go run cộng go test bench Go 1.23 arm64 10 core, chuyển trạng thái chạy thật ngưỡng 3 lỗi nghỉ 300ms giai đoạn 1 service lỗi liên tục lần 1 state CLOSED err service chết đếm lỗi lần 2 state CLOSED err service chết lần 3 state OPEN err service chết đủ 3 lỗi mở lần 4 state OPEN err circuit breaker đang mở chặn nhanh lần 5 state OPEN err circuit breaker đang mở chặn nhanh giai đoạn 2 chờ qua nghỉ thử lại sau nghỉ gọi thành công state CLOSED err nil hồi phục, benchmark chặn nhanh khi service lỗi chậm timeout 50ms cách gọi service đang chết không breaker chờ timeout 50ms lần 55.414.448 ns có breaker đã Open chặn nhanh 106,7 ns khi service chết mà mỗi lời gọi chờ 50ms mới biết lỗi breaker mở trả về ngay 107 ns thay vì kẹt 55ms nhanh hơn 500.000x đây là điều cứu hệ thống khỏi dồn tải khi service sau sập, cơ chế Closed cho qua đếm lỗi liên tiếp đủ ngưỡng Open Open chặn ngay fail-fast không gọi service sau nghỉ HalfOpen HalfOpen cho lời gọi thăm dò OK Closed lỗi Open lại thành công ở Closed reset bộ đếm lỗi, cốt lõi 3 trạng thái Closed qua Open chặn HalfOpen thăm dò fail-fast Open trả lỗi ngay không chờ timeout service chết tự hồi phục hết nghỉ thử lại OK thì đóng lỗi thì mở tiếp cứu hệ thống tránh caller dồn tải service sập cascading failure đánh đổi chỉnh ngưỡng nghỉ nên ghép timeout cộng retry

Hình 2: Chuyển trạng thái đúng — 3 lỗi → OPEN, lần 4-5 chặn nhanh, sau nghỉ hồi phục về CLOSED. Benchmark: không breaker chờ timeout 55.414.448 ns/lần so với breaker đã mở chặn nhanh 106,7 ns — nhanh hơn ~500.000 lần.

  • không breaker (chờ timeout 50ms mỗi lần): 55.414.448 ns/op (~55ms).
  • có breaker (đã Open, chặn nhanh): 106,7 ns/op.

Khi service chết, breaker mở trả lỗi trong ~107 ns thay vì kẹt 55ms chờ timeout — nhanh hơn ~500.000 lần. Con số khổng lồ này chính là điều cứu hệ thống: thay vì hàng nghìn goroutine kẹt chờ timeout của service chết (làm cạn tài nguyên và lan sập), breaker trả lỗi tức thì, giải phóng caller và cho service phía sau cơ hội nghỉ để hồi phục.

Đánh đổi cần cân nhắc

Chỉnh ngưỡng và thời gian nghỉ là nghệ thuật. Ngưỡng quá nhạy (mở sau 2 lỗi) gây "false trip" — chặn oan khi chỉ là trục trặc nhất thời. Ngưỡng quá cao (50 lỗi) thì breaker mở quá muộn, hệ thống đã dồn tải. Thời gian nghỉ quá ngắn dồn thăm dò vào service chưa hồi, quá dài thì phục vụ kém khi service đã khỏe. Thực tế nên dựa trên tỉ lệ lỗi trong cửa sổ (ví dụ >50% lỗi trong 10 giây) thay vì đếm liên tiếp thô như ví dụ này, và đo/điều chỉnh theo hành vi thật.

HalfOpen cần cẩn thận với số lời gọi thăm dò. Khi chuyển sang HalfOpen, nếu cho tất cả lời gọi đang chờ đi qua cùng lúc và service vẫn yếu, bạn lại dồn tải nó ngay. Breaker sản xuất giới hạn số lời gọi thăm dò (ví dụ chỉ 1-vài request), và chỉ đóng hẳn sau vài lần thành công liên tiếp — tránh mở/đóng dao động (flapping).

Circuit breaker là một phần của bộ công cụ resilience, không đứng một mình. Nó nên đi kèm timeout (để lời gọi có điểm dừng, mà breaker đếm được), retry với backoff (thử lại lỗi nhất thời — bài sau), và bulkhead (cô lập tài nguyên giữa các dependency). Dùng breaker mà không có timeout thì nó không biết khi nào "lỗi"; dùng riêng lẻ thì thiếu các lớp bảo vệ khác. Thư viện như sony/gobreaker cung cấp bản đã kiểm chứng cho production.

Ba ý mang về

  1. Circuit breaker là máy trạng thái ba pha: Closed (cho qua, đếm lỗi), Open (chặn nhanh khi service lỗi), HalfOpen (thăm dò sau nghỉ) — đo thật, 3 lỗi liên tiếp đưa breaker sang OPEN, các lời gọi sau bị chặn nhanh, và một lời gọi thành công sau thời gian nghỉ đưa về CLOSED (tự hồi phục).
  2. Giá trị cốt lõi là fail-fast: đo thật, khi service chết chậm (timeout 50ms), breaker đã mở trả lỗi trong 107 ns thay vì 55ms — nhanh hơn ~500.000 lần, cứu hệ thống khỏi dồn goroutine kẹt chờ timeout và sập dây chuyền.
  3. Cần chỉnh và ghép công cụ khác: ngưỡng/nghỉ phải cân (thực tế dùng tỉ lệ lỗi trong cửa sổ), HalfOpen giới hạn thăm dò tránh flapping, và circuit breaker phải đi cùng timeout + retry + bulkhead — dùng thư viện đã kiểm chứng cho production.

Phần sau ta xét lớp bảo vệ bổ trợ cho circuit breaker, xử lý lỗi nhất thời thông minh: Phần sau dựng retry với exponential backoff và jitter — thử lại lỗi tạm thời mà không gây "thundering herd" khi nhiều client cùng retry một lúc.