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

ACKの前に止まったお菓子の自販機

再試行でお菓子の倉庫を追い詰めない

TT Labで続きを見る

一言でいうと

リトライは失敗を消すボタンではありません。どのエラーを、いつまで、あと何回試すのかを決め、そのバジェットを再起動のあとも保存する必要があります。

なぜ必要なのか

学園祭のお菓子の注文サーバーが、一時的に遅くなりました。配達員10人がみな、すぐに送り直し始めると、サーバーには既存の作業にリトライまで重なります。さらに遅くなった応答のためにまたリトライし、本来なら少しで回復するはずの障害が長引きます。前のモジュールで、同じ業務IDの重複した効果を防いだとしても、リクエストを処理するCPUと接続はタダではありません。業務の重複防止と負荷の抑制は、別の問題です。

逆に、すべての失敗を永続的な失敗として捨てると、一時的な接続エラーのせいで、受け付けた注文を失います。そのため、一時的な失敗、永続的な業務エラー、意味のわからない失敗に分けます。ラボのRetryableは、もう一度試せると送信アダプターが分類したエラーで、Permanentは、人が内容を直す必要があるエラーです。わからない例外は、成功や隔離に見せかけず、呼び出し側に伝えます。HTTPステータス1つを、すべてのサービスで同じように解釈する分類器は、今回は作りません。

どう動くのか

1つの業務には、attempts、max_attempts、available_at、deadlineを保存します。attemptsはネットワークの成功回数ではなく、先取り(claim)に成功して試行を開始した回数です。リクエストを送る前にプロセスが落ちても、すでに消費した試行は戻しません。いつ送ったかわからない失敗をタダの試行として扱うと、毎回その位置で落ちることが、無限に繰り返されるおそれがあるからです。このポリシーは、障害の検知と運用者による再調整を前提にした、保守的なバジェットです。

ラボの遅延式は次のとおりです。attemptは1から始まり、jitterは、呼び出し側が選んだ0から1の比率です。整数のミリ秒に切り捨て、上限を先に適用します。

window = min(cap_ms, base_ms * 2**(attempt - 1))
delay_ms = floor(window * jitter)

base=100、cap=1000、jitter=0.5なら、最初の失敗のあとは50ms、2回目のあとは100ms待ちます。base=100、cap=250で、3回目の試行は、400をそのまま使わず、250の半分である125msです。テストは、0と1と小数の比率を直接入れて、境界を確認します。実際の呼び出し側が、毎回0.5だけを使うと、すべてのワーカーが同じパターンで待つので、ランダムな分散の効果が得られません。注入できる比率は、テストを決定的にするための仕掛けであり、乱数生成器そのものを検証したわけではありません。

available_atは、この時刻になってはじめて、もう一度取得できるという予約です。関数の中でsleepしながら、キューの書き込みロックを握ることはしません。失敗の結果をpendingとして保存して呼び出しを終え、あとのワーカーが、到来した予約を取得します。処理する仕事がない呼び出しは、Noneで終わります。ここでは、常時ポーリングするデーモンは作らず、1回の実行単位を実装するので、本番では、起こす・止める・観測するためのポリシーが別に必要です。

deadlineは、新しいリトライのたびに延長しません。now+delayがdeadlineと同じでも、その時点では開始するバジェットがないので、deadlineを理由に隔離します。最大試行に達していたら、exhaustedです。指数バックオフだけがあって、回数や締め切りがなければ、ゆっくり無限に繰り返すプログラムにすぎません。逆に、回数だけを小さくしても、1つの送信関数がいつまでも戻らなければ、呼び出し全体を終えられません。送信アダプターの時間制限は、別に必要です。

現場での姿

障害が起きたときに、リトライを複数の層に重複して入れていないかを見ます。ブラウザーが3回、APIサーバーが3回、外部SDKが3回呼び出すなら、元の業務1回に対して、より多くのリクエストが生じることがあります。1つの層のmax_attemptsだけを見て、システム全体の負荷の上限を断定してはいけません。このラボの最大試行は、jobsの1行のバジェットであり、サービス全体の1秒あたりのリクエスト制限やトークンバケットは実装しません。

時刻をDBに保存するという点も重要です。プロセスの開始から経過した時間は、再起動すると基準点が変わることがあるので、永続的な予約時刻としてそのまま使いません。このラボでは、すべてのワーカーが同じ基準で解釈する整数のmsを、引数として与えます。run_onceは、送信の前後で2回clockを呼び出し、1回の呼び出しの中で時刻が後ろへ戻ったら、エラーとして知らせます。実際の複数のホストの時計の差、再起動と時計の補正は、別の設計の対象です。この検査が、世界中のワーカーの時計を合わせてくれるわけではありません。

観測するときは、リトライ回数だけでなく、待機中の仕事、リース中の仕事、隔離された仕事と、最も長く待っている業務の経過時間を分けます。pendingが減ったからといって、すべて成功したわけではありません。deadに移されたのかもしれません。リースの期限切れで別のワーカーが引き受けたなら、外部へのリクエストが重複することがあるので、前に学んだ受信inboxの同一ID処理が、引き続き必要です。バジェットと重複防止のどちらか1つだけを残しても、十分ではありません。

次の確認ですること

続くクイズで、遅延の上限と締め切りの境界、試行回数の意味を計算します。あとの総合ラボでは、retry_delayと永続キューを実装し、bool・無限大・範囲外の値を拒否します。同じIDでもう一度受け付けても、既存の締め切りと消費した試行が初期化されないかを確認します。

参考: AWSのバックオフとジッターの説明。数量・試行の範囲と、予約・隔離の契約は、このお菓子配信ラボの設計です。