ジョブキューは同じ処理が2回走る前提で書く|冪等性を実際に試した

ジョブキューは同じ処理が2回走る前提で書く|冪等性を実際に試した

重い処理をジョブキューに逃がすと、レスポンスは速くなります。ただ同期処理では起きなかった問題が、まとめて出てきます。同じ処理が2回走る、失敗したまま誰も気づかない、といったものです。

この記事では、リトライ・冪等性・デッドレターキューを実際に動かして確かめます。以下の出力はすべて実行結果です。

送信側からキュー、ワーカーを経て成功・再投入・デッドレターキューへ分岐する流れと、ackが失われた場合も再配信されることを示した図
失敗したときだけでなく、成功したときも再配信されうるのがポイント
目次

「少なくとも1回」の意味

実務で使うキューのほとんどは at-least-once(少なくとも1回)配信です。1回しか配らないことを保証しません。これは実装の手抜きではなく、分散システムでは避けられない性質です。

重複が生まれるのは、失敗したときだけではありません。

  • ワーカーが処理に成功した直後、ack を返す前に落ちた
  • ack は送ったが、ネットワークの問題でキューに届かなかった
  • 処理に時間がかかりすぎて、キュー側の可視性タイムアウトが切れた

どれも処理そのものは成功しています。それでもキューからは「まだ処理されていない」ように見えるので、もう一度配られます。

3つ目は特に見落とされがちです。ワーカーの処理が重くなってタイムアウトを超えると、1つ目がまだ動いている最中に2つ目が配られます。同時に2つ走る状態になります。

冪等性がないとどうなるか

同じメッセージが2回届いたときに、何が起きるかを実際に動かしました。3,000円の課金処理です。

function handleNaive(msg) {
  charge(msg.orderId, msg.amount);
}
=== 冪等キーなしで、同じメッセージが2回届いた ===
  台帳: [{"orderId":"A-1001","amount":3000},{"orderId":"A-1001","amount":3000}]
  合計: 6000 円

当然ですが、2回課金されます。コードにバグはありません。「1回しか呼ばれない」という前提だけが間違っています。

冪等キーで止める

function handleIdempotent(msg) {
  if (processed.has(msg.idempotencyKey)) return "skipped";   // 2回目以降は何もしない
  charge(msg.orderId, msg.amount);
  processed.add(msg.idempotencyKey);
  return "charged";
}
=== 冪等キーありで、同じメッセージが2回届いた ===
  1回目: charged
  2回目: skipped
  台帳: [{"orderId":"A-1001","amount":3000}]
  合計: 3000 円

設計のポイントはキーの作り方です。

  • 送信側が決める
    ワーカー側で生成すると、再配信のたびに違うキーになって意味がありません
  • 業務的に一意な値から作る
    この例では 注文ID + 操作名。ランダムなIDを毎回振ると、再送のたびに別物になります
  • 操作ごとに分ける
    同じ注文でも「課金」と「メール送信」は別のキーにします

そして記録はメモリではなく、副作用と同じデータストアに置きます。上の例はメモリのSetですが、実際にはワーカーが複数台あり、再起動もします。理想は、課金の記録と冪等キーの記録を同じトランザクションで書くことです。別々だと「課金はしたがキーの記録に失敗した」という状態が生まれます。

データベースを使うなら、冪等キーにユニーク制約を張るのがいちばん確実です。二重挿入は制約違反で弾かれるので、アプリ側で「存在確認してから挿入」を書く必要がなくなります。確認と挿入の間に別プロセスが割り込む余地も消えます。

リトライの待ち方

失敗したらすぐ再試行する、という実装はよくありませんが、その理由は相手が落ちているときに追い打ちをかけるからです。復旧しかけたところに全ワーカーが一斉にリクエストを送れば、また落ちます。

そこで待ち時間を指数的に伸ばします。ただしそれだけでは足りません。同じタイミングで失敗した100件は、同じ計算式なら同じタイミングで再試行するからです。

そこにランダムを混ぜます(ジッタ)。実際に計算させた結果です。

const exp = Math.min(cap, base * 2 ** (attempt - 1));
const wait = Math.round(Math.random() * exp);      // full jitter
   1回目: 上限   1000ms  →  実際に待つ     89ms
   2回目: 上限   2000ms  →  実際に待つ   1690ms
   3回目: 上限   4000ms  →  実際に待つ   1492ms
   4回目: 上限   8000ms  →  実際に待つ      5ms
   5回目: 上限  16000ms  →  実際に待つ   9880ms
   6回目: 上限  32000ms  →  実際に待つ   4237ms
   7回目: 上限  60000ms  →  実際に待つ  43053ms

待ち時間がばらけているのが分かります。上限だけが指数的に伸びて、実際の待ち時間はその範囲でランダムになります。これで再試行が一点に集中しなくなります。

上限(ここでは60秒)を設けるのも重要です。指数のまま伸ばすと、10回目には十数分待つことになります。

工場の梱包ライン。無地の段ボールが金属のフレームを通り、2つが同じ場所で詰まっている
同じものが2回来る前提で組む

リトライしてはいけない失敗がある

ここは実装で間違えやすいところです。何度やっても結果が変わらない失敗を、5回繰り返す意味はありません。

失敗の種類例リトライ
一時的なものタイムアウト、503、接続断、429する
入力が悪いもの400、422、必須項目の欠落しない
権限がないもの401、403しない
対象がないもの404しない

リトライしない種類のものは、即座にデッドレターへ送るのが正しい扱いです。5回失敗するのを待つと、原因の把握が5倍遅れます。

デッドレターキューは「捨て場」ではない

=== 5回失敗したらデッドレターへ ===
  試行 1/5 … 失敗
  試行 2/5 … 失敗
  試行 3/5 … 失敗
  試行 4/5 … 失敗
  試行 5/5 … 失敗
  デッドレター: [{"orderId":"A-1001","amount":3000,
                 "idempotencyKey":"A-1001:charge",
                 "attempts":5,"lastError":"downstream_timeout"}]

デッドレターキューがないと、失敗したメッセージは永久にリトライされ続けるか、黙って消えます。どちらも困ります。前者はリソースを食い続け、後者は「注文が処理されていない」ことに誰も気づきません。

運用するうえで必要なのは3つです。

  • 入ったら通知する
    DLQは放っておくと誰も見ません。1件でも入ったらアラートを出します
  • 原因が分かる情報を一緒に入れる
    メッセージ本体だけでなく、試行回数・最後のエラー・リクエストIDを添えます
  • 直したあとに再投入できるようにする
    DLQから元のキューへ戻す操作を用意しておきます

再投入するときに効いてくるのが冪等性です。「途中まで処理されていたかもしれない」メッセージを安全に流し直せるのは、冪等に作ってあるからです。ここが繋がっていないと、DLQからの再投入自体が怖くてできません。

メッセージに何を入れるか

意外と迷うのがここです。「注文が確定した」というジョブを積むとき、注文の中身を全部入れるのか、注文IDだけ入れるのか。

データを全部入れるIDだけ入れる
ワーカー側DBを読まなくていい毎回DBを読む
読むデータ積んだ時点のもの処理する時点の最新
メッセージサイズ大きい(上限に当たることがある)小さい
リトライ時古いデータのまま再実行される最新で再実行される

基本はIDだけを入れるほうが安全です。理由は3行目と4行目で、リトライが数分後に走ったとき、その間にデータが変わっている可能性があるからです。注文がキャンセルされているのに、古い内容で発送処理が走る、というのが典型的な事故です。

ただしIDだけにすると、ワーカーが処理する時点でレコードが消えていることがあります。この場合は「対象がない」という失敗なので、リトライせずに正常終了として扱うのが妥当です。前述の表でいう「リトライしない失敗」です。

データを入れるとしても、後から変わらないもの(金額の確定値、その時点のスナップショット)に限ると決めておくと判断がぶれません。

可視性タイムアウトを処理時間より長くする

もう1つ、設定として必ず確認したいのがこれです。ワーカーがメッセージを受け取ってから ack を返すまでの猶予時間で、これを超えると「そのワーカーは死んだ」と判断されて別のワーカーに配られます。

処理時間より短く設定されていると、1つ目がまだ動いている最中に2つ目が走ります。しかも処理が重くなるほど発生しやすくなるので、データが増えてから顕在化します。

  • 実測した処理時間の数倍を設定する
  • 長時間かかる処理は、途中で猶予を延長できる仕組みがあればそれを使う
  • そもそも1メッセージあたりの処理を小さく分割するのがいちばん効く

定期実行も同じ問題を持つ

キューだけの話ではありません。cron のような定期実行にも同じ性質があります。

このブログは、週次のアクセスレポートを Cloudflare Workers の cron で動かして、結果をNotionに投稿しています。ここで「同じ週のレポートが2回投稿される」ことは十分に起こりえます。実行環境の再試行、デプロイのタイミング、手動実行の重なりなど、理由はいくらでもあります。

防ぎ方は同じで、「対象週」を冪等キーにします。投稿前にその週のページが既にあるかを確認し、あれば更新するか何もしない。2026-W36 のような値を1つ持つだけで、重複投稿は構造的に起きなくなります。

非同期処理の設計は「1回だけ実行されること」を保証しようとすると難しくなり、「何回実行されても同じ結果になること」を目指すと簡単になります。この置き換えが、この分野でいちばん大きい考え方の転換だと思います。

まとめ

  • キューは「少なくとも1回」
    成功した処理も再配信されうる
  • 冪等キーは送信側が、業務的に一意な値から作る
    ワーカー側で作ると意味がない
  • キーの記録は副作用と同じトランザクションで
    ユニーク制約に任せるのが確実
  • バックオフにはジッタを混ぜる
    指数だけだと再試行が一点に集中する
  • リトライしても無駄な失敗は即DLQへ
    400番台を5回試さない
  • DLQは通知と再投入までがセット
    入れっぱなしなら消しているのと同じ
  • cron も同じ
    「1回だけ」ではなく「何回でも同じ結果」を目指す

次に読む記事

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

わどこんのアバター わどこん

実務12年のバックエンド・インフラエンジニア。バックエンド開発からクラウド・インフラの設計・構築・運用まで担当しています。主要言語は Java・Kotlin・PHP・Python。運用の現場で拾った知見を、再現できる手順に落として残すのがこのブログのテーマです。

目次