Dubbo の自動再試行機能とその問題点—— framework design の観点から

Dubbo は、デフォルトで 再試行(retry)機能 を備えています。これは、クライアント側のタイムアウトや lokale エラー発生時に、複数回の呼び出しを自動的に試みる設計です。しかし、実装の背景にある設計思想と実際の運用におけるリスクのバランスには、注意すべき重要な点があります。特に、非冪等な操作に対して誤って再試行を許可してしまうと、データの重複挿入や状態の不整合といった深刻な問題を引き起こす可能性があります。

たとえば、以下のような Dubbo サービス実装を考えてみましょう:

public class UserServiceImpl implements UserService {
    @Override
    public String createUser(String name) {
        System.out.println("[サービス] ユーザー登録開始: " + name);
        try {
            // 重い処理の模擬:5秒スリープ
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return "ok";
    }
}

このサービスが 1秒 のタイムアウト設定(consumer および provider 側とも)で呼び出された場合、明確にタイムアウトが発生します。デフォルトのクラスターリング戦略 FailoverCluster では、デフォルトで"2回の再試行"+"元の1回=合計3回"の呼び出しが試みられます。この挙動は、以下のようなテストコードで確認できます:

@Test
public void testWithTimeoutAndRetry() {
    long start = System.currentTimeMillis();
    try {
        userService.createUser("dana");
    } catch (RpcException e) {
        System.out.println("呼び出し失敗: " + e.getMessage());
    }
    long duration = System.currentTimeMillis() - start;
    System.out.println("[結果] 総所要時間: " + duration + " ms");
}

このテストを実行すると、地坪 3 スレッドがそれぞれ 1 秒でタイムアウトし、約 3 秒(精确には 3226 ms など)で終了します。サービス側では同じリクエストで 3 回「ユーザー登録開始: dana」と出力され、挿入処理が 3 重に実行されていることが確認できます。つまり、再試行が業務ロジック層にまで干渉し、意図しない副作用を引き起こしている実態です。

対照的に、Apache HttpClient の再試行はネットワーク抽象層に限定されています。たとえば:

CloseableHttpClient client = HttpClients.custom()
    .setRetryHandler((exception, executionCount, context) -> {
        if (executionCount > 3) {
            System.out.println("再試行上限超え:" + executionCount);
            return false;
        }
        // 冪等(safe)なメソッドのみ再試行(例: GET)
        if (exception instanceof ConnectTimeoutException &&
            context instanceof HttpClientContext) {
            HttpContext ctx = context;
            HttpRequest req = ((HttpClientContext) ctx).getRequest();
            return !(req instanceof HttpEntityEnclosingRequest);
        }
        return false; // タイムアウト・中断系例外はデフォルトで拒否
    })
    .build();

ここで重要なのは、HttpClient のデフォルト再試行は、 InterrupedIOException とそのサブタイプ(ConnectTimeoutException、SocketTimeoutException)を明示的に除外している点です。つまり、ネットワークの断続的な不安定さ(例:一時的なパケットロス)への対応はともかく、サービスレイヤーで発生する「処理時間超過」は、設計上、再試行対象外としています。この挙動は、再試行が「通信プロトコル・トランスポート層の概念」として独立しており、ビジネスロジックに干渉しない、という思想の現れでもあります。

Dubbo の FailoverClusterInvoker 実装では、再試行回数は以下のコードで制御されます:

int len = InvokerUtils.getLength(invokers);
int retries = getUrl().getMethodParameter(methodName, RETRIES_KEY, DEFAULT_RETRIES);
retries = Math.max(0, retries); // 防御的処理:-999 でも 0 に丸められる
int retryCount = retries + 1; // 最初の1回 + retries回

for (int current = 0; current < retryCount; current++) {
    if (current > 0) {
        // select() で新しい invoker 再取得(障害ノード除外のため)
        invoker = invokeCluster.select(lb, invocation, invokers, null);
    }
    try {
        return invoker.invoke(invocation);
    } catch (RpcException e) {
        if (current == retryCount - 1) {
            throw e;
        } else {
            repeatedFailures++;
        }
    }
}

DEFAULT_RETRIES は 2 に定義されていますが、実際には「最初の 1 回+2 回=3 回」という実行回数になります。config で <dubbo:reference interface="..." retries="0" /> と指定すると、このループは 1 回だけになり、再試行は無効化されます。特に冪等性が保証されない操作(決済、注文作成など)では、これを明示的に設定する必要があります。

Dubbo 公式ドキュメントも「"通常は読み取り操作にのみ有効"」と明記していますが、開発者の理解不足により誤用される例は後を絶ちません。たとえば、外部 API(例:支払い処理系)を呼ぶ際、タイムアウト after 900ms → 直ちにサービスリトライ → サーバー側では 1.1 秒目に処理が完了 → 結果として "1 回の支払い依頼=2 回の課金" という事態が発生します。このような事象は、誤った前提(クライアントタイムアウト=サーバー未実行)に起因する典型的なリスクです。

技術的に betrachten する限り、フレームワークが"再試行"という抽象化を提供することは、可用性向上に寄与するのは事実です。しかし、その ISC(Influence Scope of Control)は、ネットワーク層かアプリケーション層かの線引きを明確にすることが不可欠です。Dubbo の課題は、「超時」をビジネス的エラー而非ネットワークエラーとして扱い、結果として業務ロジックに再試行が混入してしまう点にあります。

最後に注意点として、2.7.x 系列の一部バージョン(2.7.5/2.7.6)において、ユーザーが明示的に FailfastCluster を指定しても、内部で誤って FailoverCluster が適用され、再試行が実行される重大な不具合が存在しました。これは、クラスターリング戦略の初期化ロジック変更による副作用であり、2.7.8 以降で修正されています。バージョン選択・アップグレードにあたっては、クラスターリング設定の検証を必ず行う必要があります。

タグ: Dubbo ClusterStrategy RetryBehavior FailoverCluster ApacheHttpClient

6月29日 18:12 投稿