Ngừng Retry Ngây Thơ: Dùng Exponential Backoff Và Jitter

Ngừng Retry Ngây Thơ: Dùng Exponential Backoff Và Jitter

Tìm hiểu lý do retry cố định gây sập hệ thống và cách kết hợp Exponential Backoff với Jitter để bảo vệ service khỏi các đợt sập chùm.

Khi một API request thất bại do nghẽn mạng tạm thời hoặc server bị quá tải, phản xạ đầu tiên của nhiều lập trình viên là viết một vòng lặp retry. Suy nghĩ này rất tự nhiên: thử lại thêm một hai lần thì tỉ lệ thành công thường rất cao. Tuy nhiên, nếu bạn chỉ bọc request trong một vòng lặp while đơn giản với thời gian chờ cố định, bạn có thể vô tình biến một sự cố nhỏ thành thảm họa sập toàn bộ hệ thống.

Trong các hệ thống phân tán (Distributed Systems), chiến lược retry ngây thơ là nguyên nhân hàng đầu gây ra hiện tượng Retry Storm (bão request thử lại) hoặc Thundering Herd Problem. Trong bài viết này, mình sẽ cùng các bạn phân tích tại sao retry cố định lại nguy hiểm và cách triển khai Exponential Backoff kết hợp với Jitter để ứng dụng backend hoạt động bền bỉ hơn.

Thảm họa từ những vòng lặp Retry cố định

Hãy tưởng tượng hệ thống microservice của bạn phụ thuộc vào một dịch vụ thanh toán của bên thứ ba. Giả sử dịch vụ này bất ngờ bị gián đoạn trong 500 miligiây, khiến 500 request từ người dùng đồng loạt báo lỗi tại mốc thời gian $T=0$.

Nếu code phía client hoặc service gọi API cài đặt cơ chế retry cố định—ví dụ: cứ sau 1 giây thì thử lại một lần, tối đa 3 lần—thì đúng vào mốc $T=1$ giây, cả 500 request bị lỗi sẽ đồng loạt gửi lại request lần thứ nhất. Đến mốc $T=2$ giây, 500 request lại tiếp tục nã vào server lần thứ hai.

Thay vì tạo điều kiện cho hệ thống thanh toán phục hồi sau sự cố ngắn, ứng dụng của bạn lại liên tục dội những làn sóng traffic khổng lồ vào server đối phương. Kết quả là sự cố nghẽn mạng ban đầu bị kéo dài, thậm chí làm sập luôn các dịch vụ liên quan khác.

Bước 1: Giảm tải bằng Exponential Backoff

Minh họa hiệu ứng sập chùm trong hệ thống phân tán

Định lý cốt lõi khi xử lý hệ thống quá tải là: server càng mệt, client càng phải lùi thời gian chờ lâu hơn để server kịp giải phóng hàng đợi. Exponential Backoff (lùi thời gian chờ theo cấp số nhân) giải quyết chính xác vấn đề này bằng cách nhân đôi khoảng thời gian chờ sau mỗi lần retry thất bại.

Công thức tính thời gian chờ Exponential Backoff:

Thời_gian_chờ = Thời_gian_gốc * (Hệ_số_nhân ^ Số_lần_thử)

Ví dụ với thời gian chờ ban đầu là 100ms và hệ số nhân bằng 2:

  • Lần retry 1: Chờ 100ms
  • Lần retry 2: Chờ 200ms
  • Lần retry 3: Chờ 400ms
  • Lần retry 4: Chờ 800ms

Exponential Backoff giúp giảm tần suất dội request rất nhanh khi lỗi tiếp diễn. Nhưng nếu dừng ở đây, bạn vẫn chưa giải quyết triệt để vấn đề: Nếu 500 request cùng thất bại ở mốc $T=0$, các mốc thời gian retry của chúng vẫn hoàn toàn trùng khớp với nhau (cùng dội vào server ở 100ms, 200ms, 400ms, 800ms).

Bước 2: Phá vỡ sự trùng lặp bằng Jitter

Để khắc phục hiện tượng các client retry đồng loạt theo nhịp, chúng ta cần đưa yếu tố ngẫu nhiên vào thời gian chờ. Yếu tố ngẫu nhiên này được gọi là Jitter.

Thay vì bắt tất cả client chờ đúng một khoảng thời gian cố định theo công thức, Jitter thêm vào độ lệch ngẫu nhiên để các client thử lại ở những thời điểm lệch nhau.

Có nhiều thuật toán Jitter khác nhau, nhưng Full Jitter là phương pháp phổ biến và hiệu quả nhất cho dịch vụ microservice:

Thời_gian_chờ_thực_tế = Random(0, Thời_gian_chờ_Exponential_Backoff)

Với Full Jitter, nếu ngưỡng thời gian chờ tính theo cấp số nhân là 400ms, client sẽ chọn ngẫu nhiên một khoảng thời gian chờ trong đoạn từ 0ms đến 400ms. Điều này giúp dải đều 500 request trải dài theo thời gian thay vì tập trung thành từng đợt sóng nhọn.

Triển khai thực tế với TypeScript

Dưới đây là đoạn code ví dụ minh họa cách viết hàm fetchWithRetry hoàn chỉnh, áp dụng Exponential Backoff và Full Jitter bằng TypeScript.

interface RetryOptions {
  maxRetries?: number;
  baseDelayMs?: number;
  maxDelayMs?: number;
}

async function fetchWithRetry(
  url: string,
  options: RequestInit = {},
  retryConfig: RetryOptions = {}
): Promise<Response> {
  const { maxRetries = 4, baseDelayMs = 200, maxDelayMs = 5000 } = retryConfig;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch(url, options);

      // Không retry nếu request thành công hoặc lỗi do phía client (4xx)
      if (response.ok || (response.status >= 400 && response.status < 500)) {
        return response;
      }

      throw new Error(`Server trả về mã HTTP ${response.status}`);
    } catch (error) {
      if (attempt === maxRetries) {
        throw error;
      }

      // Tính thời gian chờ theo cấp số nhân và chặn trần tối đa
      const exponentialDelay = Math.min(
        maxDelayMs,
        baseDelayMs * Math.pow(2, attempt)
      );

      // Áp dụng Full Jitter: Chọn ngẫu nhiên từ 0 đến exponentialDelay
      const jitteredDelay = Math.floor(Math.random() * exponentialDelay);

      console.warn(
        `Thử lại lần ${attempt + 1} thất bại. Chờ ${jitteredDelay}ms trước khi thử lại...`
      );

      await new Promise((resolve) => setTimeout(resolve, jitteredDelay));
    }
  }

  throw new Error("Đã vượt quá số lần retry cho phép");
}

Những nguyên tắc vàng cần nhớ khi làm Retry

Khi áp dụng logic retry vào dự án thực tế, anh em cần lưu ý những điểm quan trọng sau:

1. Chỉ retry các lỗi tạm thời (Transient Failures)

Tuyệt đối không retry các lỗi 4xx như 401 Unauthorized hay 404 Not Found. Gửi lại một request sai body hay thiếu token chỉ làm lãng phí tài nguyên mà không bao giờ thành công. Hãy tập trung retry các lỗi mạng tạm thời hoặc lỗi HTTP status code 5xx (như 502 Bad Gateway, 503 Service Unavailable, 504 Gateway Timeout).

2. Cẩn trọng với các thao tác không Idempotent

Việc retry các request an toàn (Idempotent) như HTTP GET, PUT hay DELETE thường không gây tác dụng phụ. Nhưng với HTTP POST (ví dụ: trừ tiền tài khoản, tạo đơn hàng), việc retry không kiểm soát có thể dẫn đến trùng lặp dữ liệu nếu request đầu tiên đã đến server nhưng bị timeout lượt về. Hãy đảm bảo API backend của bạn có hỗ trợ Idempotency Key trước khi tự động retry các tác vụ ghi.

3. Luôn đặt trần thời gian chờ tối đa

Thời gian chờ cấp số nhân tăng lên rất nhanh ($2^{10} = 1024$). Nếu không đặt giới hạn thời gian chờ tối đa (maxDelayMs), ứng dụng của bạn có thể phải treo vài tiếng đồng hồ cho một lượt thử lại. Hãy đặt trần hợp lý (khoảng 5s - 10s) và cài đặt thời gian chờ tổng (request timeout).

Tóm lại

Khả năng chịu lỗi và tự phục hồi (Resilience) là ranh giới giữa một đoạn code học viên và một hệ thống production chuẩn chỉnh. Việc thay thế vòng lặp retry ngây thơ bằng Exponential Backoff và Jitter giúp tránh tình trạng sập chùm, làm mịn lưu lượng truy cập và nâng cao độ ổn định cho backend. Lần tới khi viết API client, hãy nhớ cho dịch vụ phía sau một khoảng thở đủ rộng để tự hồi phục!

NT

viết bởi

Nguyên Tech

0

Phản hồi

Đang tải bình luận…

Lattice.

Một không gian để viết dài, đọc chậm, và trò chuyện thật — không thuật toán, không quảng cáo.

© 2026 · Lattice · Đà Nẵng (16°03′ N, 108°12′ E) · v0.1 · system + ink + indigo