Backoff 退避策略是什麼?Exponential Backoff 指數退避完整教學

2024/01/08

Backoff 退避策略是一種處理請求失敗與重試的核心技術。它的精髓在於:請求失敗後不立即重試,而是等待一段時間再試,且每次失敗後等待時間遞增,有效防止雪崩效應(Thundering Herd Problem),是所有生產環境必備的網路韌性策略。

什麼是 Backoff 退避策略?

想像一個情境:你打電話給客服,電話一直佔線。如果你每秒就重撥一次,不僅自己很累,還讓客服電話更難被接通。聰明的做法是:等 1 秒撥、再等 2 秒撥、再等 4 秒撥……讓線路有機會空出來。這就是 Backoff 的核心思想。

在軟體系統中,Backoff 退避策略指的是:當 API 請求或網路操作失敗後,不立即重試,而是等待一段時間後再重試,並且每次失敗後將等待時間逐漸增加。

為什麼不能立即重試?

立即重試(Immediate Retry)在大多數情況下是有害的:

  1. 伺服器已過載:請求失敗通常正是因為伺服器正在忙碌(CPU 過高、DB 連線耗盡、流量尖峰)。立即重試等於在已過載的伺服器上繼續加壓,讓它更難恢復。
  2. 雪崩效應(Thundering Herd):想像 1000 個客戶端同時遇到失敗,如果所有人都在第 1 秒重試,伺服器會在同一時間收到 1000 個重試請求,比原來的負載更高。
  3. 網路抖動(Network Jitter):短暫的網路波動通常在幾百毫秒內就會恢復,等一下再試通常就能成功,完全不需要立即重試。

Backoff 策略讓重試時間錯開,給伺服器喘息的空間,同時又保證不會永無止境地等待。


三種 Backoff 策略

1. Fixed Backoff(固定退避)

每次失敗後,等待固定的時間間隔再重試。

第 1 次失敗 → 等 2 秒 → 重試
第 2 次失敗 → 等 2 秒 → 重試
第 3 次失敗 → 等 2 秒 → 重試

優點:最簡單,行為可預測。 缺點:無法應對長時間的伺服器問題,重試頻率一直不變。

2. Linear Backoff(線性退避)

每次失敗後,等待時間線性增加:1 秒、2 秒、3 秒、4 秒……

第 1 次失敗 → 等 1 秒 → 重試
第 2 次失敗 → 等 2 秒 → 重試
第 3 次失敗 → 等 3 秒 → 重試
第 4 次失敗 → 等 4 秒 → 重試

優點:比固定退避更有彈性,給伺服器更多恢復時間。 缺點:增長速度相對緩慢,在大規模系統中分散效果不如指數退避。

3. Exponential Backoff(指數退避)

每次失敗後,等待時間以2 的冪次方增長:1 秒、2 秒、4 秒、8 秒、16 秒……

第 1 次失敗 → 等 1 秒  → 重試
第 2 次失敗 → 等 2 秒  → 重試
第 3 次失敗 → 等 4 秒  → 重試
第 4 次失敗 → 等 8 秒  → 重試
第 5 次失敗 → 等 16 秒 → 重試(通常設上限)

優點:給伺服器足夠的恢復時間,是分散重試壓力最有效的策略,也是雲端服務(AWS、GCP)推薦的標準做法。 缺點:等待時間可能增長過快,需要設定上限(Max Delay)。

指數退避是工業界最廣泛採用的 Backoff 策略。


計算公式詳解

基本公式

delay = min(base × 2^n, maxDelay)
  • base:基礎等待時間(通常設 1000 毫秒 = 1 秒)
  • n:已失敗次數(從 0 開始)
  • maxDelay:最大等待時間上限(通常設 30 秒)
第幾次失敗 (n)計算結果有上限(30秒)後
01000 × 2⁰1 秒1 秒
11000 × 2¹2 秒2 秒
21000 × 2²4 秒4 秒
31000 × 2³8 秒8 秒
41000 × 2⁴16 秒16 秒
51000 × 2⁵32 秒30 秒(被上限截斷)
61000 × 2⁶64 秒30 秒

Full Jitter(完全隨機化)

光靠指數退避還不夠。如果 1000 個客戶端同時失敗,它們計算出的退避時間是一樣的——它們仍然會在同一時間重試,雪崩效應依然存在。

解決方案是加入 Jitter(抖動):在退避時間中加入隨機性,讓不同客戶端的重試時間錯開。

Full Jitter(完全隨機化):在 0 到計算出的延遲之間隨機取一個值:

delay = random(0, min(base × 2^n, maxDelay))
function fullJitterDelay(n, base = 1000, maxDelay = 30000) {
  const exponentialDelay = base * Math.pow(2, n);
  const cappedDelay = Math.min(exponentialDelay, maxDelay);
  return Math.random() * cappedDelay; // 在 0 到上限之間隨機取值
}

Equal Jitter(等分隨機化):保留一半的確定性,另一半加入隨機:

delay = delay/2 + random(0, delay/2)
function equalJitterDelay(n, base = 1000, maxDelay = 30000) {
  const exponentialDelay = base * Math.pow(2, n);
  const cappedDelay = Math.min(exponentialDelay, maxDelay);
  const halfDelay = cappedDelay / 2;
  return halfDelay + Math.random() * halfDelay; // 在 delay/2 到 delay 之間
}

為什麼 Jitter 很重要?

以一個具體案例說明:

  • 情境:1000 個使用者同時在用你的服務,伺服器在 t=0 時發生短暫故障,所有請求在同一時刻失敗。
  • 沒有 Jitter:1000 個客戶端計算出相同的退避時間(例如 2 秒),在 t=2s 時同時發出 1000 個重試請求,伺服器剛恢復又被打垮。
  • 有 Full Jitter:1000 個客戶端在 0 到 2 秒之間隨機分散重試,伺服器每秒大約收到 500 個請求,能平穩地處理完畢。

結論:在高並發系統中,Jitter 是 Backoff 策略不可缺少的一部分。


JavaScript 原生實作

以下是一個使用原生 fetch(不依賴 axios)實作帶 Jitter 指數退避的完整函式:

// fetchWithBackoff:帶指數退避 + Full Jitter 的 fetch 封裝
// 使用方式和 fetch() 完全相同,只是加了自動重試邏輯

async function fetchWithBackoff(url, options = {}, retryOptions = {}) {
  const {
    maxRetries = 3,       // 最大重試次數
    baseDelay = 1000,     // 基礎延遲(毫秒)
    maxDelay = 30000,     // 最大延遲上限(毫秒)
    retryOn = [500, 502, 503, 504, 429], // 哪些 HTTP 狀態碼才重試
  } = retryOptions;

  // 計算帶 Full Jitter 的指數退避延遲
  function calculateDelay(attempt) {
    const exponentialDelay = baseDelay * Math.pow(2, attempt);
    const cappedDelay = Math.min(exponentialDelay, maxDelay);
    return Math.random() * cappedDelay; // Full Jitter
  }

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  let lastError = null;

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

      // 成功回應(2xx)直接回傳
      if (response.ok) {
        return response;
      }

      // 判斷這個狀態碼是否需要重試
      if (!retryOn.includes(response.status)) {
        // 4xx 錯誤(除了 429)通常是客戶端問題,不重試
        throw new Error(`請求失敗(${response.status}),不重試`);
      }

      // 需要重試的伺服器錯誤
      lastError = new Error(`伺服器回應錯誤:${response.status}`);

      // 如果是最後一次嘗試,直接拋出錯誤
      if (attempt === maxRetries) {
        throw lastError;
      }

      // 特殊處理 429 Rate Limiting:優先使用 Retry-After 標頭
      const retryAfter = response.headers.get('Retry-After');
      const delay = retryAfter
        ? parseInt(retryAfter, 10) * 1000  // Retry-After 是秒,轉為毫秒
        : calculateDelay(attempt);

      console.warn(
        `第 ${attempt + 1} 次請求失敗(${response.status}),` +
        `${Math.round(delay / 1000)} 秒後重試...`
      );
      await sleep(delay);

    } catch (error) {
      // 網路錯誤(斷線、DNS 失敗等)
      lastError = error;

      if (attempt === maxRetries) {
        throw new Error(`已達最大重試次數(${maxRetries}),最後錯誤:${error.message}`);
      }

      // 如果是我們主動拋出的「不重試」錯誤,直接往上拋
      if (error.message.includes('不重試')) {
        throw error;
      }

      const delay = calculateDelay(attempt);
      console.warn(
        `第 ${attempt + 1} 次請求發生網路錯誤,` +
        `${Math.round(delay / 1000)} 秒後重試...`,
        error.message
      );
      await sleep(delay);
    }
  }

  throw lastError;
}

// 使用範例
async function fetchUserData(userId) {
  try {
    const response = await fetchWithBackoff(
      `/api/users/${userId}`,
      {
        method: 'GET',
        headers: { 'Content-Type': 'application/json' },
      },
      {
        maxRetries: 3,
        baseDelay: 1000,
        maxDelay: 15000,
        retryOn: [500, 502, 503, 504, 429],
      }
    );

    const data = await response.json();
    console.log('使用者資料:', data);
    return data;

  } catch (error) {
    console.error('取得使用者資料失敗:', error.message);
    throw error;
  }
}

// 呼叫
fetchUserData(123);

使用 Axios Retry 套件

如果你的專案已經在使用 Axios,可以透過 axios-retry 套件快速加上重試與退避功能,不需要自己實作:

首先,安裝 axios-retry

npm install axios-retry

然後在你的程式碼中使用它:

import axios from 'axios';
import axiosRetry from 'axios-retry';

// 配置 Axios 實例
const instance = axios.create({
  baseURL: 'https://api.example.com',
});

// 設定 axios-retry 的配置
axiosRetry(instance, {
  retries: 3, // 重試次數
  retryDelay: axiosRetry.exponentialDelay, // 退避策略,使用指數退避
  retryCondition: (error) => {
    // 在此處添加自定義的重試條件,例如只在特定錯誤碼時重試
    return axiosRetry.isNetworkError(error) || (error.response && error.response.status === 500);
  },
});

// 使用 Axios 實例進行請求
instance.get('/data')
  .then(response => {
    console.log(response.data);
  })
  .catch(error => {
    console.error(error.message);
  });

axios-retry 進階設定選項

axios-retry 提供更多設定選項,讓你精確控制重試行為:

axiosRetry(instance, {
  // 最多重試 4 次
  retries: 4,

  // 使用指數退避(內建選項)
  // axiosRetry.exponentialDelay 會產生 100ms, 200ms, 400ms... 的延遲
  retryDelay: (retryCount) => {
    // 自定義延遲:加入 Jitter
    const delay = axiosRetry.exponentialDelay(retryCount);
    const jitter = Math.random() * 500; // 加入最多 500ms 的隨機抖動
    return delay + jitter;
  },

  // 重試條件:哪些情況下要重試
  retryCondition: (error) => {
    // 網路錯誤(斷線)
    if (axiosRetry.isNetworkError(error)) return true;

    // 冪等請求(GET、HEAD)的超時也重試
    if (axiosRetry.isIdempotentRequestError(error)) return true;

    // 5xx 伺服器錯誤重試
    if (error.response && error.response.status >= 500) return true;

    // 429 Rate Limiting 重試
    if (error.response && error.response.status === 429) return true;

    return false;
  },

  // 在每次重試前執行的回呼(可用於記錄 log)
  onRetry: (retryCount, error, requestConfig) => {
    console.warn(`重試第 ${retryCount} 次:`, requestConfig.url, error.message);
  },
});

實際應用場景

場景 1:API 請求重試

最典型的應用——前端呼叫第三方 API 或自家後端 API 發生暫時性錯誤:

// 取得商品詳情,最多重試 3 次
async function getProductDetails(productId) {
  return fetchWithBackoff(
    `https://api.shop.com/products/${productId}`,
    { headers: { Authorization: `Bearer ${token}` } },
    { maxRetries: 3, baseDelay: 500 }
  );
}

場景 2:資料庫連線重試

在 Node.js 後端啟動時,資料庫可能尚未就緒:

async function connectWithBackoff(dbClient, maxRetries = 5) {
  for (let i = 0; i < maxRetries; i++) {
    try {
      await dbClient.connect();
      console.log('資料庫連線成功');
      return;
    } catch (error) {
      const delay = Math.min(1000 * Math.pow(2, i), 30000);
      console.warn(`資料庫連線失敗,${delay / 1000} 秒後重試...`);
      await new Promise(r => setTimeout(r, delay));
    }
  }
  throw new Error('無法連線到資料庫');
}

場景 3:第三方服務整合

金流、簡訊、Email 服務商的 API 偶爾會有短暫不穩定:

// 送出付款請求,加入重試保護
async function processPayment(paymentData) {
  return fetchWithBackoff(
    'https://payment-gateway.com/api/charge',
    {
      method: 'POST',
      body: JSON.stringify(paymentData),
      headers: { 'Content-Type': 'application/json' },
    },
    {
      maxRetries: 2, // 金流敏感,重試次數保守一些
      baseDelay: 2000,
      retryOn: [503, 504], // 只在明確的伺服器故障時重試,不包含 500(避免重複扣款)
    }
  );
}

場景 4:處理 429 Rate Limiting

許多 API(OpenAI、GitHub、Stripe)有請求速率限制,遇到 429 時應該按照 Retry-After 標頭等待:

// fetchWithBackoff 已內建處理 Retry-After 標頭(見上方原生實作)
// 當伺服器回傳:
// HTTP/1.1 429 Too Many Requests
// Retry-After: 60
// 函式會自動等待 60 秒後重試,而不是使用指數退避

async function callOpenAI(prompt) {
  return fetchWithBackoff(
    'https://api.openai.com/v1/chat/completions',
    {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ model: 'gpt-4', messages: [{ role: 'user', content: prompt }] }),
    },
    { maxRetries: 3, retryOn: [429, 500, 503] }
  );
}

常見問題(FAQ)

Q1:最大延遲(Max Delay)應該設多少?

沒有絕對的標準,但以下是常見的考量:

  • 使用者面向的請求(互動型):最大延遲設 5-15 秒。超過 15 秒使用者通常會直接放棄或刷新頁面,繼續重試意義不大。
  • 背景任務(批次處理、排程):最大延遲可以設 30-60 秒,甚至更長。因為不影響使用者體驗,等待時間長一點反而能讓伺服器更好地恢復。
  • 關鍵服務(金流、認證):最大延遲設 10-30 秒,同時搭配較少的重試次數(2-3 次)。

Q2:哪些 HTTP 狀態碼應該重試?哪些不該?

HTTP 狀態碼是否重試原因
5xx(500, 502, 503, 504)伺服器端的暫時性錯誤,重試有意義
429 Too Many RequestsRate Limiting,等待後重試。應優先讀取 Retry-After 標頭
408 Request Timeout視情況網路超時,重試可能有效
4xx(400, 401, 403, 404)客戶端錯誤,重試不會改變結果(請求本身有問題)
401 Unauthorized否(先重新認證)應先刷新 Token 再重試,而不是直接重試

原則:5xx 和 429 重試;4xx 不重試(先修正請求本身)。

Q3:最多應該重試幾次?

取決於場景:

  • 一般 API 請求3 次是最常見的選擇,搭配指數退避總共等待約 7-15 秒。
  • 關鍵業務操作(訂單、付款):2-3 次,且要注意冪等性(idempotency)——確保重試不會造成重複操作(例如重複扣款)。
  • 非關鍵背景同步5-8 次,但每次等待要設更長的間隔。
  • 網路連線重試(啟動時連 DB):5-10 次,搭配指數退避。

超過 5 次重試在大多數情況下意義不大——如果服務真的掛掉超過幾分鐘,應該觸發告警並回報使用者,而不是無限重試。


了解退避策略後,搭配前端輪詢技術可以實作出更可靠的資料同步機制。請參考:前端 Polling 輪詢教學