仕組みから学ぶ Web
応用読了目安 15#リトライ#タイムアウト#バックオフ#サーキットブレーカー#分散システム

リトライとタイムアウト — 助けるつもりの再試行が、障害を大きくする

決済サービスは2分で直ったのに、全体は40分止まった。層ごとのリトライは掛け算になり、揃っていないタイムアウトは誰も待っていない仕事を増やす。

この記事の進み方

What — 1回の失敗が、何回の呼び出しになるか

リトライは、失敗した呼び出しをもう一度送ることです。一時的な失敗(一瞬の混雑、接続の切断)なら、もう一度送れば通ります。

問題は、呼び出しが何層にもつながっているときです。各層のリトライは、足し算ではなく掛け算になります。

アプリ          最大3回 ─┐
BFF             最大3回 ─┼─ 3 × 3 × 3 = 決済サービスに最大 27回
注文サービス    最大3回 ─┘

最下層が遅いとき、上の層はそれぞれ「失敗した」と判断して試し直します。失敗の原因である最下層に、いちばん多くの呼び出しが集まります。 弱っているところを、全員で叩いている形です。

確認 — ここまで読めたか

アプリ・BFF・注文サービスの3つが、それぞれ失敗したら最大3回(初回を含む)まで試します。決済サービスがまったく応答しないとき、1回の購入操作で決済サービスに届く呼び出しは最大何回ですか?

まず選ぶ(解答例は a〜d の記号で説明します)

タイムアウトが揃っていないと、誰も待っていない仕事が残る

リトライと並んで事故を大きくするのが、層ごとにばらばらのタイムアウトです。

Figure上の層が諦めても、下の層は試し続ける
BFFタイムアウト 5秒注文サービス2秒 × 3回、間に1秒決済サービス遅延中POST /orders(0秒)1回目(0〜2秒)2回目(3〜5秒)タイムアウト(5秒)3回目(6〜8秒)200 決済成功(7秒)
1/6
POST /orders(0秒)BFF は5秒まで待つつもりで呼ぶ。この5秒は、利用者を待たせてよい上限から決めた。

BFF は5秒で利用者にエラーを返した。注文サービスはその後も決済を呼び続ける。もしその呼び出しが通れば、利用者は「失敗した」と思っているのに決済は成功している。

注文サービスが諦めるまでの合計は、2秒 × 3回 + 待ち1秒 × 2回 = 8秒です。BFF は 5秒で諦めます。下の層の「試し切るまでの時間」が、上の層のタイムアウトより長いと、上が諦めたあとも下は働き続けます。

タイムアウト
応答を待つ上限時間。超えたら諦めて、握っていた接続やスレッドを手放す。
バックオフ
リトライの間に待つ時間。失敗するたびに伸ばしていくのが指数バックオフ。
ジッター
待ち時間にランダムな揺らぎを足すこと。同時に失敗した呼び出しが、同時に試し直さないようにする。
サーキットブレーカー
失敗が続いた呼び出し先を一定時間呼ばず、すぐ失敗として返す仕組み。
確認 — ここまで読めたか

BFF のタイムアウトは5秒です。その先の注文サービスは、決済サービスを2秒のタイムアウトで最大3回呼び、間に1秒ずつ待ちます。決済サービスが応答しないとき、何が起きますか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — なぜ再試行が障害を増幅するのか

リトライは「一時的な失敗なら、もう一度で通る」という前提で入れます。この前提は、失敗が偶然で、呼び出し先に余力があるときにしか成り立ちません。

呼び出し先が過負荷で遅くなっているときは逆です。遅さの原因は仕事が多すぎることで、リトライはそこに仕事を足します。

過負荷で遅い → タイムアウト → リトライ → さらに仕事が増える → さらに遅い → …

原因が取り除かれても、この循環は自分では止まりません。 冒頭で、重いクエリが2分で終わったのに40分止まり続けたのはこのためです。積み上がったリトライそのものが、過負荷の原因に入れ替わっていました。

即座に試し直すと、全員が同じ瞬間に叩く

失敗した直後に試し直すと、もう1つ問題が起きます。

障害は多くの呼び出しに同時に起きます。同じ瞬間に失敗した呼び出しが、同じ間隔で試し直すと、また同じ瞬間に届きます。負荷の山が、リトライの間隔ごとに繰り返し押し寄せます。

Compareリトライの間隔

失敗した瞬間に試し直す

retry after 0ms
呼び出し先の回復時間
**無い**
同時に失敗した呼び出し
**同じ瞬間にまた届く**
利用者の待ち時間
最短
向いている場面
接続の瞬断など、ごく稀な偶然の失敗
注意
  • 冒頭の事故の設定
  • 過負荷の相手には、負荷をそのまま倍にして返すことになる
  • 1回だけなら許される場面もあるが、繰り返すと害になる

間隔を伸ばすことと、ばらすことは別の効果。伸ばすと呼び出し先に回復の時間ができ、ばらすと山が崩れる。両方が要る。

何を試し直してよいか

リトライしてよいのは、もう一度送れば結果が変わりうる失敗で、かつ2回届いても困らない操作だけです。

失敗試し直す理由
タイムアウト、接続の切断する(冪等なら)一時的かもしれない。ただし相手に届いた可能性がある
503、429する。Retry-After があれば従う相手が「後で来て」と言っている
400、401、403、404、422しない何度送っても同じ。送り方が間違っている
500慎重に一時的な場合も、恒久的なバグの場合もある

タイムアウトは「失敗した」ではなく「分からない」です。相手に届いて処理されたかもしれません。冪等でない操作をタイムアウトで試し直すと、二重に実行されます(api/idempotency)。

呼ばない、という選択

リトライは「もう一度呼ぶ」判断ですが、逆に**「しばらく呼ばない」**判断もあります。呼び出し先が明らかに弱っているなら、試し直すより、呼ばずにすぐ失敗として扱うほうが、自分も相手も守れます。

Figureサーキットブレーカー
通常

通常どおり呼んでいる。失敗の割合を数えている

遮断

呼ばずに、すぐ失敗として扱う。代わりの結果を返す

半開様子見

少数の呼び出しだけを試しに通している

1/5
失敗の割合が閾値を超えた直近のタイムアウトや失敗が一定の割合を超えたら開く。ここからは呼び出しを送らないので、待ち時間は0になり、相手にも負荷を送らない。

失敗が続いたら呼ぶのをやめ、しばらくしたら少しだけ試す。呼ばない間は、待たずにすぐ代わりの結果を返す。

開いている間、呼び出し元は待たずに代わりの結果を返せます。相手には呼び出しが届かないので、回復するための時間ができます。半開で少しずつ試すのは、回復しかけの相手に一度に全部を戻すと、また潰してしまうからです。

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

購入の経路で、リトライとタイムアウトをどう決めますか?

与えられた条件
  • 呼び出しは アプリ → BFF → 注文サービス → 外部の決済 API と、注文サービス → 在庫サービス
  • 外部の決済 API は冪等キーに対応している。ときどき 503 と、Retry-After 付きの 429 を返す
  • 在庫サービスの「在庫を引く」API は冪等キーに対応していない
  • 利用者が画面で待てるのは、長くても10秒程度
  • いまは全層が「最大3回・即座に」で試し直している
この軸で考える
  • · どの層で試し直すか。掛け算を避けられるか
  • · 各層のタイムアウトを、上の層の待ち時間の中にどう収めるか
  • · 冪等でない呼び出しを、失敗したときどう扱うか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

ふりかえりで「決済サービスは2分で直ったのに、なぜ40分も止まったのか」と聞かれました。説明してください

与えられた条件
  • 参加者は各サービスの開発者と、プロダクトマネージャー
  • 各サービスのリトライは、それぞれのチームが善意で入れたもの
  • 誰かを責める場ではなく、次に何を変えるかを決めたい

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。

この記事を前提にしている記事