Cổng thanh toán bắt đầu bị timeout. Request nào trong app cũng ngồi chờ 10 giây, retry ba lần rồi cuối cùng vẫn lỗi. Worker PHP-FPM dồn cục lại để chờ một service đã chết từ đời nào, và giờ tới lượt app của mình sập theo. Retry kèm backoff thì xử lý được mấy lần chập chờn ngắn, còn khi service bên kia hư thiệt, cách tốt nhất là ngưng gọi nó một lúc. Circuit breaker sinh ra để làm đúng chuyện đó.
Ba trạng thái
Circuit breaker bọc ngoài các lời gọi tới một dependency và theo dõi kết quả. Lúc nào nó cũng nằm ở một trong ba trạng thái:
- Closed: chạy bình thường. Lời gọi đi qua, lỗi thì được đếm. Gọi thành công một lần là bộ đếm về 0.
- Open: số lỗi đã chạm ngưỡng. Mọi lời gọi bị trả lỗi liền, không tốn một vòng mạng nào, nên worker được giải phóng trong vài micro giây thay vì vài giây.
- Half-Open: đã hết thời gian chờ (cooldown). Cho đúng một lời gọi thử đi qua. Thành công thì đóng mạch lại, thất bại thì mở mạch thêm một lượt cooldown nữa.
Cài đặt
Đồng hồ (clock) được truyền vô từ ngoài, nhờ vậy test breaker không cần phải sleep.
final class CircuitOpenException extends RuntimeException {}
final class CircuitBreaker
{
private string $state = 'closed';
private int $failures = 0;
private ?float $openedAt = null;
public function __construct(
private int $failureThreshold = 5,
private float $cooldownSeconds = 30.0,
private ?Closure $clock = null,
) {
$this->clock ??= fn (): float => microtime(true);
}
public function call(callable $operation): mixed
{
if ($this->state === 'open') {
if (($this->clock)() - $this->openedAt < $this->cooldownSeconds) {
throw new CircuitOpenException('Circuit is open, failing fast.');
}
$this->state = 'half_open';
}
try {
$result = $operation();
} catch (Throwable $e) {
$this->recordFailure();
throw $e;
}
$this->reset();
return $result;
}
public function state(): string
{
return $this->state;
}
private function recordFailure(): void
{
$this->failures++;
if ($this->state === 'half_open' || $this->failures >= $this->failureThreshold) {
$this->state = 'open';
$this->openedAt = ($this->clock)();
}
}
private function reset(): void
{
$this->state = 'closed';
$this->failures = 0;
$this->openedAt = null;
}
}
import time
from typing import Callable
class CircuitOpenError(Exception):
pass
class CircuitBreaker:
def __init__(
self,
failure_threshold: int = 5,
cooldown_seconds: float = 30.0,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self.clock = clock
self.state = "closed"
self.failures = 0
self.opened_at: float | None = None
def call[T](self, operation: Callable[[], T]) -> T:
if self.state == "open":
if self.clock() - self.opened_at < self.cooldown_seconds:
raise CircuitOpenError("Circuit is open, failing fast.")
self.state = "half_open"
try:
result = operation()
except Exception:
self._record_failure()
raise
self._reset()
return result
def _record_failure(self) -> None:
self.failures += 1
if self.state == "half_open" or self.failures >= self.failure_threshold:
self.state = "open"
self.opened_at = self.clock()
def _reset(self) -> None:
self.state = "closed"
self.failures = 0
self.opened_at = None
Chia sẻ trạng thái trong Laravel
Class ở trên giữ trạng thái trong bộ nhớ. Với worker Python chạy lâu dài thì không sao, nhưng dưới PHP-FPM mỗi request lại tạo object mới tinh, nên breaker không bao giờ nhớ được lần lỗi nào hết. Trong Laravel, mình cất trạng thái vô cache dùng chung (Redis):
use Illuminate\Support\Facades\Cache;
final class CachedCircuitBreaker
{
public function __construct(
private string $name,
private int $failureThreshold = 5,
private int $cooldownSeconds = 30,
private int $failureWindowSeconds = 60,
) {}
public function call(callable $operation): mixed
{
$openKey = "circuit:{$this->name}:open";
$failKey = "circuit:{$this->name}:failures";
if (Cache::has($openKey)) {
throw new CircuitOpenException("Circuit {$this->name} is open.");
}
try {
$result = $operation();
} catch (Throwable $e) {
Cache::add($failKey, 0, $this->failureWindowSeconds);
if (Cache::increment($failKey) >= $this->failureThreshold) {
Cache::put($openKey, true, $this->cooldownSeconds);
Cache::forget($failKey);
}
throw $e;
}
Cache::forget($failKey);
return $result;
}
}
$breaker = new CachedCircuitBreaker('payment-gateway', failureThreshold: 5, cooldownSeconds: 30);
try {
$response = $breaker->call(
fn () => Http::timeout(3)->post($url, $payload)->throw()
);
} catch (CircuitOpenException) {
return response()->json([
'message' => 'Cổng thanh toán đang tạm gián đoạn, bạn thử lại sau ít phút nha.',
], 503);
}
Bản dùng cache này đơn giản hơn bản trong bộ nhớ: khi key open hết hạn thì toàn bộ traffic chạy lại luôn, chứ không phải chỉ một lời gọi thử. Đa số app vậy là đủ xài. Nếu service bên kia yếu quá, thêm một cái Cache::lock() ngắn để chỉ đúng một request được đi gọi thử.
Viết test cho breaker
Vì clock được truyền vô, mình chỉ cần đẩy thời gian tới chứ không phải ngồi sleep:
it('opens after the threshold and recovers after the cooldown', function () {
$now = 1000.0;
$breaker = new CircuitBreaker(3, 30, function () use (&$now) { return $now; });
$fail = fn () => throw new RuntimeException('down');
foreach (range(1, 3) as $_) {
try { $breaker->call($fail); } catch (RuntimeException) {}
}
expect($breaker->state())->toBe('open')
->and(fn () => $breaker->call(fn () => 'ok'))->toThrow(CircuitOpenException::class);
$now += 31;
expect($breaker->call(fn () => 'ok'))->toBe('ok')
->and($breaker->state())->toBe('closed');
});
Chọn ngưỡng sao cho hợp
- Ngưỡng lỗi: bắt đầu với 5 lỗi là phổ biến. Thấp quá thì mới chập chờn một cái là mạch nhảy; cao quá thì mình cứ đập hoài vô một service đã chết.
- Cooldown: khoảng 15 tới 60 giây. Đủ lâu để service bên kia kịp restart hay scale, mà cũng đủ ngắn để người dùng không phải đợi cả mấy phút sau khi nó sống lại.
- Timeout: breaker chỉ có tác dụng khi lời gọi lỗi nhanh. Lúc nào cũng phải set timeout cho client (
Http::timeout(3)); để timeout mặc định 30 giây là mỗi lời gọi giam worker 30 giây.
Mấy lỗi hay gặp
- Đếm sai loại lỗi.
422 Validation failedlà lỗi phía người gọi, đâu phải dấu hiệu service sập. Chỉ đếm timeout, lỗi kết nối và response5xxthôi. - Xài một breaker cho tất cả. Mỗi dependency một breaker riêng (
payment-gateway,sms-provider), để SMS có sập thì thanh toán vẫn chạy. - Không có phương án dự phòng. Phải tính trước khi mạch mở thì người dùng thấy gì: giá trị trong cache, một job xếp hàng để chạy lại sau, hay một thông báo
503rõ ràng. - Retry bên trong breaker mà không giới hạn. Để phần retry có backoff nằm bên trong operation và giữ số lần ít thôi, không thì breaker chỉ thấy một lần lỗi trong khi thực tế là năm lời gọi chậm rì.
Khi nào nên xài
Bọc circuit breaker quanh bất kỳ dependency từ xa nào có thể chậm hoặc sập mà lại nằm trên luồng xử lý chính: cổng thanh toán, nhà cung cấp SMS và email, API bên thứ ba, kể cả một microservice nội bộ. Kết hợp với exponential backoff: backoff lo mấy lần chập chờn ngắn, còn breaker lo những lúc sập thiệt.
Thử thách
Luyện tập ngay điều vừa học. Viết lời giải, mở gợi ý nếu bí.
Đề bài
Viết hàm call() cho class CircuitBreaker. Breaker bắt đầu ở trạng thái 'closed'. Ở 'closed', lời gọi nào cũng chạy operation; mỗi lần lỗi thì tăng bộ đếm, và khi bộ đếm chạm failureThreshold thì chuyển mạch sang 'open', đồng thời ghi lại thời điểm mở mạch. Gọi thành công thì đưa mạch về 'closed' và bộ đếm về 0. Ở 'open', lời gọi phải ném CircuitOpenException (Python: CircuitOpenError) ngay lập tức, không được chạy operation, cho tới khi đã qua đủ cooldownSeconds kể từ lúc mở mạch. Hết cooldown thì lời gọi kế tiếp chuyển mạch sang 'half_open' và chạy operation đúng một lần để thử: thành công thì đóng mạch, thất bại thì mở mạch lại thêm một lượt cooldown mới. Lúc nào cũng phải ném lại đúng exception gốc của operation. Lấy thời gian từ clock được truyền vô, không được dùng sleep.
Code khởi tạo
final class CircuitOpenException extends RuntimeException {}
final class CircuitBreaker
{
private string $state = 'closed';
private int $failures = 0;
private ?float $openedAt = null;
public function __construct(
private int $failureThreshold = 5,
private float $cooldownSeconds = 30.0,
private ?Closure $clock = null,
) {
$this->clock ??= fn (): float => microtime(true);
}
public function call(callable $operation): mixed
{
// TODO: if the circuit is 'open' and the cooldown has NOT elapsed,
// throw CircuitOpenException without calling $operation
// TODO: if the circuit is 'open' and the cooldown HAS elapsed,
// move to 'half_open' and let this one call through
// TODO: run $operation()
// - on success: reset to 'closed' (failures = 0) and return the result
// - on failure: count it, open the circuit when the threshold is reached
// (or immediately if the trial call in 'half_open' failed), then rethrow
}
public function state(): string
{
return $this->state;
}
}
import time
from typing import Callable
class CircuitOpenError(Exception):
pass
class CircuitBreaker:
def __init__(
self,
failure_threshold: int = 5,
cooldown_seconds: float = 30.0,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self.clock = clock
self.state = "closed"
self.failures = 0
self.opened_at: float | None = None
def call[T](self, operation: Callable[[], T]) -> T:
# TODO: if the circuit is "open" and the cooldown has NOT elapsed,
# raise CircuitOpenError without calling operation
# TODO: if the circuit is "open" and the cooldown HAS elapsed,
# move to "half_open" and let this one call through
# TODO: run operation()
# - on success: reset to "closed" (failures = 0) and return the result
# - on failure: count it, open the circuit when the threshold is reached
# (or immediately if the trial call in "half_open" failed), then re-raise
raise NotImplementedError
Lời giải của bạn
Gợi ý
Đã mở hết gợi ýKiểm tra trạng thái 'open' trước: so clock() - openedAt với cooldownSeconds rồi mới quyết định trả lỗi liền hay chuyển sang 'half_open'
Lời gọi thử ở 'half_open' mà lỗi thì mở mạch lại liền, không cần quan tâm bộ đếm lỗi đang là bao nhiêu
Tách riêng nhánh thành công và nhánh lỗi: thành công thì reset hết, lỗi thì ghi nhận rồi ném exception ra lại
final class CircuitOpenException extends RuntimeException {}
final class CircuitBreaker
{
private string $state = 'closed';
private int $failures = 0;
private ?float $openedAt = null;
public function __construct(
private int $failureThreshold = 5,
private float $cooldownSeconds = 30.0,
private ?Closure $clock = null,
) {
$this->clock ??= fn (): float => microtime(true);
}
public function call(callable $operation): mixed
{
if ($this->state === 'open') {
if (($this->clock)() - $this->openedAt < $this->cooldownSeconds) {
throw new CircuitOpenException('Circuit is open, failing fast.');
}
$this->state = 'half_open';
}
try {
$result = $operation();
} catch (Throwable $e) {
$this->recordFailure();
throw $e;
}
$this->reset();
return $result;
}
public function state(): string
{
return $this->state;
}
private function recordFailure(): void
{
$this->failures++;
if ($this->state === 'half_open' || $this->failures >= $this->failureThreshold) {
$this->state = 'open';
$this->openedAt = ($this->clock)();
}
}
private function reset(): void
{
$this->state = 'closed';
$this->failures = 0;
$this->openedAt = null;
}
}
import time
from typing import Callable
class CircuitOpenError(Exception):
pass
class CircuitBreaker:
def __init__(
self,
failure_threshold: int = 5,
cooldown_seconds: float = 30.0,
clock: Callable[[], float] = time.monotonic,
) -> None:
self.failure_threshold = failure_threshold
self.cooldown_seconds = cooldown_seconds
self.clock = clock
self.state = "closed"
self.failures = 0
self.opened_at: float | None = None
def call[T](self, operation: Callable[[], T]) -> T:
if self.state == "open":
if self.clock() - self.opened_at < self.cooldown_seconds:
raise CircuitOpenError("Circuit is open, failing fast.")
self.state = "half_open"
try:
result = operation()
except Exception:
self._record_failure()
raise
self._reset()
return result
def _record_failure(self) -> None:
self.failures += 1
if self.state == "half_open" or self.failures >= self.failure_threshold:
self.state = "open"
self.opened_at = self.clock()
def _reset(self) -> None:
self.state = "closed"
self.failures = 0
self.opened_at = None

