Lập trình · 21/09/2026

Circuit Breaker, Timeout và Retry: Ngăn lỗi dây chuyền trong hệ thống backend

Một dependency chậm không chỉ làm hỏng một request. Nếu mỗi request giữ thread, connection và tiếp tục retry, sự cố nhỏ có thể biến thành lỗi dây chuyền trên toàn hệ thống. Timeout, retry và circuit breaker giải quyết ba phần khác nhau của bài toán: giới hạn thời gian chờ, thử lại lỗi tạm thời và ngừng gọi một dependency đang có xác suất thất bại cao.

Circuit Breaker, Timeout và Retry: Ngăn lỗi dây chuyền trong hệ thống backend

Một dependency chậm không chỉ làm hỏng một request. Nếu mỗi request giữ thread, connection và tiếp tục retry, sự cố nhỏ có thể biến thành lỗi dây chuyền trên toàn hệ thống. Timeout, retry và circuit breaker giải quyết ba phần khác nhau của bài toán: giới hạn thời gian chờ, thử lại lỗi tạm thời và ngừng gọi một dependency đang có xác suất thất bại cao.

Bài viết này xây dựng một chính sách chịu lỗi từ ngân sách thời gian của request, thay vì sao chép vài con số cấu hình. Ví dụ dùng pseudocode gần với TypeScript, nhưng nguyên tắc áp dụng cho mọi HTTP client và nền tảng backend.

Ba cơ chế, ba trách nhiệm

Cơ chếCâu hỏi nó trả lờiNếu cấu hình sai
TimeoutMột lần gọi được chờ tối đa bao lâu?Giữ tài nguyên quá lâu hoặc hủy request hợp lệ quá sớm
RetryLỗi nào đáng thử lại, khi nào và bao nhiêu lần?Nhân tải lên dependency đang quá tải
Circuit breakerKhi nào nên fail fast thay vì tiếp tục gửi request?Mở quá nhạy hoặc tiếp tục đánh vào dịch vụ đang hỏng

Retry kỳ vọng lỗi sẽ sớm biến mất; circuit breaker giả định dependency cần thời gian hồi phục. Hai pattern có thể phối hợp, nhưng circuit mở phải chặn retry tiếp theo. Timeout vẫn cần thiết vì breaker chỉ biết một lần gọi thất bại sau khi client nhận được kết quả hoặc hết hạn.

1. Bắt đầu bằng deadline end-to-end

Giả sử endpoint của bạn có SLO phản hồi 800 ms. Không thể cấp cả 800 ms cho dịch vụ thanh toán: còn thời gian xác thực, truy vấn dữ liệu, serialize và trả response. Hãy phân bổ ngân sách rõ ràng, ví dụ 500 ms cho toàn bộ chuỗi gọi payment, trong đó mỗi attempt tối đa 180 ms.

request deadline: 800 ms
  local work:      180 ms
  payment budget:  500 ms (mọi attempt + backoff)
  safety margin:   120 ms

Phân biệt connect timeout với request/read timeout nếu thư viện hỗ trợ. Deadline tổng phải bao phủ cả DNS, kết nối, TLS, thời gian chờ trong connection pool, đọc body và các lần retry. Đồng thời truyền deadline còn lại xuống downstream để service sau không làm việc cho một request mà caller đã từ bỏ.

Chọn số liệu từ phân phối latency thực tế theo operation và region, không dựa vào trung bình. Theo dõi p95/p99, cold connection và deployment. Timeout quá sát p99 có thể tạo false timeout khi latency chỉ tăng nhẹ; timeout quá dài làm cạn pool trước khi hệ thống phát hiện sự cố.

2. Chỉ retry lỗi có khả năng tạm thời

Có thể cân nhắc retry lỗi kết nối, timeout, HTTP 429 và một số 5xx như 502/503/504. Không retry lỗi validation, authentication, authorization hay phần lớn 4xx. Với 429 hoặc 503, ưu tiên tôn trọng Retry-After nếu giá trị hợp lệ và vẫn nằm trong deadline.

An toàn dữ liệu quan trọng hơn tỷ lệ thành công. GET thường có thể thử lại; POST tạo đơn hoặc trừ tiền chỉ nên retry khi API hỗ trợ idempotency key hoặc nghiệp vụ có cơ chế khử trùng lặp. “Client chưa nhận response” không có nghĩa server chưa commit.

function retryable(error, operation) {
  if (!operation.isIdempotent) return false;
  if (error.isNetworkFailure || error.isTimeout) return true;
  return [429, 502, 503, 504].includes(error.status);
}

3. Exponential backoff cần jitter và giới hạn

Nếu hàng nghìn instance retry đúng tại 100 ms, 200 ms, 400 ms, chúng tạo các đợt tải đồng bộ. Jitter phân tán các lần thử. Một công thức “full jitter” đơn giản là chọn ngẫu nhiên từ 0 đến mức backoff đã được chặn trần:

cap = min(maxBackoff, base * 2 ** attempt)
delay = random(0, cap)

Luôn giới hạn số attempt, thời gian backoff và deadline tổng. Retry budget còn đặt trần tỷ lệ traffic retry trên toàn client hoặc service, chẳng hạn chỉ cho phép retry chiếm một phần nhỏ request ban đầu. Nhờ đó, lỗi diện rộng không tự động nhân tải lên hai hoặc ba lần.

Không xếp retry ở mọi tầng. Nếu API gateway, service A và service B đều thử ba lần, một request có thể gây ra hàng chục lệnh downstream. Chọn một tầng chịu trách nhiệm retry, thường là caller hiểu rõ operation và còn bao nhiêu deadline.

4. Circuit breaker là một state machine

  • Closed: request đi qua; breaker ghi nhận kết quả trong cửa sổ gần đây.
  • Open: request bị từ chối ngay hoặc dùng fallback, không gọi dependency.
  • Half-open: chỉ một lượng nhỏ probe được phép đi qua để kiểm tra khả năng hồi phục.

Đừng mở breaker chỉ vì một lỗi đơn lẻ. Cấu hình thường cần minimum sample size, cửa sổ trượt, tỷ lệ failure/slow call, khoảng open và số probe half-open. Mỗi endpoint hoặc nhóm lỗi độc lập nên có breaker riêng; gộp nhiều shard hay region vào một breaker có thể chặn cả tài nguyên vẫn khỏe.

if (breaker.isOpen()) throw new DependencyUnavailable();

try {
  const result = await callWithTimeout(remainingDeadline());
  breaker.recordSuccess();
  return result;
} catch (error) {
  if (countsTowardBreaker(error)) breaker.recordFailure();
  throw error;
}

Chỉ tính lỗi phản ánh sức khỏe dependency. HTTP 400 do payload sai không nên làm mở circuit. Ngược lại, timeout, connection failure và 5xx phù hợp hơn. Half-open phải giới hạn concurrency; nếu thả toàn bộ backlog cùng lúc, chính traffic phục hồi có thể đánh sập dependency lần nữa.

5. Ghép chính sách theo một ngân sách chung

Thứ tự wrapper cụ thể phụ thuộc thư viện, nhưng semantics nên rõ: kiểm tra deadline và breaker trước mỗi lần gọi; áp timeout cho từng attempt; chỉ retry lỗi cho phép; delay không được vượt thời gian còn lại; breaker mở thì dừng ngay.

async function resilientCall(ctx, operation) {
  let lastError;
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
    if (ctx.remainingMs() < MIN_ATTEMPT_MS) throw new DeadlineExceeded();
    if (!breaker.allowRequest()) throw new CircuitOpen();

    try {
      return await withTimeout(
        operation({ idempotencyKey: ctx.idempotencyKey }),
        Math.min(PER_ATTEMPT_MS, ctx.remainingMs())
      );
    } catch (error) {
      breaker.observe(error);
      lastError = error;
      if (!retryable(error, ctx) || attempt === MAX_ATTEMPTS - 1) throw error;

      const delay = retryAfter(error) ?? fullJitter(attempt);
      if (delay + MIN_ATTEMPT_MS >= ctx.remainingMs()) throw error;
      await sleep(delay);
    }
  }
  throw lastError;
}

Trong code thật, promise timeout không nhất thiết hủy socket hoặc công việc downstream. Hãy dùng cơ chế cancellation của client như AbortSignal và kiểm tra thư viện có giải phóng connection đúng cách. Fallback cũng phải hữu hạn: cache cũ, dữ liệu tối giản hoặc queue bất đồng bộ; không gọi vòng sang một dependency có cùng failure domain.

6. Quan sát đúng trước khi tinh chỉnh

Dashboard tối thiểu nên có số request gốc, số attempt, retry success, retry exhausted, timeout theo phase, trạng thái breaker, rejected call, half-open probe, latency từng attempt và deadline còn lại. Gắn dependency, operation, region và kết quả vào metric nhưng tránh label có cardinality cao như user ID.

Log một event có cấu trúc khi breaker đổi trạng thái, kèm nguyên nhân và cấu hình hiệu lực. Trace cần biểu diễn từng attempt dưới span con để phân biệt “request chậm do downstream” với “request chậm vì đã backoff hai lần”. Cảnh báo dựa trên ảnh hưởng người dùng và thời gian breaker mở kéo dài, không cảnh báo từng retry riêng lẻ.

7. Kiểm thử failure mode, không chỉ happy path

  • Dependency trả 503 liên tục: breaker mở, traffic downstream giảm và caller fail fast.
  • Latency vượt timeout: request bị hủy, pool không rò rỉ và deadline tổng được giữ.
  • 429 có Retry-After: client tôn trọng chỉ dẫn nhưng không vượt deadline.
  • Half-open: chỉ số probe đã định đi qua; thất bại đưa breaker về open.
  • POST mất response sau commit: idempotency key ngăn tạo bản ghi hoặc thanh toán trùng.
  • Nhiều instance lỗi cùng lúc: jitter phân tán retry và retry budget chặn khuếch đại tải.

Dùng fault injection trong staging và thử tải với tỷ lệ lỗi, latency, packet loss thay đổi. Cấu hình tốt là cấu hình đáp ứng SLO và hành vi dependency thực tế; nó cần được version hóa, review và điều chỉnh bằng telemetry.

Checklist trước production

  • Mỗi remote call có timeout và nằm trong deadline end-to-end.
  • Danh sách lỗi retryable được khai báo rõ; mutation có idempotency.
  • Backoff có jitter, số attempt hữu hạn và retry budget.
  • Breaker có minimum sample, sliding window và half-open concurrency.
  • Fallback không che giấu lỗi dữ liệu và không tạo vòng gọi mới.
  • Metric, structured log, trace và cảnh báo đã được kiểm chứng bằng fault injection.

Tài liệu tham khảo

Thảo luận

Bình luận 0

Đăng nhập để bình luận

Bạn cần có tài khoản để tham gia thảo luận và trả lời độc giả khác.

Đăng nhậpĐăng ký

Chưa có bình luận. Hãy là người đầu tiên chia sẻ ý kiến.