Xây dựng Database Circuit Breaker với Python và Tenacity: Tự Động Retry, Backoff Thông Minh và Kết Nối Tự Phục Hồi

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Sự Cố Lúc 2 Giờ Sáng Đã Thay Đổi Cách Tôi Xử Lý Lỗi Database

Tất cả bắt đầu từ một đợt tăng đột biến traffic. Database PostgreSQL của chúng tôi chậm dần dưới tải, các query bắt đầu timeout, và ứng dụng — được cấu hình với retry logic hung hăng — liên tục dội vào database hàng nghìn lần kết nối lại mỗi giây. Thay vì phục hồi, database sụp hoàn toàn. Bốn mươi phút downtime, cả team vô cùng bực bội, và một buổi postmortem mà mọi thứ đều chỉ về cùng một nguyên nhân gốc rễ: không có circuit breaker.

Nếu bạn đã từng bị gọi lúc 2 giờ sáng vì chuyện như thế này, bạn biết chính xác cảm giác đó như thế nào. Hướng dẫn này sẽ đưa bạn qua từng bước xây dựng một database circuit breaker đúng nghĩa trong Python để bạn không phải học theo cách cay đắng như tôi.

Điều Gì Thực Sự Xảy Ra Khi Database Của Bạn Gặp Sự Cố

Đây là chuỗi thất bại dây chuyền thường đánh bất ngờ các developer mới:

  1. Database trở nên chậm do tải cao hoặc một query chạy lâu
  2. Các request từ ứng dụng bắt đầu timeout
  3. Retry logic kích hoạt — mỗi request thất bại thử lại 3 lần
  4. Bây giờ bạn có gấp 3 lần tải lên database chỉ từ các lần retry
  5. Database càng chậm hơn, càng nhiều timeout, càng nhiều retry
  6. Sụp đổ hoàn toàn

Đây được gọi là retry storm (đôi khi gọi là “thundering herd”). Retry logic của bạn, vốn được thiết kế để cải thiện độ tin cậy, lại trở thành thứ giết chết database. Nguyên nhân gốc rễ không phải bản thân các lần retry — mà là các retry đó không có nhận thức về sức khỏe tổng thể của hệ thống. Chúng tiếp tục chạy ngay cả khi rõ ràng database cần được nghỉ ngơi.

Ba Cách Tiếp Cận — Và Lý Do Hầu Hết Team Chọn Sai

Lựa Chọn 1: Retry Đơn Giản

import time
import psycopg2

def query_with_retry(sql, max_retries=3):
    for attempt in range(max_retries):
        try:
            conn = psycopg2.connect("postgresql://localhost/mydb")
            cursor = conn.cursor()
            cursor.execute(sql)
            return cursor.fetchall()
        except Exception:
            if attempt == max_retries - 1:
                raise
            time.sleep(1)

Đây là những gì hầu hết mọi người bắt đầu với. Vấn đề: trong trường hợp database quá tải, cách này còn khuếch đại thêm áp lực. 100 người dùng đồng thời × 3 lần retry mỗi người = 300 lần kết nối dội vào một database đang vật lộn. Nó chủ động làm mọi thứ tệ hơn.

Lựa Chọn 2: Exponential Backoff với Tenacity

Tenacity là một thư viện Python xử lý retry với backoff gọn gàng. Đây là một cải tiến thực sự:

pip install tenacity psycopg2-binary
from tenacity import retry, stop_after_attempt, wait_exponential
import psycopg2

@retry(
    stop=stop_after_attempt(5),
    wait=wait_exponential(multiplier=1, min=1, max=30)
)
def query_db(sql):
    conn = psycopg2.connect("postgresql://localhost/mydb")
    cursor = conn.cursor()
    cursor.execute(sql)
    return cursor.fetchall()

Exponential backoff có nghĩa là khoảng dừng giữa các lần retry tăng dần: 1s, 2s, 4s, 8s… Điều này cho database thời gian thở giữa các lần thử. Tốt hơn nhiều so với Lựa chọn 1. Nhưng vẫn không có cơ chế để dừng retry hoàn toàn khi database rõ ràng đã sập trong một thời gian dài. Bạn vẫn đang dội request vào một hệ thống bị hỏng, chỉ là chậm hơn thôi.

Lựa Chọn 3: Circuit Breaker Pattern (Công Cụ Đúng)

Khái niệm circuit breaker bắt nguồn từ kỹ thuật điện. Khi một mạch bị quá tải, cầu dao ngắt để bảo vệ hệ thống. Khi mọi thứ nguội xuống, bạn reset lại. Trong phần mềm, một circuit breaker có ba trạng thái:

  • Closed (Đóng) — hoạt động bình thường, request đi qua
  • Open (Mở) — circuit đã ngắt, request thất bại ngay lập tức mà không chạm đến database
  • Half-Open (Nửa Mở) — sau một khoảng thời gian hạ nhiệt, cho phép một request thử nghiệm để kiểm tra xem database đã phục hồi chưa

Điểm mấu chốt: khi circuit ở trạng thái Open, bạn hoàn toàn dừng dội vào database. Điều này cho nó không gian để tự phục hồi.

Xây Dựng: Circuit Breaker + Tenacity Kết Hợp

Đây là implementation tôi dùng trên production. Kết hợp backoff của Tenacity với một custom circuit breaker cho bạn điều tốt nhất của cả hai — retry thông minh với lỗi tạm thời, và dừng hoàn toàn khi database thực sự sập.

Bước 1: Lớp Circuit Breaker

import time
import threading
from enum import Enum

class CircuitState(Enum):
    CLOSED = "closed"
    OPEN = "open"
    HALF_OPEN = "half_open"

class DatabaseCircuitBreaker:
    def __init__(self, failure_threshold=5, recovery_timeout=30):
        self.failure_threshold = failure_threshold
        self.recovery_timeout = recovery_timeout
        self.failure_count = 0
        self.last_failure_time = None
        self.state = CircuitState.CLOSED
        self._lock = threading.Lock()

    def record_failure(self):
        with self._lock:
            self.failure_count += 1
            self.last_failure_time = time.time()
            if self.failure_count >= self.failure_threshold:
                self.state = CircuitState.OPEN
                print(f"[Circuit Breaker] OPEN — phát hiện {self.failure_count} lỗi")

    def record_success(self):
        with self._lock:
            self.failure_count = 0
            self.state = CircuitState.CLOSED

    def can_attempt(self):
        with self._lock:
            if self.state == CircuitState.CLOSED:
                return True
            if self.state == CircuitState.OPEN:
                elapsed = time.time() - self.last_failure_time
                if elapsed >= self.recovery_timeout:
                    self.state = CircuitState.HALF_OPEN
                    print("[Circuit Breaker] HALF-OPEN — đang kiểm tra khôi phục")
                    return True
                return False
            return True  # HALF_OPEN: cho phép một lần thăm dò

Bước 2: Bọc Các Lệnh Gọi Database

from tenacity import retry, stop_after_attempt, wait_exponential, RetryError
import psycopg2

db_circuit = DatabaseCircuitBreaker(failure_threshold=5, recovery_timeout=30)

@retry(
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=1, min=1, max=10)
)
def _execute_query(sql, params=None):
    conn = psycopg2.connect("postgresql://localhost/mydb")
    cursor = conn.cursor()
    cursor.execute(sql, params or ())
    result = cursor.fetchall()
    conn.close()
    return result

def safe_query(sql, params=None):
    if not db_circuit.can_attempt():
        raise Exception("Circuit OPEN — database không khả dụng, bỏ qua request")
    try:
        result = _execute_query(sql, params)
        db_circuit.record_success()
        return result
    except RetryError as e:
        db_circuit.record_failure()
        raise Exception(f"Query thất bại sau các lần retry: {e}")
    except Exception as e:
        db_circuit.record_failure()
        raise

Bước 3: Kiểm Tra Sức Khỏe Nền để Tự Động Phục Hồi

Trạng thái half-open xử lý các request thử nghiệm từ traffic đến một cách tự động. Với các dịch vụ nền có traffic thấp, hãy thêm một probe định kỳ để circuit không bị kẹt mãi ở trạng thái open khi chờ một request thật:

def db_health_check(circuit_breaker, interval=15):
    """Thread nền: thăm dò DB khi circuit đang open."""
    while True:
        time.sleep(interval)
        if circuit_breaker.state == CircuitState.OPEN:
            try:
                conn = psycopg2.connect(
                    "postgresql://localhost/mydb",
                    connect_timeout=3
                )
                conn.close()
                circuit_breaker.record_success()
                print("[Health Check] Database đã phục hồi — circuit CLOSED")
            except Exception:
                print("[Health Check] Database vẫn chưa khả dụng")

health_thread = threading.Thread(
    target=db_health_check,
    args=(db_circuit,),
    daemon=True
)
health_thread.start()

Bước 4: Kiểm Tra Cơ Bản

if __name__ == "__main__":
    for i in range(20):
        try:
            result = safe_query("SELECT NOW()")
            print(f"Query {i+1}: OK — {result}")
        except Exception as e:
            print(f"Query {i+1}: THẤT BẠI — {e}")
        time.sleep(0.5)

Điều Chỉnh Tham Số cho Hệ Thống của Bạn

Có hai con số quan trọng nhất:

  • failure_threshold: Số lần thất bại trước khi circuit mở. Đặt quá thấp (1-2) và bạn sẽ ngắt khi gặp những sự cố mạng thoáng qua. Quá cao và bạn vẫn đang dội vào một database đang vật lộn. Bắt đầu với 5 là phù hợp cho hầu hết các setup.
  • recovery_timeout: Số giây chờ trước khi cho phép một request thăm dò. 30 giây là mặc định an toàn cho Postgres. Với tải nặng khi database cần thêm thời gian để xử lý hết hàng đợi kết nối, tôi đã dùng 60-120 giây.

Một lưu ý thực tế từ workflow của chính tôi: khi thiết lập các kịch bản test cần dữ liệu mẫu, tôi thường cần chuyển đổi CSV fixtures sang JSON cho các import script. Tôi dùng toolcraft.app/vi/tools/data/csv-to-json — chạy hoàn toàn trong trình duyệt, nên dữ liệu không rời khỏi máy của bạn. Hữu ích khi làm việc với dữ liệu gần với production thật.

Các Chuyển Trạng Thái Tổng Quan

CLOSED    → (đạt ngưỡng failure_threshold) → OPEN
OPEN      → (hết thời gian recovery_timeout)  → HALF_OPEN
HALF_OPEN → (request thử nghiệm thành công)     → CLOSED
HALF_OPEN → (request thử nghiệm thất bại)        → OPEN

Những Gì Pattern Này Không Bao Gồm

Một circuit breaker bảo vệ ứng dụng của bạn khỏi việc làm quá tải database, nhưng nó đứng cạnh — không phải thay thế — các tầng độ tin cậy khác:

  • Connection pooling: Dùng PgBouncer hoặc pool tích hợp của SQLAlchemy để giới hạn tổng số kết nối đồng thời ở cấp hệ điều hành
  • Read replicas: Áp dụng circuit-break riêng cho primary và replicas — chúng có các chế độ lỗi khác nhau
  • Graceful degradation: Khi circuit đang open, hãy quyết định API của bạn trả về gì — dữ liệu được cache, lỗi 503 rõ ràng, hoặc một giá trị mặc định an toàn. Thất bại âm thầm còn tệ hơn thất bại ồn ào

Một khi cơ chế này được triển khai, các sự cố database mà trước đây từng leo thang thành downtime toàn bộ ứng dụng thường tự phục hồi trong vòng chưa đầy một phút. Database có không gian để thở, và ứng dụng của bạn tự kết nối lại khi nó đã ổn định. Còn 40 phút downtime tôi đề cập lúc đầu? Sau khi thêm circuit breaker vào toàn bộ tầng database, cùng kiểu lỗi đó chỉ còn là một sự gián đoạn 45 giây tự giải quyết trước khi bất kỳ ai thức dậy.

Share: