コンテンツへスキップ

指数バックオフとリトライ設計 — なぜ一定間隔でなく待機時間を伸ばすのか

一時的な失敗にどう再試行するかは、ネットワーク越しの処理を書くたびに向き合う設計判断だ。定番として語られるのが「指数バックオフ(exponential backoff)」— リトライのたびに待機時間を2倍・4倍と伸ばしていく手法だが、これが常に正解というわけではない。今回は指数バックオフが解決する問題を整理した上で、自社コードの実際のリトライ処理を題材に、あえて指数バックオフを使わず一定間隔(固定間隔)を選んでいる箇所を見ていく。

指数バックオフが解決する問題

補足: 指数バックオフとは、リトライのたびに待機時間を指数関数的(2倍・4倍・8倍…)に伸ばしていくリトライ戦略のこと。

base = 1
for attempt in range(6):
    delay = base * (2 ** attempt)
    print(f"attempt {attempt}: wait {delay}s")
# attempt 0: wait 1s
# attempt 1: wait 2s
# attempt 2: wait 4s
# attempt 3: wait 8s
# attempt 4: wait 16s
# attempt 5: wait 32s

指数バックオフが本領を発揮するのは、同じ相手(サーバーやAPI)に対して、多数のクライアントが同時にリトライしうる状況だ。サーバーが一時的に過負荷になって失敗を返し始めたとき、全クライアントが「1秒後に再試行」のような一定間隔で一斉に再試行すると、失敗の波が一定周期で再発し続ける(thundering herd=雷鳴の群れ、と呼ばれる現象)。待機時間をリトライごとに伸ばしていけば、この波は時間とともに間隔が開いていき、サーバーが回復する猶予が生まれる。多くのHTTPクライアントライブラリやクラウドSDKが指数バックオフを標準搭載しているのはこのためだ。

裏を返せば、指数バックオフが効くのは「多数の独立したクライアントが同じ資源を奪い合う」構造があってこそだ。この前提が成立しない場面では、指数バックオフを入れても複雑さが増えるだけで、得られる利益がない。以下、自社コードの実際のリトライ箇所を3つ見ていく。

実例1: HTTPチェックの単発リトライ(一定間隔・回数固定)

このアプリはWordPress更新の前後でサイトのHTTPステータスを確認し、悪化していればロールバックする設計になっている。その中核が maintenance_agent.py::_http_status_check_stable() だ。

def _http_status_check_stable(url, timeout=15, retry_delay=3, basic_auth=None):
    status = _http_status_check(url, timeout=timeout, basic_auth=basic_auth)
    if status == 0:
        try:
            import time as _time
            _time.sleep(retry_delay)
        except Exception:
            pass
        status = _http_status_check(url, timeout=timeout, basic_auth=basic_auth)
    return status

status == 0(サーバーに届く前の失敗=DNS瞬断・TLSハンドシェイク失敗・回線の瞬きなど)のときだけ、3秒待って1回だけ再試行する。500番台の応答が返ってきた場合はリトライせず、そのまま「異常」として扱う(応答が返ってきている以上、一時的な回線揺れではなく実際にサーバー側で何かが起きている可能性が高いと判断している)。

このコードには指数バックオフの要素が一切ない。理由は、この関数が1回のメンテナンス実行の中で同じサイトに対して最大5箇所から呼ばれる構造にあるからだ(コード内コメントにも「5 箇所の _http_status_check_stable() に渡す」と明記されている)— 更新前のベースライン確認・コア更新後の確認・ロールバック後の確認・プラグインごとの更新後確認、といった具合に、プラグイン数が多いサイトほど呼び出し回数も増える。

もしここに指数バックオフを組み込み、リトライ回数を複数に増やしていたらどうなるか。プラグインを20個更新するサイトでは、単純計算でも「呼び出し × リトライ回数分の待機時間」が積み上がり、1回のメンテナンス実行全体の所要時間が予測しづらくなる。無人スケジュール実行の前提では、1サイトあたりの処理時間がある程度読めることの方が、まれに起きる長めの回線揺れを追いかけて粘ることより価値が高い。「一時的な瞬きか、本当のサーバー異常か」を切り分けるだけなら、待機時間を伸ばしながら何度も試す必要はなく、1回の固定リトライで十分という判断だ。

実例2: ファイルロック取得のポーリング(一定間隔・締切固定)

複数のプロセスが同じ設定ファイル(sites_*.json 等)を同時に書き込むと壊れる。これを防ぐプロセス間排他ロックが core/file_lock.py::FileLock で、GUI(Webサーバー本体)とバックグラウンドのメンテナンス実行(別プロセスとして起動)が同じファイルを触る場面を想定して作られている。

def acquire(self) -> None:
    deadline = time.monotonic() + self.timeout
    while True:
        if self._try_acquire_once():
            return
        if time.monotonic() >= deadline:
            raise FileLockTimeout(
                f"Failed to acquire lock within {self.timeout}s: {self.lock_path}"
            )
        time.sleep(self.poll_interval)

デフォルトは timeout=10.0(最大10秒待つ)・poll_interval=0.1(0.1秒おきに確認)。ロックが取れなければ、待機時間を伸ばすことなく、同じ0.1秒間隔で締切まで淡々とポーリングし続ける。

ここでも指数バックオフを避けているのには理由がある。このロックを奪い合うのは「不特定多数のクライアント」ではなく、基本的には同一アプリの2プロセス(GUIとバックグラウンド処理)に限られる。相手が数個しかいない場面では、そもそも thundering herd(多数の再試行が重なって相手を圧迫する現象)が起こりようがない。むしろ、ロックはローカルディスク上のファイル操作であり、待機コストは「サーバーへの負荷」ではなく「ただの待ち時間」でしかない。だとすれば、短い間隔で細かくポーリングしてロックが解放された瞬間に近い形で取得する方が、ユーザー体験としては望ましい。待機時間を段階的に伸ばす設計にすると、運悪く「次のポーリングまでまだ長く待つ区間」でロックが解放された場合に、無駄に取得が遅れてしまう。

実例3: 「待つ」のではなく「やり方を変える」リトライ

core/ssh_utils.py のプラグイン一覧取得には、また違う種類のリトライがある。

res = c.run(
    f"{wp_with_plugins} plugin list --update=available --format=json",
    hide=True, warn=True, encoding='utf-8'
)
if not (res.ok and res.stdout.strip()):
    # フォールバック: 全スキップで再試行
    res = c.run(
        f"{wp_safe} plugin list --update=available --format=json",
        hide=True, warn=True, encoding='utf-8'
    )

一部のプラグインが持つ独自の更新検出フィルターを効かせるため通常は --skip-plugins を外して実行するが、これが失敗した場合は、待たずに即座に全プラグインスキップの安全なコマンドへ切り替えて再試行する。時間を置けば直る類の失敗(回線の瞬き等)ではなく、「特定のプラグインが割り込んで処理を壊している」という構造的な原因を疑っているため、時間を置くこと自体に意味がない。ここでのリトライ軸は「待機時間」ではなく「実行方法そのものを変える」ことにある。

まとめ

指数バックオフは、多数の独立したクライアントが同じ資源に一斉にアクセスしうる状況で、失敗の波を時間的にばらけさせるための手法であり、外部APIやクラウドサービスとの通信では有効な標準戦略になる。だが、①リトライ回数がそもそも少なく済む(1回のHTTPチェック)、②競合する相手が少数に限られる(同一アプリ内の2プロセスによるローカルファイルロック)、③待って直る類の失敗ではない(実装方法の切り替えで解決するプラグイン一覧取得)、といった状況では、指数バックオフを導入しても複雑さが増えるだけで実質的な利益がない。リトライ設計を考えるときは「待機時間をどう伸ばすか」ではなく、まず「何と何が競合しているのか」「待てば直る失敗なのか」を見極めることが、適切な手法を選ぶ近道になる。