Featured image of post コード探偵ロックの事件簿【Retry Pattern】嵐を呼ぶリトライ〜指数バックオフという礼儀作法〜

コード探偵ロックの事件簿【Retry Pattern】嵐を呼ぶリトライ〜指数バックオフという礼儀作法〜

固定間隔リトライが引き起こすリトライストームのメカニズムを解剖し、指数バックオフ+Full Jitterで「再挑戦の作法」を身につけるRetry PatternをPerl/Mooで解説。

事件の発端 — ノックの作法

土曜の午後だった。

雑居ビルの3階。細い廊下の突き当たりに、くすんだ茶色のドアがある。「レガシー・コード・インベスティゲーション(LCI)」と書かれた小さなプレートが、ドアの中央やや上にかかっている。

先週の深夜、この人が私のオフィスにアポなしで来た。今日は私がアポを取って来ている。なんだか立場が逆転した気分だ。

ドアをノックした。返答がない。もう一度。返答がない。

ドアノブに手をかけると、鍵がかかっていなかった。開ける。

部屋の奥にデスクトップPCが2台、モニタが3台。飲みかけのエナジードリンク缶が机の端に5本並んでいる。壁には小さなホワイトボードがかかっていて、何かの数式が消えかけていた。

そしてロックさんが、机の上に広げた古い基板にハンダごてを当てていた。

「ワトソン君、少し待ちたまえ。この接合部が冷めるまでは手が離せない」

私を見ていない。基板だけ見ている。

「……お邪魔します」

ドアを閉め、部屋を見回した。雑然としている。だがPC周辺だけは整然としていた。モニタの1台にターミナルが開いていて、Perlのコードが表示されている。もう1台にはGrafanaらしきダッシュボードが映っている。

メールの返信は丁寧だったのに、部屋は丁寧じゃなかった。——いや、この人が丁寧なのはコードに対してだけなんだろう。先週、深夜1時の私のオフィスで、障害中のログを前にして目を輝かせていた人だ。

ロックさんがハンダごてをスタンドに置き、基板を裏返して確認した。満足げに頷いてから、ようやく私のほうを向いた。

「で、ブレーカーの調子はどうだ」

鞄からノートPCを出しながら答える。

「正常に動いています。先週の月曜にデプロイして、月曜から水曜まで障害ゼロ。CBのOpen遷移もゼロ」

一拍おいた。

「——ただ、木曜に気になることがありました」

「気になること」

ノートPCを開き、Grafanaのスクリーンショットを見せた。木曜深夜、決済APIのレスポンスタイムのグラフ。23:42に503レスポンスのスパイクが10秒間。23:42:12にCircuit BreakerがOpen遷移。60秒後にHalf-Open、そしてClosed。

「決済APIのプロバイダに確認しました。23:42にデプロイがあって、10秒ほどリクエストを受け付けない状態だったそうです。10秒の一時障害です。それなのにCBがOpenに遷移して、60秒間停止しました」

ロックさんがスクリーンショットを覗き込む。10秒のスパイクと60秒の停止の落差を、目で追っている。

「……10秒の障害が60秒の停止になっている」

「CBの設定は変えていません。失敗閾値5回、Open状態のタイムアウト60秒。先週ロックさんと一緒に決めた値です」

「CBは正しく動いた。5回連続の失敗を検知してOpenに遷移した。問題はCBではない」

ロックさんが私を見た。

「——5回の失敗がなぜ10秒のうちに積み上がったのか、だ」

ここだ。ここを見せるために来た。

「調べました」

もう1枚のスクリーンショットを開く。金曜日に自分でアクセスログのリトライタイムスタンプを突き合わせた結果だ。10インスタンスのリトライログ。23:42:01にinstance-01からinstance-10が一斉にリトライ。23:42:02にまた一斉。23:42:03にまた。全インスタンスが毎秒同じタイミングでリトライしている。

「10インスタンスが同時にリトライしているんじゃないか、と思ったんです。タイミングが揃いすぎている」

ロックさんがログを見た。1秒目に10リクエスト、2秒目に10リクエスト……を目で追っている。

「よく見ている、ワトソン君」

一瞬、黙った。

……悪い気はしない。

「10インスタンスが1秒間隔でリトライ。各インスタンスが最大5回リトライする。——1秒あたり最大10リクエスト。5秒間で50リクエスト。決済APIが回復しかけたところに50発のリクエストが降り注ぐ」

「……前回ロックさんが言った『倒れた相手にもう一度立てと叫び続ける』のと同じ構造ですか」

ロックさんの表情が少し変わった。前回の言葉を覚えていることに対する反応だ。

「近いが、微妙に違う。前回は1人が叫び続けていた。今回は10人が同時に叫んでいる

間。

「前回の処方箋はCBだった。叫ぶのをやめて、回復を待つ。今回は別の病だ。叫び方の作法が問題だ」

現場検証 — リトライの指紋

ロックさんがデスクの横の折りたたみ椅子を私に勧め、自分は基板をどけてデスクの前に座った。私のノートPCをデスクの上に移動させ、二人でログを見る形になる。

「現在のリトライコードを見せてくれ」

エディタを開いた。Circuit Breaker実装の内側にあるリトライロジック。先週ロックさんと一緒にCBを入れた際、リトライ部分には手を付けなかった。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
sub call_payment_api ($self, $order) {
    my $max_retries = 5;
    my $attempt     = 0;

    while ($attempt < $max_retries) {
        my $result = eval { $self->payment_client->charge($order) };
        return $result unless $@;

        $attempt++;
        sleep 1;    # 1秒待ってリトライ
    }

    die "Payment API failed after $max_retries retries: $@";
}

ロックさんの指が sleep 1 の行で止まった。

sleep 1。固定。——10インスタンスが全員、失敗した瞬間から正確に1秒後にリトライする。そして2秒後に。3秒後に」

「はい。ここが原因だと思います。でも——」

少し考えてから、自分の仮説を述べた。

「リトライ間隔を1秒から5秒に伸ばせば、同じタイミングに重ならなくなりますか?」

ロックさんがデスクの上のエナジードリンク缶5本を、横一列に等間隔で並べた。

「5秒間隔にした。エレベーターの前に10人が立っている。全員が5秒ごとにボタンを押す。——何が起きる?」

缶を見た。等間隔に並んだ5本。

「……全員が同じ5秒に押す」

間隔を伸ばしても、全員が同じ間隔で動いている限り、同期は解消されない。5秒に1回、10人分のリクエストが同時に届く。間隔が1秒でも5秒でも10秒でも、嵐の周期が変わるだけで嵐自体はなくならない」

「……では、間隔の長さの問題じゃない」

タイミングの問題だ。全員が同じリズムで動いていることが問題の本質だ」

ロックさんが壁のホワイトボードの消えかけた数式を消し、新しく書き始めた。

「まず基本形。指数バックオフ」

1
2
3
4
5
6
7
8
sleep = min(cap, base × 2^attempt)

base = 1秒, cap = 60秒
attempt 0: min(60, 1 × 1)  = 1秒
attempt 1: min(60, 1 × 2)  = 2秒
attempt 2: min(60, 1 × 4)  = 4秒
attempt 3: min(60, 1 × 8)  = 8秒
attempt 4: min(60, 1 × 16) = 16秒

「リトライのたびに待機時間が倍増する。1秒、2秒、4秒、8秒……。固定間隔と違い、後になるほど間隔が広がる。障害が長引いているなら、より長く待つ。——合理的だろう?」

「はい。でも——全員が同じ指数で伸ばしたら、また同じタイミングに揃いませんか? attempt=0で全員1秒、attempt=1で全員2秒……」

ロックさんが頷いた。ホワイトボードに棒グラフを描く。attempt=0の列に10本の短い線を同じ位置に重ねて描く。attempt=1にも10本。attempt=2にも。各列で10本が同じ位置に集中している。

「その通りだ。バーストの間隔が広がっただけで、バースト自体は消えていない

間があった。

ロックさんが10本の線を描き直す。今度は各列で線がバラバラの位置に散らばっている。

「ではこうしたらどうだ。各インスタンスが、0から計算値の間のどこかランダムなタイミングでリトライする」

「……ランダム?」

ジッター

ホワイトボードに書き足した。

1
2
3
4
5
6
Full Jitter:
sleep = rand(0, min(cap, base × 2^attempt))

attempt 0: rand(0, 1)  → 例: 0.3秒, 0.8秒, 0.1秒...
attempt 1: rand(0, 2)  → 例: 1.4秒, 0.6秒, 1.9秒...
attempt 2: rand(0, 4)  → 例: 3.1秒, 0.5秒, 2.7秒...

「attempt=2なら上限4秒。あるインスタンスは0.5秒待ち、あるインスタンスは3.7秒待ち、あるインスタンスは1.2秒待つ。——誰も同じタイミングにならない

ホワイトボードの散らばった線の図を見つめた。バラバラの位置。同じ attempt なのに、全員が違うタイミング。

「……でも、Full Jitterだと、待機時間が0に近くなるケースもありますよね。すぐにリトライしてしまったら、結局負荷をかけるのでは?」

ロックさんがホワイトボードに2つのグラフを描いた。左はジッターなし——1点に10本のリクエストが集中する棒グラフ。右はFull Jitter——0〜4秒に10本が均等に散らばる面グラフ。

「確かに、1つのインスタンスが0.1秒で再試行する可能性はある。だが、10インスタンスの全員が0.1秒を引く確率は?

計算した。

「……ほぼゼロです。均等に散らばるなら、4秒間に10本。毎秒2〜3本」

1インスタンスが早くリトライすることは問題ではない。全インスタンスが同時にリトライすることが問題だった。Full Jitterは平均待機時間を犠牲にしていない。期待値はジッターなしの半分だ。だがリクエストのピーク値を大幅に下げる。——AWSのシミュレーションでは、Full Jitterは100クライアント環境でサーバーへの呼び出し回数を半分以上削減している」

「1つのリトライが速くなるデメリットより、全体のピークが消えるメリットのほうが遥かに大きい——」

礼儀作法だよ、ワトソン君。全員が同時にドアを叩いたら、ドアは壊れる。1人ずつノックすれば、ドアは開く

推理披露 — 賢いリトライの設計

ロックさんがデスクの横にあるもう1台のPCでエディタを開いた。私は自分のノートPCでBeforeコードを開いたまま、ロックさんの画面を見る。

問題の整理

「君の現在のコードの問題を整理しよう」

ホワイトボードに3つ列挙された。

  1. 固定間隔 (sleep 1) → 全インスタンスが同期 → リトライストーム
  2. 障害の種類を区別しない → 一時的障害も永続的障害も同じ回数リトライ → 無駄な負荷
  3. 最大待機時間がない → バックオフを入れると待機時間が際限なく伸びる可能性

「1つ目はジッターで解決した。2つ目と3つ目も同時に解決する」

リトライ戦略オブジェクト

「リトライの判断を、戦略オブジェクトに分離する」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package RetryStrategy;
use Moo;
use v5.36;
use Types::Standard qw(Num Int);

has base_delay  => (is => 'ro', isa => Num, default => 1);
has max_delay   => (is => 'ro', isa => Num, default => 60);
has max_retries => (is => 'ro', isa => Int, default => 3);

sub calculate_delay ($self, $attempt) {
    my $exponential = $self->base_delay * (2 ** $attempt);
    my $capped      = $exponential < $self->max_delay
                    ? $exponential
                    : $self->max_delay;

    # Full Jitter: 0からcappedの間でランダム
    return rand($capped);
}

sub should_retry ($self, $attempt, $error) {
    return 0 if $attempt >= $self->max_retries;
    return $self->_is_transient($error);
}

sub _is_transient ($self, $error) {
    return 1 if $error =~ /timeout|connection[ _]refused|503|429/i;
    return 0;  # 400, 401, 403 等はリトライしない
}

_is_transient で障害の種類を判別している……」

前回のCircuit Breakerで、一時的障害と永続的障害の区別が大事だと教わったことを思い出す。

「覚えていたか。CBは障害の蓄積を見て遮断する。Retryは個々の障害を見てリトライするか決める。判断の粒度が違う。——そしてリトライすべきでない障害にリトライすることは、前回君が経験した通り、害にしかならない」

リトライ実行オブジェクト

「次。戦略を使ってリトライを実行するオブジェクト」

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package RetryExecutor;
use Moo;
use v5.36;
use Types::Standard qw(InstanceOf CodeRef);
use Time::HiRes qw(sleep);

has strategy => (
    is       => 'ro',
    isa      => InstanceOf['RetryStrategy'],
    required => 1,
);

has on_retry => (
    is      => 'ro',
    isa     => CodeRef,
    default => sub { sub {} },
);

sub execute ($self, $code) {
    my $attempt = 0;

    while (1) {
        my $result = eval { $code->() };
        return $result unless $@;

        my $error = "$@";
        unless ($self->strategy->should_retry($attempt, $error)) {
            die $error;
        }

        my $delay = $self->strategy->calculate_delay($attempt);
        $self->on_retry->($attempt, $delay, $error);
        sleep($delay);
        $attempt++;
    }
}

on_retry コールバックでリトライのたびにログを出せるんですね。……前回、ログが足りなくて原因の特定に時間がかかったので」

「そうだ。リトライは見えない負荷だ。ログに出さなければ、リトライストームが起きていることに気づけない。——Grafanaで気づけたのは、君がログを見る目を持っていたからだ。全員がそうとは限らない」

クラス構造

	classDiagram
    class RetryStrategy {
        +Num base_delay
        +Num max_delay
        +Int max_retries
        +calculate_delay(Int attempt) Num
        +should_retry(Int attempt, Str error) Bool
        -_is_transient(Str error) Bool
    }

    class RetryExecutor {
        +RetryStrategy strategy
        +CodeRef on_retry
        +execute(CodeRef code) Any
    }

    RetryExecutor --> RetryStrategy : uses

「RetryExecutorはRetryStrategyを使うだけ。戦略を差し替えれば挙動が変わる」

「固定間隔に戻したければ calculate_delay を上書きしたサブクラスを渡せばいい。テスト時はジッターをゼロにした戦略を注入すれば、結果が決定的になる」

呼び出し側の変化

「呼び出し側はこう変わります?」

Before(固定間隔リトライ):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
sub call_payment_api ($self, $order) {
    my $max_retries = 5;
    my $attempt     = 0;

    while ($attempt < $max_retries) {
        my $result = eval { $self->payment_client->charge($order) };
        return $result unless $@;

        $attempt++;
        sleep 1;
    }

    die "Payment API failed after $max_retries retries: $@";
}

After(戦略オブジェクト+ジッター):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
sub call_payment_api ($self, $order) {
    my $executor = RetryExecutor->new(
        strategy => RetryStrategy->new(
            base_delay  => 1,
            max_delay   => 30,
            max_retries => 3,
        ),
        on_retry => sub ($attempt, $delay, $error) {
            warn sprintf(
                "[retry] attempt=%d delay=%.2fs error=%s",
                $attempt, $delay, $error,
            );
        },
    );

    return $executor->execute(sub {
        $self->payment_client->charge($order);
    });
}

「Beforeは5回リトライ、固定1秒間隔、障害の種類を問わない。Afterは3回リトライ、指数バックオフ+Full Jitter、一時的障害のみリトライ。——回数は減ったが、成功率は上がる」

「回数を減らして成功率が上がる……」

「リトライが利己的な行為であることを忘れるな。リトライはサーバーに負荷をかけることで自分の成功確率を上げる行為だ。全員がそれを同時にやれば、サーバーは潰れる。1人ずつ、間隔を空けてやれば、サーバーは耐えられる」

retries are selfish——AWSの言葉がよぎった。自分のサービスだけが成功すればいいわけじゃない。テックリードとして、全体のことを考えなければならない。

「……全員が礼儀正しくリトライすれば、結果的に全員が成功する」

「その通りだ」

Circuit Breakerとの組み合わせ

「Circuit BreakerとRetryは、どう組み合わせるんですか? CBがOpenのときにリトライしても意味がないですよね」

ロックさんがホワイトボードを消し、新しい図を描いた。

1
Request → RetryExecutor → CircuitBreaker → Payment API

「CBがClosedのとき、RetryExecutorはバックオフ+ジッターでリトライする。CBがOpenのとき——RetryExecutorはリトライしない。即座にCBの例外をそのまま上に返す

「つまり、CBがOpenかどうかをRetryExecutorが知っている必要がある——」

「もっとシンプルに。RetryExecutorはCBの存在を知らなくていい。CBがOpenのとき、CBは即座に CircuitOpenError を投げる。RetryExecutorの should_retry がその例外を見て、これは一時的障害ではないと判断し、リトライしない。——リトライすべきかどうかの判断を例外の種類に委ねる。CBとRetryは疎結合のままだ」

メモを取りながら、頭の中で前回と今回がつながった。

「……前回のCBが諦める判断をして、今回のRetryが再挑戦の作法を決める。で、再挑戦するかどうか自体は、CBの状態が決める」

「正しい。2つのパターンは独立しているが、例外という共通言語で協調する

事件の解決 — 嵐、止む

ノートPCにRetryStrategyとRetryExecutorのコードを写し取り、テストを書いた。ロックさんはデスクに戻り、さきほどの基板を眺めている。口出ししない。——前回は手取り足取りだったのに。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
use v5.36;
use Test::More;
use RetryStrategy;

my $strategy = RetryStrategy->new(
    base_delay  => 1,
    max_delay   => 60,
    max_retries => 3,
);

# ジッターのテスト: 0以上、上限以下
for my $attempt (0..4) {
    my $cap = 1 * (2 ** $attempt);
    $cap = 60 if $cap > 60;
    for (1..100) {
        my $delay = $strategy->calculate_delay($attempt);
        ok $delay >= 0,    "attempt=$attempt: delay >= 0";
        ok $delay <= $cap, "attempt=$attempt: delay <= $cap";
    }
}

# リトライ判断のテスト
ok  $strategy->should_retry(0, "timeout"),
    "attempt 0, transient → retry";
ok  $strategy->should_retry(2, "503 Service Unavailable"),
    "attempt 2, 503 → retry";
ok !$strategy->should_retry(3, "timeout"),
    "attempt 3 (max) → no retry";
ok !$strategy->should_retry(0, "401 Unauthorized"),
    "attempt 0, 401 → no retry";
ok !$strategy->should_retry(0, "400 Bad Request"),
    "attempt 0, 400 → no retry";

done_testing;

テストを実行した。全件パス。モニタの緑色を静かに見る。

「通りました」

ロックさんは基板から目を上げなかった。

「当然だ」

苦笑した。

「……前回は全部ロックさんに教えてもらいました。今回は自分でテストを書けた」

ロックさんがようやく私を見た。

「前回、君は『リトライを増やせばいい』と思っていた。今は『リトライの仕方を考える』ようになった。——その変化のほうが、コードの変化より大きい」

間。

「……ありがとうございます」

「礼は要らない。コードで返したまえ」

探偵の報酬 — 嵐のあとの余韻

ノートPCを鞄にしまい始めた。ロックさんはデスクの上を片付けている。基板をケースに入れ、ハンダごてのコードを巻いている。

「そういえば報酬の話をしていなかったね。前回は非常時だったから遠慮したが——」

「請求書はCTOに——」

「いや、君個人にお願いしたい。——あの障害ダッシュボードのGrafanaのアラート音を変えてくれ。あの甲高いビープ音は探偵の思考を妨げる」

「……先週、ロックさんは私のオフィスに1時間いただけですよね」

ロックさんがハンダごてを吹いて冷ます仕草をした。

「探偵の耳は一度聞いた音を忘れない」

呆れながらも笑ってしまった。鞄を持って立ち上がる。

「アラート音、変えておきます」

「頼んだよ、ワトソン君」

ドアに向かった。ドアノブに手をかけ、振り返る。

「次に何かあったら、また来ていいですか」

ロックさんは基板のケースを閉じながら答えた。

「来たまえ。——ただし、次は自分で半分以上解いてから来ると約束してくれ」

「……努力します」

LCIの事務所を出た。雑居ビルの階段を降りながら、スマホを取り出す。Grafanaのアプリを開いた。決済APIのレスポンスタイムのグラフ。木曜深夜の10秒間のスパイクが見える。

次にこのスパイクが来たとき、10インスタンスは同時に叫ばない。1人ずつ、間隔を空けて、丁寧にノックする。

雑居ビルの1階に出ると、土曜の午後の日差しが眩しかった。前回ここを出たのは深夜1時だった。あのときは暗かった。

——次は、自分で半分以上。


探偵の調査報告書

容疑(アンチパターン)真実(パターン)証拠(効果)
固定間隔リトライ(全インスタンスが同時にリトライし、リトライストームを引き起こす)Retry Pattern + 指数バックオフ + Full Jitter(各インスタンスのリトライタイミングをランダムに分散)瞬間ピーク負荷が消失し、一時障害でCBがトリップしなくなる
障害の種類を区別しないリトライ(401でも503でも同じ回数リトライ)should_retry による一時的障害の判別(timeout, 503, 429のみリトライ)無駄なリトライが排除され、サーバーへの不要な負荷が消える
リトライロジックがビジネスコードに埋め込まれているRetryStrategy + RetryExecutor への分離(戦略パターン)テスト容易性が向上し、リトライ戦略の差し替えが可能に

推理のステップ

  1. リトライストームの特定: Grafanaとアクセスログで、全インスタンスが同じタイミングでリトライしていることを確認する
  2. 固定間隔の問題を理解する: sleep 1sleep 5 に変えても、同期が解消されないことを確認する。問題は間隔の長さではなくタイミングの同期
  3. 指数バックオフの導入: base × 2^attempt でリトライ間隔を指数的に増加させる。ただしこれだけではバーストは残る
  4. Full Jitterの追加: rand(0, min(cap, base × 2^attempt)) で待機時間をランダム化し、全インスタンスのリトライを時間的に分散させる
  5. 一時的障害の判別: _is_transient メソッドで障害の種類を判定し、永続的エラー(401, 400等)にはリトライしない
  6. 戦略オブジェクトへの分離: RetryStrategy にリトライ判断を、RetryExecutor にリトライ実行を分離する。テスト時はジッターゼロの戦略を注入して結果を決定的にする
  7. Circuit Breakerとの協調: CBの CircuitOpenError は一時的障害ではないため、RetryExecutor は自動的にリトライしない。2つのパターンは例外の型で疎結合に協調する

ロックより

礼儀作法を知らぬリトライは、嵐を呼ぶ。全員が同時にドアを叩けば、ドアは壊れる。だが1人ずつ、ランダムな間隔でノックすれば、ドアは開く。指数バックオフは「待ち方」を教え、Full Jitterは「待つタイミング」を散らす。この2つが揃ったとき、リトライは利己的な行為から、システム全体への礼儀作法に変わる。

前回のCircuit Breakerが「諦める判断」だったとすれば、今回のRetry Patternは「再挑戦の作法」だ。2つのパターンは独立しているが、例外という共通言語で協調する。——覚えておきたまえ、ワトソン君。強いシステムとは、壊れないシステムではない。壊れ方を知っているシステムだ。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。