Retry là phản xạ đầu tiên khi một service gọi service khác bị lỗi. Nghe hợp lý: lỗi mạng thì thử lại, biết đâu lần sau được. Nhưng có một kiểu lỗi mà retry chỉ làm mọi thứ tệ hơn — khi service kia đã chết hẳn.
Mình từng ngồi trực một buổi tối mà chỉ một service nhỏ chết, rồi cả hệ thống đứng hình theo. Không phải vì service đó quan trọng, mà vì mọi service khác cứ kiên nhẫn gọi nó, kiên nhẫn chờ, kiên nhẫn giữ connection. Đến lúc hết connection pool thì mấy service "khoẻ" cũng sập nốt. Domino. Circuit breaker sinh ra để chặn đúng cái domino đó.
Retry không xấu, retry mù mới xấu
Nếu service B chết, mỗi request từ A sang B sẽ mất đúng bằng timeout của B. Timeout 30 giây, traffic 100 request/giây, nghĩa là A giữ 3000 request đang chờ cùng lúc — mỗi request là một connection, một slot trong pool hoặc một promise nằm trong RAM.
A không chết vì B chết. A chết vì chính sự kiên nhẫn của nó.
Circuit breaker đảo ngược logic: thay vì cố gắng đến cùng, nó học rằng B đang hỏng và từ chối gọi luôn. Fail ngay lập tức — 0ms thay vì 30 giây. Nghe ngược đời nhưng đây mới là cách giữ phần còn lại của hệ thống sống.
Ba trạng thái của mạch
- Closed — bình thường, cho request đi qua, đếm số lần fail.
- Open — vượt ngưỡng fail, chặn hết, trả lỗi ngay mà không gọi mạng.
- Half-open — sau một khoảng chờ, hé cửa cho vài request thử. Thành công thì về Closed, fail thì quay lại Open và chờ tiếp.
Điểm hay là half-open biến breaker thành cơ chế tự phục hồi. Không cần người trực bấm nút, service sống lại thì traffic tự chảy về.
Viết breaker bằng TypeScript
Bản tối giản nhưng đủ dùng thật:
type State = 'closed' | 'open' | 'half-open';
interface Options {
failureThreshold: number; // fail liên tiếp để mở mạch
resetTimeoutMs: number; // thời gian chờ trước khi thử lại
halfOpenMaxCalls: number; // số request cho thử ở half-open
}
export class CircuitBreaker {
private state: State = 'closed';
private failures = 0;
private openedAt = 0;
private probes = 0;
constructor(private readonly name: string, private readonly opts: Options) {}
async exec<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'open') {
if (Date.now() - this.openedAt < this.opts.resetTimeoutMs) {
throw new Error(`[${this.name}] mạch mở, từ chối gọi ngay`);
}
this.state = 'half-open';
this.probes = 0;
}
if (this.state === 'half-open' && this.probes >= this.opts.halfOpenMaxCalls) {
throw new Error(`[${this.name}] half-open đã đủ số lần thử`);
}
if (this.state === 'half-open') this.probes++;
try {
const result = await fn();
this.onSuccess();
return result;
} catch (err) {
this.onFailure();
throw err;
}
}
private onSuccess() {
this.state = 'closed';
this.failures = 0;
this.probes = 0;
}
private onFailure() {
this.failures++;
if (this.state === 'half-open' || this.failures >= this.opts.failureThreshold) {
this.state = 'open';
this.openedAt = Date.now();
}
}
}
Dùng ở tầng gọi service ngoài:
const breaker = new CircuitBreaker('payment-api', {
failureThreshold: 5,
resetTimeoutMs: 10_000,
halfOpenMaxCalls: 2,
});
const res = await breaker.exec(() =>
fetch(PAYMENT_URL, { signal: AbortSignal.timeout(2000) }),
);
Bốn cái bẫy mình từng dính
Thiếu timeout là vô dụng. Breaker chỉ đếm fail, mà request treo 60 giây thì chưa fail. Phải có AbortSignal.timeout() cho mọi call, không thì mạch không bao giờ mở đúng lúc.
Đếm fail theo cửa sổ trượt, đừng đếm cả đời. 5 fail trong 10 giây khác hoàn toàn 5 fail rải rác cả ngày. Bản trên dùng fail liên tiếp cho gọn — production thì nên dùng cửa sổ thời gian.
Không jitter thì mạch mở cùng lúc. Mười instance cùng chờ 10 giây rồi cùng thử lại một lượt — service vừa sống lại đã bị đập sập lần nữa. Cho resetTimeoutMs cộng thêm random 0–20% là xong.
Phân loại lỗi trước khi đếm. 404 hay 400 là lỗi phía mình, không phải service kia hỏng. Đếm mấy lỗi đó thì mạch mở oan, chặn luôn request đáng lẽ chạy tốt.
Kết
Circuit breaker không làm hệ thống nhanh hơn, nó chỉ giới hạn thiệt hại khi có thứ gì đó hỏng. Đổi lại, hệ thống có một hành vi rất đáng giá: hỏng cục bộ thay vì hỏng toàn bộ.
Trong Node.js, thư viện opossum làm sẵn phần này, còn ở runtime khác như .NET thì breaker thường nằm trong mesh hoặc framework. Viết tay bản trên vẫn nên — để hiểu mình đang đo cái gì mà chỉnh ngưỡng cho đúng.
📋 Phụ lục thuật ngữ
- Circuit breaker — cầu dao tự động: ngắt luồng gọi sang service đang hỏng để không kéo sập phần còn lại.
- Failure threshold — ngưỡng lỗi, vượt qua thì mạch chuyển từ Closed sang Open.
- Half-open — trạng thái thử nghiệm: hé một ít request để kiểm tra service đã sống lại chưa.
- Reset timeout — thời gian mạch giữ trạng thái Open trước khi hé cửa thử lại.
- Thundering herd — dồn dập: nhiều client cùng gọi ùa vào một lúc, đúng lúc service vừa hồi phục.
- Cascading failure — sập dây chuyền, một service hỏng lôi theo cả những service phụ thuộc nó.