タイムアウトの後にリトライすると何が起きるか
一言でいうと
タイムアウトは、リクエストが失敗したという意味ではなく、結果がわからないという意味です。この違いを見落とすと、リトライが二重決済と二重送信を生みます。
なぜ必要なのか
決済連携で最もよくある事故は、次のとおりです。
- こちらが決済リクエストを送る
- 3秒でタイムアウト。応答が来ない
- 「失敗したようだ」と考えて、リトライする
- 決済事業者では、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つではありません。
リトライのポリシー
やみくもにリトライすると、障害を大きくします。下流が遅くてタイムアウトが起きたのに、全員がすぐにリトライすると、負荷が倍に増えて、完全に崩れます。
- 指数バックオフ: 1秒、2秒、4秒、8秒。間隔を広げて、息をつく余地を与えます。
- ジッター: これにランダム性を混ぜます。そうしないと、すべてのクライアントがまったく同じ時刻に同時にリトライします(thundering herd)。
- 上限: 最大回数と、最大の合計時間を決めます。無限のリトライは禁止です。
- サーキットブレーカー: 連続した失敗がしきい値を超えると、しばらくまったく試行しません。下流が回復する時間を与える仕組みです。
そして、どのエラーをリトライするかを区別します。
| 応答 | リトライ |
|---|---|
| 接続拒否、タイムアウト | はい(冪等であるか、冪等キーがあるとき) |
| 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倍に増えた」のが、ユーザーが増えたのかリトライなのかが、すぐにわかれます。
現場での姿
- 月末の精算で金額が合わない → タイムアウトのリトライで生じた重複。
- 通知が2、3回届く → 送信APIに冪等キーがない。
- 下流が遅くなると全体が崩れた → バックオフとサーキットブレーカーがない。
続くラボですること
処理はするのに、応答だけを失う決済APIを相手に、3回決済してみます。
멱등키 없이 주문 4건 → 기록 7건
비즈니스 행위당 키 하나 주문 4건 → 기록 4건
시도마다 새 키 주문 4건 → 기록 7건 ← 키를 붙였는데도 소용없다
このコードブロックの韓国語は、冪等キーなしは注文4件に対して記録7件、ビジネス行為ごとにキー1つは注文4件に対して記録4件、試行ごとに新しいキーは注文4件に対して記録7件で、キーを付けたのに意味がない、という意味です。
3つ目が、このラボの核心です。キーを付けたという事実だけでは、何も保証されません。