Giải Phẫu Một Cơn Bão Lỗi Dây Chuyền (Cascading Outage)

Trong kiến trúc vi dịch vụ (microservices), sự phụ thuộc giữa các dịch vụ cấu thành một đồ thị có hướng phức tạp. Khi một dịch vụ tầng dưới (downstream) gặp sự cố quá tải tài nguyên, độ trễ mạng tăng vọt hoặc xuất hiện deadlock cơ sở dữ liệu, các dịch vụ gọi nó ở tầng trên (upstream) thường vô tình làm trầm trọng thêm tình hình. Các HTTP client cấu hình cơ chế thử lại (retry) ngây thơ sẽ liên tục dội hàng ngàn yêu cầu vào một hệ thống đang kiệt quệ. Hậu quả là các hàng đợi kết nối (connection pool) bị tắc nghẽn, các luồng xử lý bị đóng băng khi chờ phản hồi I/O, và sự cố cục bộ nhanh chóng biến thành thảm họa sập toàn bộ hệ thống.

Giải pháp kinh điển cho bài toán này là mô hình Circuit Breaker (Bộ ngắt mạch). Lấy cảm hứng từ aptomat chống quá tải trong mạng lưới điện dân dụng, phần mềm circuit breaker bao bọc các lời gọi dịch vụ từ xa và liên tục đo lường tỷ lệ lỗi. Khi tỷ lệ thất bại vượt qua một ngưỡng xác định, bộ ngắt mạch sẽ kích hoạt (trip), lập tức chặn đứng các yêu cầu đi ra ngoài mà không cần gửi dữ liệu qua mạng. Tuy nhiên, nếu chỉ đơn thuần trả về lỗi 503 Service Unavailable, trải nghiệm người dùng sẽ bị đứt gãy. Một kiến trúc vững chắc cần kết hợp máy trạng thái của circuit breaker với bộ nhớ đệm dự phòng (stale fallback cache), giúp ứng dụng duy trì khả năng thoái lui mềm dẻo (graceful degradation).

Máy Trạng Thái Hữu Hạn Của Circuit Breaker

Một bộ ngắt mạch tiêu chuẩn vận hành dựa trên ba trạng thái cốt lõi:

  • CLOSED (Đóng): Trạng thái bình thường. Mọi yêu cầu được chuyển tiếp thông suốt tới dịch vụ đích. Tỷ lệ thành công và thất bại được ghi nhận trong một cửa sổ thời gian trượt (sliding time window). Nếu tỷ lệ lỗi vượt ngưỡng cho phép, mạch sẽ chuyển sang trạng thái OPEN.
  • OPEN (Mở): Dịch vụ đích được xác định là đang gặp sự cố. Toàn bộ yêu cầu bị chặn ngay lập tức tại ranh giới tiến trình mà không tốn tài nguyên mạng. Mạch trả về lỗi ngay tức thì (fast-fail) hoặc định tuyến luồng dữ liệu sang bộ xử lý dự phòng (fallback). Một bộ đếm thời gian hồi phục (recovery timer) sẽ quyết định thời gian duy trì trạng thái này.
  • HALF-OPEN (Nửa mở): Khi thời gian chờ hồi phục kết thúc, mạch chuyển sang HALF-OPEN để cho phép một lượng nhỏ yêu cầu thăm dò (canary probes) đi qua. Nếu các yêu cầu thăm dò này thành công mỹ mãn, mạch tự động đặt lại về CLOSED. Ngược lại, nếu chỉ cần một yêu cầu thăm dò gặp lỗi máy chủ, mạch lập tức chuyển lại về OPEN với thời gian chờ được gia tăng.

Xây Dựng Circuit Breaker Chuẩn Sản Xuất Bằng TypeScript

Dưới đây là mã nguồn cài đặt Circuit Breaker hoàn chỉnh không phụ thuộc thư viện ngoài, tích hợp cơ chế gom nhóm dữ liệu theo cửa sổ trượt và bộ xử lý fallback dự phòng.

type BreakerState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';

interface CircuitBreakerOptions<T> {
  failureThresholdPercentage: number;
  minimumRequests: number;
  recoveryTimeoutMs: number;
  samplingWindowMs: number;
  fallbackFn?: (error: Error) => Promise<T>;
}

interface WindowBucket {
  timestamp: number;
  successes: number;
  failures: number;
}

export class CircuitBreaker<T> {
  private state: BreakerState = 'CLOSED';
  private buckets: WindowBucket[] = [];
  private lastStateChange: number = Date.now();
  private halfOpenSuccessfulProbes: number = 0;
  private readonly requiredProbes: number = 3;

  constructor(
    private readonly action: () => Promise<T>,
    private readonly options: CircuitBreakerOptions<T>
  ) {}

  public async execute(): Promise<T> {
    this.evaluateStateTransitions();

    if (this.state === 'OPEN') {
      return this.handleFallback(
        new Error('CircuitBreaker đang ở trạng thái OPEN: Chặn yêu cầu sớm để bảo vệ dịch vụ đích.')
      );
    }

    try {
      const result = await this.action();
      this.recordSuccess();
      return result;
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      this.recordFailure();
      return this.handleFallback(error);
    }
  }

  private evaluateStateTransitions(): void {
    const now = Date.now();

    if (this.state === 'OPEN') {
      // Kiểm tra xem thời gian phục hồi đã trôi qua chưa
      if (now - this.lastStateChange >= this.options.recoveryTimeoutMs) {
        this.transitionTo('HALF_OPEN');
      }
    }

    this.pruneExpiredBuckets(now);
  }

  private recordSuccess(): void {
    if (this.state === 'HALF_OPEN') {
      this.halfOpenSuccessfulProbes += 1;
      if (this.halfOpenSuccessfulProbes >= this.requiredProbes) {
        this.transitionTo('CLOSED');
      }
      return;
    }

    this.getCurrentBucket().successes += 1;
  }

  private recordFailure(): void {
    if (this.state === 'HALF_OPEN') {
      this.transitionTo('OPEN');
      return;
    }

    this.getCurrentBucket().failures += 1;
    this.checkThresholds();
  }

  private checkThresholds(): void {
    const { totalSuccesses, totalFailures } = this.calculateTotals();
    const totalRequests = totalSuccesses + totalFailures;

    if (totalRequests < this.options.minimumRequests) {
      return;
    }

    const failurePercentage = (totalFailures / totalRequests) * 100;
    if (failurePercentage >= this.options.failureThresholdPercentage) {
      this.transitionTo('OPEN');
    }
  }

  private transitionTo(newState: BreakerState): void {
    this.state = newState;
    this.lastStateChange = Date.now();
    this.halfOpenSuccessfulProbes = 0;
    if (newState === 'CLOSED') {
      this.buckets = [];
    }
  }

  private getCurrentBucket(): WindowBucket {
    const now = Date.now();
    const current = this.buckets[this.buckets.length - 1];

    if (!current || now - current.timestamp > 1000) {
      const newBucket: WindowBucket = { timestamp: now, successes: 0, failures: 0 };
      this.buckets.push(newBucket);
      return newBucket;
    }

    return current;
  }

  private pruneExpiredBuckets(now: number): void {
    const cutoff = now - this.options.samplingWindowMs;
    this.buckets = this.buckets.filter((b) => b.timestamp >= cutoff);
  }

  private calculateTotals(): { totalSuccesses: number; totalFailures: number } {
    return this.buckets.reduce(
      (acc, bucket) => ({
        totalSuccesses: acc.totalSuccesses + bucket.successes,
        totalFailures: acc.totalFailures + bucket.failures,
      }),
      { totalSuccesses: 0, totalFailures: 0 }
    );
  }

  private async handleFallback(error: Error): Promise<T> {
    if (this.options.fallbackFn) {
      return await this.options.fallbackFn(error);
    }
    throw error;
  }

  public getState(): BreakerState {
    return this.state;
  }
}

Kết Hợp Bộ Đệm Stale Fallback Để Thoái Lui Mềm Dẻo

Một circuit breaker nếu thiếu cơ chế fallback sẽ biến sự cố nội bộ thành lỗi sập hiển thị trực tiếp lên màn hình người dùng. Đối với các tác vụ đọc dữ liệu (read-heavy), việc phối hợp circuit breaker với fallback cache giúp dịch vụ đạt tính sẵn sàng cao (high availability). Khi circuit breaker bị ngắt, ứng dụng lấy dữ liệu đã lưu tạm thời trong Redis hoặc bộ nhớ tiến trình để phục vụ người dùng thay vì ném ra mã lỗi HTTP 500.

interface UserProfile {
  id: string;
  name: string;
  tier: string;
}

class ResilientUserService {
  private localCache = new Map<string, { data: UserProfile; cachedAt: number }>();

  async fetchUserProfile(userId: string): Promise<UserProfile> {
    const breaker = new CircuitBreaker<UserProfile>(
      async () => {
        // Cuộc gọi HTTP tới dịch vụ người dùng tầng dưới
        const response = await fetch(`https://api.internal.local/v1/users/${userId}`, {
          signal: AbortSignal.timeout(1200),
        });

        if (!response.ok) {
          throw new Error(`Dịch vụ trả về mã lỗi HTTP ${response.status}`);
        }

        const data: UserProfile = await response.json();
        // Cập nhật bộ đệm dự phòng khi gọi thành công
        this.localCache.set(userId, { data, cachedAt: Date.now() });
        return data;
      },
      {
        failureThresholdPercentage: 50,
        minimumRequests: 10,
        recoveryTimeoutMs: 15000,
        samplingWindowMs: 30000,
        fallbackFn: async (err: Error) => {
          const cached = this.localCache.get(userId);
          if (cached) {
            // Ghi log sự kiện thoái lui mềm dẻo để giám sát hệ thống
            return cached.data;
          }
          // Nếu không có dữ liệu cache dự phòng, ném ra lỗi có cấu trúc
          throw new Error(`Dịch vụ tạm thời gián đoạn. Nguyên nhân: ${err.message}`);
        },
      }
    );

    return breaker.execute();
  }
}

Hóa Giải Vấn Đề Thundering Herd Bằng Jitter Ngẫu Nhiên

Khi hàng chục node server chạy song song cùng chuyển từ trạng thái OPEN sang HALF_OPEN tại đúng mốc 15 giây, chúng sẽ đồng thời gửi một đợt yêu cầu thăm dò dồn dập vào dịch vụ đích vừa mới hồi phục. Đây chính là hiện tượng Thundering Herd (Đàn bò giẫm đạp).

Để ngăn chặn nguy cơ này, kỹ sư cần bổ sung độ lệch ngẫu nhiên (Decorrelated Jitter) vào chu kỳ thử nghiệm hồi phục:

function calculateJitteredBackoff(baseDelayMs: number, maxJitterMs: number): number {
  const jitter = Math.floor(Math.random() * maxJitterMs);
  return baseDelayMs + jitter;
}

// Khi khởi tạo circuit breaker trong thực tế:
const recoveryTimeout = calculateJitteredBackoff(15000, 5000); // Dao động từ 15s đến 20s

Các Cạm Bẫy Vận Hành Trong Môi Trường Production

Triển khai circuit breaker vào hệ thống lớn cần đặc biệt chú ý các sai lầm phổ biến sau:

  • Dùng chung một Breaker cho nhiều Endpoint: Bao bọc nhiều API độc lập vào chung một thực thể circuit breaker là một lỗi thiết kế nghiêm trọng. Nếu một tính năng phụ (như tải ảnh đại diện) bị lỗi, breaker dùng chung sẽ ngắt toàn bộ mạch, vô tình làm tê liệt cả luồng thanh toán hoặc xác thực. Mỗi endpoint hoặc dịch vụ phụ thuộc bắt buộc phải sở hữu breaker riêng biệt.
  • Cấp phát bộ nhớ không kiểm soát trong Metrics: Lưu trữ lịch sử lỗi không giới hạn trong RAM sẽ gây rò rỉ bộ nhớ (memory leak) khi lưu lượng truy cập lớn. Hãy luôn chia nhỏ metrics theo từng khối thời gian cố định (như từng giây) và liên tục dọn sạch các khối đã quá hạn khỏi cửa sổ trượt.
  • Không phân loại mã lỗi chính xác: Không phải mọi lỗi đều bắt nguồn từ dịch vụ đích. Lỗi mã HTTP 4xx (như 400 Bad Request hoặc 404 Not Found) xuất phát từ dữ liệu đầu vào của client, không phản ánh sự cố máy chủ. Việc tính gộp lỗi 4xx vào tỷ lệ lỗi của breaker sẽ khiến mạch bị ngắt oan uổng khi có bot quét lỗi hoặc lỗi logic từ phía client. Chỉ nên ghi nhận lỗi timeout kết nối, đứt socket và mã lỗi HTTP 5xx.

Điểm Cốt Lõi

  • Circuit Breaker bảo vệ tính ổn định của toàn hệ thống bằng cách ngắt kết nối sớm khi dịch vụ tầng dưới quá tải, triệt tiêu nguy cơ cạn kiệt connection pool và luồng xử lý.
  • Áp dụng cửa sổ thời gian trượt (sliding window) với các khối thời gian riêng biệt giúp tính toán tỷ lệ lỗi chính xác theo mặt thống kê, tránh phản ứng thái quá với các lỗi chập chờn nhất thời.
  • Kết hợp bộ ngắt mạch với bộ nhớ đệm dự phòng (stale fallback cache) mang lại khả năng thoái lui mềm dẻo, trả về dữ liệu khả dụng cho người dùng thay vì màn hình báo lỗi sập hệ thống.
  • Luôn đưa độ trễ ngẫu nhiên (jitter) vào thời gian chờ hồi phục trên các node phân tán để tránh hiện tượng thundering herd đánh sập dịch vụ vừa gượng dậy.