非同期ジョブキューの設計で見落とす『順序保証』と『重複実行防止』の両立
実装の落とし穴から見える設計の難しさ
非同期ジョブキュー(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をパーティションキーにしてキューを分割するアプローチが有効です。
導入判断のポイント
非同期ジョブキューが向くケース:
- 大量のリクエストをさばく必要がある
- 処理時間にばらつきがあり、バッファが必要
- 複数の独立した処理を並列実行したい
向かないケース:
- 厳密な順序保証と高スループットの両立が必須
- 重複実行が絶対に許されない(かつべき等性の設計が困難)
- 処理の成功/失敗をリアルタイムでクライアントに返す必要がある
中小規模の開発組織であれば、最初は「シンプルに、限定的に」導入することをお勧めします。例えば、メール送信やログ集約など、重複や順序の乱れが比較的許容できるタスクから始めるのです。そこで運用ノウハウを蓄積してから、より複雑な用途に拡張する方が、結果的に堅牢なシステムになります。