TT Lab
はじめる
学ぶ 学習パス コース

統合とデプロイ

タイムアウトの後にリトライすると何が起きるか

TT Labで続きを見る

一言でいうと

タイムアウトは、リクエストが失敗したという意味ではなく、結果がわからないという意味です。この違いを見落とすと、リトライが二重決済と二重送信を生みます。

なぜ必要なのか

決済連携で最もよくある事故は、次のとおりです。

  1. こちらが決済リクエストを送る
  2. 3秒でタイムアウト。応答が来ない
  3. 「失敗したようだ」と考えて、リトライする
  4. 決済事業者では、2回承認される

3つ目の判断が、間違っていました。応答を受け取れなかったことと、処理されなかったことは、違います。リクエストは到着し、処理もされ、応答だけが失われた可能性があります。

これを、分散システムでは不確実な結果(indeterminate)と呼びます。成功でも失敗でもない3つ目の状態で、この状態を扱う方法が別にあります。

リトライしてよいものと、いけないもの

核心の質問は、「この操作は冪等か」です。同じリクエストを2回送っても、結果が1回送ったときと同じなら、冪等です。

操作 冪等か リトライ
GET /orders/123 はい 自由に
PUT /orders/123 {status:"PAID"} はい(同じ値を書く) 安全
DELETE /orders/123 はい(2回目はない) 安全
POST /orders {…} いいえ 危険。冪等キーが必要
POST /payments {amount:1000} いいえ 危険
残高の差し引きbalance -= 1000 いいえ 危険

HTTPメソッドの冪等性は、規約であって保証ではありません。サーバーがPUTでカウンターを増やすなら、そのPUTは冪等ではありません。ドキュメントではなく動作を確認する必要があります。

冪等キー

冪等でない操作を安全にリトライするには、クライアントがリクエストごとに一意のキーを作って送ります。

POST /payments
Idempotency-Key: 3f9a1c7e-2b44-4c9d-a1f8-0d6e2b7c5a11
{"order_id": 123, "amount": 1000}

サーバーは、このキーを保存しておき、同じキーで再度来たら、新しく処理せず、保存しておいた結果をそのまま返します。リトライが何回でも、決済は1回です。

キーを作るときに、注意点があります。リトライするときに同じキーを使う必要があります。試行のたびに新しいUUIDを作ると、何の意味もありません。キーは、「このビジネス行為」に対して1つであって、「このHTTPリクエスト」に対して1つではありません。

リトライのポリシー

やみくもにリトライすると、障害を大きくします。下流が遅くてタイムアウトが起きたのに、全員がすぐにリトライすると、負荷が倍に増えて、完全に崩れます。

そして、どのエラーをリトライするかを区別します。

応答 リトライ
接続拒否、タイムアウト はい(冪等であるか、冪等キーがあるとき)
429 Too Many Requests はい。Retry-Afterを守って
500、502、503、504 たいていはい
400、422(リクエストが誤っている) いいえ。何回送っても同じです
401、403 いいえ。認証情報を直す必要があります

調査するときのシグナル

重複が疑われるときは、次のように探します。

SELECT order_id, count(*) FROM payments
GROUP BY 1 HAVING count(*) > 1;

そして、その重複した件のcreated_atの間隔を見ます。間隔がリトライのバックオフと似ているなら(1秒、2秒、4秒)、原因はほぼ確定です。

リトライが障害を大きくする仕組み

リトライは、うまく使えば一時的な失敗を隠してくれますが、誤って使うと、小さな障害を大きな障害に育てます。育てる経路は決まっています。

層ごとにリトライすると、掛け算になります。クライアントが3回、ゲートウェイが3回、サービスが3回リトライすると、1回のユーザーリクエストが、上流に27回の負荷として届きます。上流が遅くて始まったことが、上流をさらに遅くします。原則は、1つの層でだけリトライすることで、たいていは、ユーザーに最も近い層がその場所です。

同じ間隔でリトライすると、波になります。障害中に失敗したリクエストが、ちょうど1秒後に、一斉に戻ってきます。回復しかけていたサービスが、その波で再び倒れます。指数バックオフにランダムなジッターを混ぜる理由が、これです。

delay = min(cap, base * 2 ** attempt) * (0.5 + random.random() * 0.5)

サーキットブレーカーがないと、リトライは止まりません。上流が完全に死んでいるときは、リトライに何の意味もなく、リソースを燃やすだけです。失敗率がしきい値を超えたら、しばらく試行そのものを止め、ときどき1件だけを流して回復を確認します。すばやく失敗するほうが、ユーザーにとってもよいです。30秒待ったあとのエラーより、すぐに来るエラーのほうが、リトライしやすいからです。

期限(deadline)を伝播させます。ユーザーが5秒待つなら、そのリクエストが通るすべての区間が、残り時間を知っている必要があります。残り時間が200msなのに、3秒かかる呼び出しを新しく始めるのは、無駄です。gRPCのdeadlineや、ヘッダーで渡した期限時刻を、各層が確認します。

リトライできないエラーは、リトライしません。400・401・404・409は、何回再送しても同じです。リトライする価値があるのは、429・502・503・504と接続エラーだけで、429にはRetry-Afterが付いてくるので、その値を守ります。

リトライしたリクエストに目印を残します。ログとヘッダーに試行番号を入れておけば、障害調査で、「リクエストが3倍に増えた」のが、ユーザーが増えたのかリトライなのかが、すぐにわかれます。

現場での姿

続くラボですること

処理はするのに、応答だけを失う決済APIを相手に、3回決済してみます。

멱등키 없이             주문 4건 → 기록 7건
비즈니스 행위당 키 하나   주문 4건 → 기록 4건
시도마다 새 키           주문 4건 → 기록 7건   ← 키를 붙였는데도 소용없다

このコードブロックの韓国語は、冪等キーなしは注文4件に対して記録7件、ビジネス行為ごとにキー1つは注文4件に対して記録4件、試行ごとに新しいキーは注文4件に対して記録7件で、キーを付けたのに意味がない、という意味です。

3つ目が、このラボの核心です。キーを付けたという事実だけでは、何も保証されません。