再試行パターン: 回復力のあるマイクロサービスの構築

再試行パターン: 回復力のあるマイクロサービスの構築

マイクロサービス アーキテクチャでは、サービスはメモリ内呼び出しではなくネットワーク経由で通信します。この分離により、大規模な水平スケーリングと独立した展開が可能になりますが、ネットワークの信頼性が低いという重大な脆弱性も生じます。

ダウンストリーム サービスでは、いつでも、短時間のネットワーク障害、一時的な CPU スパイク、データベース ロックの急速な競合、ローリング アップデートの再起動が発生する可能性があります。これらの一時的な障害は 一時的な障害 として知られています。

ダウンストリーム呼び出しが失敗した瞬間にサービスがすぐにエラーをスローし、リクエストを失敗させると、脆弱なユーザー エクスペリエンスが作成されます。代わりに、これらの一時的なエラーの多くは、少し待ってから再試行することで自動的に解決できます。ここで 再試行パターン が役に立ちます。

このガイドでは、再試行パターン、内部でどのように動作するか、単純な実装の危険性、Java (Resilience4j) と Go で再試行パターンを正しく実装する方法について説明します。


現実世界のたとえ: 話し中の回線にリダイヤルする

あなたが友人に電話しようとしていると想像してください。あなたは相手の番号にダイヤルしますが、相手は現在別の通話中であるため、話中信号を受け取ります。

あなたはすぐに諦めて連絡先を削除し、二度と話せないと思い込んでいませんか?もちろん違います。電話を切り、少し待ってから、もう一度相手の番号をダイヤルします。まだ通話中の場合は、5 分待ってからもう一度試してください。

最終的に通話は終了し、再試行は成功します。

リクエストの試行、バックオフ遅延、および 2 回目の成功した再試行を示す再試行パターン アーキテクチャ図

マイクロサービスでは:

  • 呼び出しは、ダウンストリーム サービスへの API リクエストです。
  • ビジー信号は、一時的なネットワーク エラーまたは 503 Service Unavailable 応答です。
  • リダイヤルは再試行です。
  • 待機時間はバックオフ期間です。

危険: 単純な再試行と「再試行の嵐」

再試行メカニズムの実装は、一見すると簡単に思えます。HTTP 呼び出しを for ループでラップし、成功するまで試行し続けるだけです。ただし、単純な再試行の実装では、軽微な問題がシステム全体の壊滅的な停止に簡単に変化する可能性があります。

突然のトラフィックの急増に苦戦しているダウンストリーム サービスを想像してください。データベースは 99% の CPU 使用率で実行されており、リクエストがタイムアウトし始めています。

100 個のクライアント サービスすべてがタイムアウトを検出し、待機せずにすぐに 3 回再試行すると、すでに絞殺されているダウンストリーム サービスに到達するトラフィックの量が突然 3 倍になります。このトラフィックの突然の増幅は、再試行ストーム (または Thundering Herd 問題) として知られています。

再試行によってダウンストリーム サービスの回復を支援する代わりに、ダウンストリーム サービスが低下し続け、サービスが追いつくことができなくなります。


解決策: バックオフとジッター

再試行の嵐を防ぐために、回復力のあるシステムは、バックオフジッター という 2 つの重要な戦略を利用する必要があります。

1. バックオフ戦略

バックオフは、クライアントが次の再試行を行う前に待機する時間を指定します。

  • 固定バックオフ: クライアントは試行の間に一定の時間 (たとえば、ちょうど 200 ミリ秒) 待機します。シンプルではありますが、同期トラフィックが急増するリスクは依然としてあります。
  • 指数関数的バックオフ: 試行が失敗するたびに待機時間は指数関数的に増加します (例: 100ms、200ms、400ms、800ms)。これにより、障害が継続するにつれて、ダウンストリーム サービスの回復時間が徐々に長くなります。

2. ジッター (ランダム性)

指数バックオフを使用する場合でも、ネットワーク障害により 1,000 件のリクエストが同時に失敗した場合、1,000 台のクライアントすべてがまったく同じバックオフ遅延を計算します。その結果、それらはすべて波状に同時に再試行され、同期されたスパイクでダウンストリーム サービスにヒットします。

ジッター は、バックオフ遅延にランダムな分散を追加することでこれを解決します。

Without Jitter (Synchronized Waves):
Time: 0ms   -> [1000 requests fail]
Time: 100ms -> [1000 retries hit simultaneously]
Time: 200ms -> [1000 retries hit simultaneously]

With Jitter (Distributed Traffic):
Time: 0ms   -> [1000 requests fail]
Time: 92ms  -> [85 retries]
Time: 105ms -> [120 retries]
Time: 118ms -> [95 retries]
... (Traffic is smoothed out over time)

再試行をランダムな間隔で分散することで、ダウンストリーム サービスの負荷が軽減され、正常に回復できるようになります。


再試行の黄金律: べき等性

API エンドポイントに再試行を適用する前に、この操作はべき等ですか? という重要な質問をする必要があります。

冪等 操作とは、複数の同一のリクエストを行うと、単一のリクエストを行うのとまったく同じ効果が得られる操作です。

  • 冪等: ユーザー プロファイルの読み取り (GET /users/123)、電子メール フィールド全体の更新 (PUT /users/123/email)、またはアイテムの削除 (DELETE /items/456)。
  • 非冪等: 新しい注文の作成 (POST /orders)、またはクレジット カード請求の処理 (POST /payments)。

POST /payments を呼び出して顧客に 50 ドルを請求するとします。ダウンストリームの支払いゲートウェイはリクエストを受信し、クレジット カードに正常に請求されますが、200 OK 応答を送信する前にネットワーク ブリップが発生します。

サービスはタイムアウトを登録し、呼び出しが失敗したものとみなし、自動的に再試行します。支払いゲートウェイが重複したリクエストを処理するように設計されていない場合、顧客には 2 回請求されることになります

[!警告] ダウンストリーム サービスが 冪等キー (重複トランザクションの検出と破棄に使用される一意のリクエスト ID) をサポートしていない限り、非冪等操作を再試行しないでください。


実装例

Java と Go で再試行パターンを実装する方法を見てみましょう。

1. Java (Resilience4j および Spring Boot)

Resilience4j は、堅牢で高度に構成可能な再試行モジュールを提供します。外部課金サービス用に設定する方法は次のとおりです。

構成 (application.yml)

resilience4j.retry:
  instances:
    billingService:
      maxAttempts: 3
      waitDuration: 100ms
      enableExponentialBackoff: true
      exponentialBackoffMultiplier: 2.0
      enableRandomizedWait: true
      randomizedWaitFactor: 0.5
      retryExceptions:
        - org.springframework.web.client.ResourceAccessException
        - io.netty.channel.ConnectTimeoutException
      ignoreExceptions:
        - org.springframework.web.client.HttpClientErrorException # E.g., 400 Bad Request shouldn't be retried

コードの実装

import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class PaymentProcessor {

    private final RestTemplate restTemplate;

    public PaymentProcessor(RestTemplate restTemplate) {
        this.restTemplate = restTemplate;
    }

    // Apply the configured retry policy with a fallback method
    @Retry(name = "billingService", fallbackMethod = "billingFallback")
    public String chargeUser(BillingRequest request) {
        return restTemplate.postForObject("http://billing-service/charge", request, String.class);
    }

    // Executed when all retry attempts fail
    public String billingFallback(BillingRequest request, Throwable throwable) {
        return "Billing service is currently unavailable. Your transaction will be queued.";
    }
}

2. Go (Golang)

Go では、標準ライブラリ タイマーを使用して、指数バックオフとランダム化されたジッターを備えたエレガントな再試行ランナーを作成できます。

package main

import (
	"context"
	"errors"
	"fmt"
	"math/rand"
	"time"
)

// RetryConfig holds the policies for our retry attempts
type RetryConfig struct {
	MaxAttempts int
	MinBackoff  time.Duration
	MaxBackoff  time.Duration
}

// Execute runs the operation using exponential backoff with full jitter
func Execute(ctx context.Context, config RetryConfig, operation func() error) error {
	var err error
	
	for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
		err = operation()
		if err == nil {
			return nil // Success!
		}

		if attempt == config.MaxAttempts {
			break
		}

		// Calculate exponential backoff
		// wait = min(MaxBackoff, MinBackoff * 2^(attempt-1))
		backoff := config.MinBackoff * (1 << (attempt - 1))
		if backoff > config.MaxBackoff || backoff <= 0 {
			backoff = config.MaxBackoff
		}

		// Apply Full Jitter: wait randomly between 0 and backoff
		jitter := time.Duration(rand.Int63n(int64(backoff)))

		fmt.Printf("[Attempt %d/%d] Failed: %v. Retrying in %v...\n", attempt, config.MaxAttempts, err, jitter)

		select {
		case <-time.After(jitter):
		case <-ctx.Done():
			return ctx.Err()
		}
	}

	return fmt.Errorf("operation failed after %d attempts: %w", config.MaxAttempts, err)
}

func main() {
	config := RetryConfig{
		MaxAttempts: 4,
		MinBackoff:  100 * time.Millisecond,
		MaxBackoff:  2000 * time.Millisecond,
	}

	// Mock function that fails 3 times and succeeds on the 4th
	attempts := 0
	mockAPI := func() error {
		attempts++
		if attempts < 4 {
			return errors.New("network timeout (503)")
		}
		return nil
	}

	ctx := context.Background()
	err := Execute(ctx, config, mockAPI)
	if err != nil {
		fmt.Printf("Final Outcome: %v\n", err)
	} else {
		fmt.Println("Final Outcome: Successfully connected on attempt", attempts)
	}
}

再試行とサーキット ブレーカー: いつどちらを使用するか?

開発者は、再試行パターンサーキット ブレーカー パターンを混同することがよくあります。どちらもシステムの回復力を向上させることを目的としていますが、根本的に異なるタイプの障害を処理します。

メトリック 再試行パターン サーキットブレーカーのパターン
主な目標 一時的 (短期間の) 障害から自動的に回復します。 永続的 (長期間続く) 障害によって呼び出し元がダウンするのを防ぎます。
戦略 すぐに再試行するか、バックオフで少し待ってから再試行してください。 ターゲットサービスを呼び出さずにただちにフェイルファストします。
下流への影響 ダウンストリーム サービスの負荷が一時的に増加します。 ダウンストリーム サービスをトラフィックから保護し、回復できるようにします。
典型的なトリガー 短時間のネットワーク切断、データベースのタイムアウト、ソケットのドロップ。 ダウンストリーム サービスが完全にダウンし、連続 500 秒またはタイムアウトが返されます。

パワーカップル: 両方を組み合わせる

運用システムでは、これら 2 つのパターンは 一緒に使用できるように設計されています。

ダウンストリーム リクエストを行うときは、最初に サーキット ブレーカー を通過し、次に Retry ラッパーを通過する必要があります。

一時的なグリッチが発生した場合、再試行メカニズムがそれを中断して再試行します。ただし、下流のサービスが停止している場合、障害は山積します。サーキット ブレーカーは連続した障害を検出し、「トリップ」し、その後のすべての呼び出しをブロックします。

ここで、サービスを呼び出そうとすると、サーキット ブレーカーはすぐに失敗し、再試行ループを完全にバイパスしてコンピューティング リソースを節約します。


再試行パターンのベスト プラクティス

  1. 一時的なエラーのみを再試行: HTTP ステータス コードを検査します。 503 Service Unavailable429 Too Many Requests (存在する場合は Retry-After ヘッダーを考慮)、およびネットワーク タイムアウトを再試行します。 400 Bad Request401 Unauthorized、または 404 Not Found絶対に再試行しないでください。これらは再試行では決して成功しません。
  2. ジッターを適用: ランダムなジッターを発生させずに、固定の再試行タイマーを使用しないでください。
  3. 最大試行回数を制限: 適切なしきい値 (通常は 3 ~ 5 回の試行) を超えた後、再試行を停止します。無制限の再試行はリソースを消費し、待ち時間を短縮します。
  4. カスケード再試行には注意してください: サービス A がサービス B (3 回再試行) を呼び出し、サービス B がサービス C (3 回再試行) を呼び出す場合、C での失敗により $3 \times 3 = 9$ のカスケード呼び出しが発生する可能性があります。最も意味のある境界でのみ再試行を適用してください。
  5. 常に厳密なタイムアウトを設定: スレッドが無期限に保持されないように、ネットワーク タイムアウトがバックオフ期間よりも短いことを確認してください。

## 結論

再試行パターンは、マイクロサービス アーキテクチャにおけるネットワークの信頼性の低下に対する強力な防御の第一線です。 エクスポネンシャル バックオフジッターサーキット ブレーカーと組み合わせると、連鎖的な停止からシステムを保護し、ユーザーにシームレスな自己修復エクスペリエンスを提供できます。