Trung bình DevOps

Circuit Breaker Pattern: Đừng gọi hoài vô một service đã sập

Chỉ retry thôi có khi còn đè chết service đang yếu. Coi circuit breaker trả lỗi nhanh, chừa thời gian cho service hồi phục và gọi thử an toàn ra sao, có code PHP, Python, Laravel.

23 Th10, 2026 8 phút Cập nhật 23 Th10, 2026 5 Khối code
Sơ đồ
graph LR A[Closed: cho gọi bình thường] -->|lỗi chạm ngưỡng| B[Open: trả lỗi liền] B -->|hết thời gian chờ| C[Half-Open: cho gọi thử 1 lần] C -->|gọi thử thành công| A C -->|gọi thử thất bại| B

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:

graph LR A[Closed: cho gọi bình thường] -->|lỗi chạm ngưỡng| B[Open: trả lỗi liền] B -->|hết thời gian chờ| C[Half-Open: cho gọi thử 1 lần] C -->|gọi thử thành công| A C -->|gọi thử thất bại| B
  • 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 failed là 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à response 5xx thô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 503 rõ 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 tương tác

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

php
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;
    }
}
python
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 ý
Gợi ý 1

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'

Gợi ý 2

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

Gợi ý 3

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

Lời giải · php
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;
    }
}
Lời giải · python
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