リトライとタイムアウト — 助けるつもりの再試行が、障害を大きくする
決済サービスは2分で直ったのに、全体は40分止まった。層ごとのリトライは掛け算になり、揃っていないタイムアウトは誰も待っていない仕事を増やす。
この記事の進み方
What — 1回の失敗が、何回の呼び出しになるか
リトライは、失敗した呼び出しをもう一度送ることです。一時的な失敗(一瞬の混雑、接続の切断)なら、もう一度送れば通ります。
問題は、呼び出しが何層にもつながっているときです。各層のリトライは、足し算ではなく掛け算になります。
アプリ 最大3回 ─┐
BFF 最大3回 ─┼─ 3 × 3 × 3 = 決済サービスに最大 27回
注文サービス 最大3回 ─┘
最下層が遅いとき、上の層はそれぞれ「失敗した」と判断して試し直します。失敗の原因である最下層に、いちばん多くの呼び出しが集まります。 弱っているところを、全員で叩いている形です。
アプリ・BFF・注文サービスの3つが、それぞれ失敗したら最大3回(初回を含む)まで試します。決済サービスがまったく応答しないとき、1回の購入操作で決済サービスに届く呼び出しは最大何回ですか?
タイムアウトが揃っていないと、誰も待っていない仕事が残る
リトライと並んで事故を大きくするのが、層ごとにばらばらのタイムアウトです。
BFF は5秒で利用者にエラーを返した。注文サービスはその後も決済を呼び続ける。もしその呼び出しが通れば、利用者は「失敗した」と思っているのに決済は成功している。
注文サービスが諦めるまでの合計は、2秒 × 3回 + 待ち1秒 × 2回 = 8秒です。BFF は 5秒で諦めます。下の層の「試し切るまでの時間」が、上の層のタイムアウトより長いと、上が諦めたあとも下は働き続けます。
- タイムアウト
- 応答を待つ上限時間。超えたら諦めて、握っていた接続やスレッドを手放す。
- バックオフ
- リトライの間に待つ時間。失敗するたびに伸ばしていくのが指数バックオフ。
- ジッター
- 待ち時間にランダムな揺らぎを足すこと。同時に失敗した呼び出しが、同時に試し直さないようにする。
- サーキットブレーカー
- 失敗が続いた呼び出し先を一定時間呼ばず、すぐ失敗として返す仕組み。
BFF のタイムアウトは5秒です。その先の注文サービスは、決済サービスを2秒のタイムアウトで最大3回呼び、間に1秒ずつ待ちます。決済サービスが応答しないとき、何が起きますか?
Why — なぜ再試行が障害を増幅するのか
リトライは「一時的な失敗なら、もう一度で通る」という前提で入れます。この前提は、失敗が偶然で、呼び出し先に余力があるときにしか成り立ちません。
呼び出し先が過負荷で遅くなっているときは逆です。遅さの原因は仕事が多すぎることで、リトライはそこに仕事を足します。
過負荷で遅い → タイムアウト → リトライ → さらに仕事が増える → さらに遅い → …
原因が取り除かれても、この循環は自分では止まりません。 冒頭で、重いクエリが2分で終わったのに40分止まり続けたのはこのためです。積み上がったリトライそのものが、過負荷の原因に入れ替わっていました。
即座に試し直すと、全員が同じ瞬間に叩く
失敗した直後に試し直すと、もう1つ問題が起きます。
障害は多くの呼び出しに同時に起きます。同じ瞬間に失敗した呼び出しが、同じ間隔で試し直すと、また同じ瞬間に届きます。負荷の山が、リトライの間隔ごとに繰り返し押し寄せます。
失敗した瞬間に試し直す
retry after 0ms- 呼び出し先の回復時間
- **無い**
- 同時に失敗した呼び出し
- **同じ瞬間にまた届く**
- 利用者の待ち時間
- 最短
- 向いている場面
- 接続の瞬断など、ごく稀な偶然の失敗
- 注意
- —
- –冒頭の事故の設定
- –過負荷の相手には、負荷をそのまま倍にして返すことになる
- –1回だけなら許される場面もあるが、繰り返すと害になる
間隔を伸ばすことと、ばらすことは別の効果。伸ばすと呼び出し先に回復の時間ができ、ばらすと山が崩れる。両方が要る。
何を試し直してよいか
リトライしてよいのは、もう一度送れば結果が変わりうる失敗で、かつ2回届いても困らない操作だけです。
| 失敗 | 試し直す | 理由 |
|---|---|---|
| タイムアウト、接続の切断 | する(冪等なら) | 一時的かもしれない。ただし相手に届いた可能性がある |
| 503、429 | する。Retry-After があれば従う | 相手が「後で来て」と言っている |
| 400、401、403、404、422 | しない | 何度送っても同じ。送り方が間違っている |
| 500 | 慎重に | 一時的な場合も、恒久的なバグの場合もある |
タイムアウトは「失敗した」ではなく「分からない」です。相手に届いて処理されたかもしれません。冪等でない操作をタイムアウトで試し直すと、二重に実行されます(api/idempotency)。
呼ばない、という選択
リトライは「もう一度呼ぶ」判断ですが、逆に**「しばらく呼ばない」**判断もあります。呼び出し先が明らかに弱っているなら、試し直すより、呼ばずにすぐ失敗として扱うほうが、自分も相手も守れます。
閉通常通常どおり呼んでいる。失敗の割合を数えている
開遮断呼ばずに、すぐ失敗として扱う。代わりの結果を返す
半開様子見少数の呼び出しだけを試しに通している
閉—失敗の割合が閾値を超えた→開 — 直近のタイムアウトや失敗が一定の割合を超えたら開く。ここからは呼び出しを送らないので、待ち時間は0になり、相手にも負荷を送らない。失敗が続いたら呼ぶのをやめ、しばらくしたら少しだけ試す。呼ばない間は、待たずにすぐ代わりの結果を返す。
開いている間、呼び出し元は待たずに代わりの結果を返せます。相手には呼び出しが届かないので、回復するための時間ができます。半開で少しずつ試すのは、回復しかけの相手に一度に全部を戻すと、また潰してしまうからです。
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
購入の経路で、リトライとタイムアウトをどう決めますか?
- 呼び出しは アプリ → BFF → 注文サービス → 外部の決済 API と、注文サービス → 在庫サービス
- 外部の決済 API は冪等キーに対応している。ときどき 503 と、Retry-After 付きの 429 を返す
- 在庫サービスの「在庫を引く」API は冪等キーに対応していない
- 利用者が画面で待てるのは、長くても10秒程度
- いまは全層が「最大3回・即座に」で試し直している
- · どの層で試し直すか。掛け算を避けられるか
- · 各層のタイムアウトを、上の層の待ち時間の中にどう収めるか
- · 冪等でない呼び出しを、失敗したときどう扱うか
ふりかえりで「決済サービスは2分で直ったのに、なぜ40分も止まったのか」と聞かれました。説明してください
- 参加者は各サービスの開発者と、プロダクトマネージャー
- 各サービスのリトライは、それぞれのチームが善意で入れたもの
- 誰かを責める場ではなく、次に何を変えるかを決めたい
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。