0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Exponential Backoff とは? — リトライ戦略の基本と Jitter の重要性

0
Posted at

はじめに

API 呼び出しやネットワーク通信が失敗したとき、すぐにリトライするのは最悪の戦略です。サーバーが過負荷で応答できないのに、大量のクライアントが一斉にリトライすれば、状況はさらに悪化します。

Exponential Backoff(指数関数的バックオフ) は、リトライの待機時間を指数関数的に増やすことで、サーバーに回復の猶予を与えるリトライ戦略です。

なぜ単純なリトライではダメなのか

即時リトライの問題

サーバー過負荷 → 100台のクライアントがエラーを受け取る
  → 100台が即座にリトライ
    → サーバーがさらに過負荷
      → 100台がまた即座にリトライ
        → 無限ループ...

固定間隔リトライの問題

サーバー過負荷 → 100台のクライアントがエラーを受け取る
  → 100台が「3秒後」にリトライ
    → 3秒後に100台のリクエストが同時に到着
      → サーバーがまた過負荷

固定間隔でも、全クライアントが同じタイミングでリトライするため、Thundering Herd(殺到)問題が発生します。

Exponential Backoff の仕組み

リトライのたびに待機時間を指数関数的に増やします

1回目のリトライ: 1秒後
2回目のリトライ: 2秒後
3回目のリトライ: 4秒後
4回目のリトライ: 8秒後
5回目のリトライ: 16秒後

計算式:

待機時間 = baseDelay × 2^(リトライ回数 - 1)

基本実装(TypeScript)

async function fetchWithBackoff(
  url: string,
  maxRetries: number = 5,
  baseDelay: number = 1000 // ミリ秒
): Promise<Response> {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(url);

      if (response.ok) {
        return response;
      }

      // 4xx エラーはリトライしない(クライアント側の問題)
      if (response.status >= 400 && response.status < 500) {
        throw new Error(`Client error: ${response.status}`);
      }

      // 5xx エラーはリトライ対象
    } catch (error) {
      if (attempt === maxRetries - 1) {
        throw error; // 最後のリトライでも失敗したら例外を投げる
      }
    }

    // Exponential Backoff: 1s → 2s → 4s → 8s → 16s
    const delay = baseDelay * Math.pow(2, attempt);
    await sleep(delay);
  }

  throw new Error("Max retries exceeded");
}

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

Exponential Backoff の問題点

単純な Exponential Backoff にはまだ問題があります。

クライアントA: エラー → 1秒後リトライ → 2秒後リトライ → 4秒後リトライ
クライアントB: エラー → 1秒後リトライ → 2秒後リトライ → 4秒後リトライ
クライアントC: エラー → 1秒後リトライ → 2秒後リトライ → 4秒後リトライ

すべてのクライアントが同じタイミングでリトライするため、Thundering Herd 問題は解消されません。

Jitter(ジッター)の導入

Jitter は待機時間にランダムなばらつきを加えることで、リトライのタイミングを分散させます。

Full Jitter

待機時間を 0 から 最大待機時間 の範囲でランダムに決定します。AWS SDK でも採用されている方式です。

// Full Jitter: 0 〜 (baseDelay × 2^attempt) のランダム
function fullJitter(baseDelay: number, attempt: number): number {
  const maxDelay = baseDelay * Math.pow(2, attempt);
  return Math.random() * maxDelay;
}
クライアントA: エラー → 0.7秒後 → 1.3秒後 → 3.8秒後
クライアントB: エラー → 0.2秒後 → 2.9秒後 → 2.1秒後
クライアントC: エラー → 0.9秒後 → 0.5秒後 → 7.2秒後

リトライのタイミングがバラけるため、サーバーへの負荷が分散されます。

Equal Jitter

最大待機時間の半分を固定とし、残り半分をランダムにします。Full Jitter より待機時間のばらつきが小さくなります。

// Equal Jitter: 最大値の半分 + ランダム
function equalJitter(baseDelay: number, attempt: number): number {
  const maxDelay = baseDelay * Math.pow(2, attempt);
  const half = maxDelay / 2;
  return half + Math.random() * half;
}

Decorrelated Jitter

前回の待機時間を基準にランダムに決定します。

// Decorrelated Jitter
function decorrelatedJitter(
  baseDelay: number,
  previousDelay: number
): number {
  return Math.min(
    maxDelay,
    Math.random() * (previousDelay * 3 - baseDelay) + baseDelay
  );
}

どの Jitter を選ぶべきか

方式 特徴 推奨場面
Full Jitter ばらつきが最大。サーバー負荷の分散に最も効果的 大量のクライアントがいる場合(推奨)
Equal Jitter ある程度の待機が保証される 最低限の待機時間を確保したい場合
Decorrelated Jitter 前回の結果に基づく 特殊な要件がある場合

一般的には Full Jitter が最も推奨されます。

実践的な実装

完全な実装(TypeScript)

interface RetryConfig {
  maxRetries: number;
  baseDelayMs: number;
  maxDelayMs: number;
  retryableStatuses: number[];
}

const defaultConfig: RetryConfig = {
  maxRetries: 5,
  baseDelayMs: 1000,
  maxDelayMs: 30000, // 最大30秒
  retryableStatuses: [429, 500, 502, 503, 504],
};

async function fetchWithExponentialBackoff(
  url: string,
  options?: RequestInit,
  config: RetryConfig = defaultConfig
): Promise<Response> {
  let lastError: Error | null = null;

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

      // リトライ対象のステータスコードか判定
      if (!config.retryableStatuses.includes(response.status)) {
        return response;
      }

      // Rate Limit の場合、Retry-After ヘッダーを尊重
      const retryAfter = response.headers.get("Retry-After");
      if (retryAfter && attempt < config.maxRetries) {
        const retryAfterMs = parseInt(retryAfter) * 1000;
        await sleep(retryAfterMs);
        continue;
      }

      lastError = new Error(`HTTP ${response.status}`);
    } catch (error) {
      lastError = error as Error;
    }

    if (attempt < config.maxRetries) {
      // Full Jitter + 上限あり
      const maxDelay = Math.min(
        config.maxDelayMs,
        config.baseDelayMs * Math.pow(2, attempt)
      );
      const delay = Math.random() * maxDelay;

      console.log(
        `Retry ${attempt + 1}/${config.maxRetries} in ${Math.round(delay)}ms`
      );
      await sleep(delay);
    }
  }

  throw new Error(
    `Failed after ${config.maxRetries} retries: ${lastError?.message}`
  );
}

実装のポイント

1. リトライすべきエラーを判別する

// リトライすべき
429  // Too Many Requests(Rate Limit)
500  // Internal Server Error
502  // Bad Gateway
503  // Service Unavailable
504  // Gateway Timeout

// リトライすべきでない
400  // Bad Request(リクエストが不正)
401  // Unauthorized(認証エラー)
403  // Forbidden(権限エラー)
404  // Not Found
422  // Unprocessable Entity

4xx エラーはクライアント側の問題なので、何度リトライしても結果は変わりません。

2. 最大待機時間を設定する

// 上限なしだと...
// 10回目: 1000 × 2^9 = 512,000ms = 約8.5分
// → 長すぎる

// 上限ありにする
const maxDelayMs = 30000; // 最大30秒
const delay = Math.min(maxDelayMs, baseDelay * Math.pow(2, attempt));

3. Retry-After ヘッダーを尊重する

// サーバーが「N秒後にリトライしてください」と指示している場合
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
  await sleep(parseInt(retryAfter) * 1000);
}

Rate Limit(429)の場合、サーバーが Retry-After ヘッダーで適切な待機時間を指示していることがあります。この場合は自前の Exponential Backoff ではなく、サーバーの指示に従います。

4. べき等性を確認する

リトライは「同じリクエストを複数回送る」ことを意味します。GET リクエストは問題ありませんが、POST リクエストはべき等(idempotent)でない場合、二重処理のリスクがあります。

// べき等キーを使って二重処理を防ぐ
const response = await fetch("/api/orders", {
  method: "POST",
  headers: {
    "Idempotency-Key": crypto.randomUUID(), // リクエストごとに一意なキー
  },
  body: JSON.stringify(order),
});

Thundering Herd 問題の図解

                    固定間隔リトライ(Jitter なし)

リクエスト数
  │
  │  ███          ███          ███
  │  ███          ███          ███
  │  ███          ███          ███
  │──███──────────███──────────███──────→ 時間
     0s           3s           6s
     ↑            ↑            ↑
   全員が同時にリトライ(サーバーに殺到)


              Exponential Backoff + Full Jitter

リクエスト数
  │
  │  █                █
  │  ██   █          ██    █
  │  ███  ██  █      ███  ██    █
  │──███──██──█──────███──██────█───→ 時間
     0s        5s          15s
     ↑
   リトライが分散される(サーバー負荷が平準化)

各言語・フレームワークでのサポート

多くの SDK やライブラリが Exponential Backoff を組み込みでサポートしています。

ツール サポート
AWS SDK デフォルトで Exponential Backoff + Full Jitter
Google Cloud Client Libraries デフォルトで Exponential Backoff
axios-retry (JavaScript) retryDelay: axiosRetry.exponentialDelay
tenacity (Python) wait_exponential() + wait_random()
Polly (.NET) WaitAndRetryAsync with exponential backoff

自前で実装する前に、使用しているライブラリにリトライ機能が組み込まれていないか確認してください。

まとめ

戦略 待機時間 問題点
即時リトライ 0秒 サーバーに殺到
固定間隔 常に N秒 Thundering Herd
Exponential Backoff 1s → 2s → 4s → 8s クライアント同士が同期
Exponential Backoff + Jitter ランダムに分散 推奨

Exponential Backoff + Jitter は、分散システムにおけるリトライの標準的な戦略です。実装時は以下の点に注意してください。

  1. リトライすべきエラーかを判別する(4xx はリトライしない)
  2. 最大待機時間を設定する(無限に待たせない)
  3. Retry-After ヘッダーを尊重する
  4. べき等でないリクエストはべき等キーを使う
  5. ライブラリの組み込み機能を確認する

参考記事・データ

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?