非同期ジョブキューの設計で見落とす『順序保証』と『重複実行防止』の両立

実装の落とし穴から見える設計の難しさ

非同期ジョブキュー(RabbitMQ、Redis、SQS など)は、今やほとんどのシステムで使われています。スケーラビリティが必要な場面では自然な選択肢ですし、トレンドでもあります。

ただ現場では、キューを導入したはいいが、運用が始まると予想外の問題が浮上することが多いです。特に「同じジョブが重複して実行される」「ジョブの順序がバラバラになって整合性が崩れる」という2つの課題が同時に発生すると、対応が非常に難しくなります。

この2つは一見すると相反する要件に見えます。だから設計段階で「どちらを優先するのか」という判断を明確にしておかないと、実装が進むにつれて矛盾が深刻化していくのです。

「順序保証」と「重複実行防止」は両立しない場合がある

まず整理しておきたいのは、この2つが常に両立できるわけではないということです。

順序保証が必要な例:

  • 在庫減少 → 予約確定 → 請求書生成という一連の処理
  • ユーザーの登録 → メール送信 → ポイント加算
  • 注文確定 → 倉庫システム連携 → 配送手配

重複実行防止が必要な例:

  • 決済確定処理(同じ決済が2回実行されると二重請求になる)
  • ポイント加算(重複するとユーザーが過度に得をする)
  • 在庫の確保(同じ在庫が複数回確保されると矛盾する)

ここで大事なのは、これらが「同じジョブ内で両立する」のではなく、「システム全体の設計判断として、どちらを優先するか決める」という段階があるということです。

現場で起きやすい判断の誤り

多くの場合、開発チームは以下のような判断をしてしまいます:

パターンA:「両方やれば大丈夫」と考える

ジョブキュー側で順序保証を有効にして、かつアプリケーション側でべき等性(同じ処理を何度実行しても結果が同じ)を実装する、という発想です。

理想的に聞こえますが、実務では問題があります。キューの順序保証機能(例えば FIFO キューや単一コンシューマー)は、スループットを大きく制限します。複数ワーカーで並列処理できなくなるため、スケーラビリティが失われるのです。

パターンB:「重複は許容できる」と楽観的に考える

ジョブの重複実行が起きても、べき等性で吸収できるだろうと想定します。

しかし実際には、べき等性の実装は想像以上に難しいです。特に外部システムとの連携が入ると、「1回目は成功したが、2回目の実行時に外部システムの状態が変わっていた」という状況が発生しやすくなります。

パターンC:「順序は絶対」と考えて設計を厳しくしすぎる

結果として、スケーラビリティが必要な場面でキューを導入した意味が薄れてしまいます。

実装観点での現実的な判断軸

では、実際にはどう判断すればいいのか。現場での経験では、以下の軸で切り分けるのが有効です。

1. ジョブの粒度を細かくする

「在庫減少 → 予約確定 → 請求書生成」を1つのジョブにするのではなく、各ステップを別のジョブに分割します。

ジョブA: 在庫確保(べき等性で重複防止)
  ↓ 成功したら
ジョブB: 予約確定(べき等性で重複防止)
  ↓ 成功したら
ジョブC: 請求書生成(べき等性で重複防止)

こうすると、各ジョブは独立して重複実行防止の設計ができます。順序は「前のジョブが成功したら次を送信する」という上位層のロジックで保証します。

2. べき等性の実装を明示的に設計する

重複実行を許容する場合、べき等性は「運任せ」にしてはいけません。明示的に設計します。

例えば、決済確定ジョブの場合:

def process_payment(job_id, user_id, amount, idempotency_key):
    # idempotency_key をキーに、既に実行済みか確認
    result = cache.get(f"payment:{idempotency_key}")
    if result:
        return result  # 2回目以降は同じ結果を返す
    
    # 実際の決済処理
    payment_result = external_payment_api.charge(user_id, amount)
    
    # 結果をキャッシュ(TTL は十分長く)
    cache.set(f"payment:{idempotency_key}", payment_result, ttl=86400)
    
    return payment_result

この方式では、同じ idempotency_key で複数回実行されても、2回目以降はキャッシュから結果を返すため、外部システムには1回だけ請求が行われます。

3. 「順序が重要」と「スケーラビリティが重要」を分離する

同じシステム内でも、ジョブの種類によって戦略を変えます。

  • 順序が絶対に必要な処理:単一キュー、単一ワーカーで処理(スループット低くても許容)
  • 重複実行防止が重要な処理:複数キューパーティション、複数ワーカーで並列処理(べき等性で保護)

例えば旅行予約システムでは、同じユーザーの予約申し込みは順序が重要ですが、異なるユーザーの申し込みは並列処理できます。この場合、ユーザーIDをパーティションキーにしてキューを分割するアプローチが有効です。

導入判断のポイント

非同期ジョブキューが向くケース:

  • 大量のリクエストをさばく必要がある
  • 処理時間にばらつきがあり、バッファが必要
  • 複数の独立した処理を並列実行したい

向かないケース:

  • 厳密な順序保証と高スループットの両立が必須
  • 重複実行が絶対に許されない(かつべき等性の設計が困難)
  • 処理の成功/失敗をリアルタイムでクライアントに返す必要がある

中小規模の開発組織であれば、最初は「シンプルに、限定的に」導入することをお勧めします。例えば、メール送信やログ集約など、重複や順序の乱れが比較的許容できるタスクから始めるのです。そこで運用ノウハウを蓄積してから、より複雑な用途に拡張する方が、結果的に堅牢なシステムになります。