Tech Learning Daily

2026-08-02 (Sun) — 第 17 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Arch 🌿 基礎 ⏱ 約 8 分

同じ注文が二回処理された。メッセージキューの「重複」はなぜ起こるのか?

SQS や RabbitMQ でジョブキューを組んだことがあるなら、ログを見て「同じ処理が二回走っている」と気づいて青ざめた経験があるかもしれない。コードを読み返しても enqueue しているのは一度だけだ。それは壊れているのではなく、多くのキューが仕様として意図的にそう動くように作られている。

🎯 3 行まとめ
  • SQS や RabbitMQ の多くは、メッセージを失うより重複させる方を選ぶ at-least-once 配信を採用しており、重複はバグではなく仕様である
  • 重複が生まれるのは、ブローカーが見ているのが「処理が終わったか」ではなく「確認応答(ack)が返ったか」だけだからだ
  • 対策は配信を完璧にすることではなく、消費側を冪等(同じメッセージを何度処理しても結果が変わらない)に作ることである

キューが「重複させてでも失わない」を選ぶ理由

ブローカーはあるメッセージを消費者に届けたあと、それが本当に処理し終わったのかを、消費者からの確認応答(ack)以外の方法で知ることができない。ネットワークもプロセスも途中で落ちうるという前提に立つと、ブローカーが取れる設計は大きく二つに分かれる。

一つは「ack が返らなければ処理は失敗したとみなし、もう一度届ける」というat-least-once 配信(少なくとも一度は届ける配信方式)。もう一つは「一度送ったら二度と送らない」at-most-once 配信で、この場合は ack を送る直前にネットワークが一瞬切れただけでも、そのメッセージは誰にも処理されないまま消えてしまう。

Amazon SQS は複数のサーバーにメッセージのコピーを冗長に保持しており、そのうちの1台がメッセージの受信・削除のタイミングでたまたま応答不能になると、同じメッセージがもう一度取得されることがある。AWS の公式ドキュメントは、これに備えて「アプリケーションを冪等に設計せよ」と明記している。重複を配信側で防ぎ切るのではなく、起きる前提で無害化するという設計思想である。

ack が返るまでの空白で何が起きているか

SQS ではメッセージが受信された瞬間からvisibility timeout(受信直後のメッセージを他の消費者から一時的に隠す時間)が始まる。既定値は 30 秒、最大 12 時間まで延長できる。処理を終えて明示的に削除する前にこの時間を過ぎると、メッセージは再び見える状態に戻り、同じ消費者にも別の消費者にも改めて配信されうる。

RabbitMQ で手動 ack を使っている場合は仕組みが少し違う。確認応答されなかった配信(未確認メッセージ、unacked message)は、それを受け取ったチャネルや接続が閉じた瞬間——クラッシュ、再起動、例外など——に自動的に再キューされる。

「処理はちゃんと終わったのに、なぜ二重に届くのか」と感じるかもしれない。しかし実際にブローカーが見ているのは処理の完了ではなく、確認応答が返ってきたかどうかだけだ。処理そのものは成功していても、その直後にプロセスが落ちて ack だけが届かなければ、ブローカーの目には「何も処理されていない」場合と区別がつかない。

┌───────┐          ┌────────┐
│ Queue │─receive─▶│Consumer│
└───────┘          └────────┘
    ▲                  │
    │ timeout で       │ 処理完了 直後に
    │ 再可視化           │ crash(ack届かず)
    └──────────────────┘
🍱 たとえるなら

宅配の再配達に近い。配達員が荷物を確かに届けたのに、受け取りサインの控えが配送センターに戻らなかったとする。センターにはサインという「証拠」しか見えていないので、荷物が届いていないものとして扱い、もう一度同じ荷物を送り出す。実際には受取人の玄関先にもう荷物がある。受取人の側が「これはもう受け取った荷物だ」と気づいて弾く仕組みを持たない限り、この重複は止まらない。

冪等性をどう実装に落とし込むか

冪等性とは、同じ操作を複数回実行しても、一度だけ実行したときと同じ結果になる性質のことを指す。決済 API を提供する Stripe は、この性質をリクエスト単位で保証する仕組みとして冪等性キーIdempotency-Key ヘッダーに入れる一意な値)を用意している。最初のリクエストに対するステータスコードと本文をサーバー側で保存しておき、同じキーで再送されたリクエストには実際の処理をやり直さず、保存済みのレスポンスをそのまま返す。このキーは最低 24 時間保持され、同じキーで異なるパラメータのリクエストが来た場合はエラーとして拒否する。キーを付けなければ、リトライのたびに新しいリクエストとして扱われ、決済であれば二重課金につながる。

メッセージキューの消費者でも考え方は同じで、メッセージが表す業務上の識別子(注文 ID など)を使って「処理済みかどうか」を記録するテーブルを持てばよい。

INSERT INTO processed_messages (message_key)
VALUES ('order-42-charge')
ON CONFLICT (message_key) DO NOTHING;
-- 挿入行数が 0 なら「処理済み」なので後続をスキップする

この記録と実際の副作用(課金や在庫更新など)を別々のトランザクションで行うと、「記録だけ成功して副作用は失敗した」という新しい不整合を生む。両者は同じトランザクション内で行う必要がある。

冪等性があっても防げないもの

冪等性は「同じメッセージを複数回処理しても安全」にするものであり、メッセージが届く順序までは保証しない。SQS の標準キューはそもそも順序を保証しないため、"作成" の直後に送った "更新" が先に届くことすらありうる。

順序を保証する FIFO キューを使えば重複からも解放されると思うかもしれない。しかし AWS の公式ドキュメントが明言しているのは、FIFO キューの重複排除が防ぐのは「同じメッセージが 5 分以内に二重にキューへ送信されること」であって、キューから消費者への配信そのものは標準キューと同じ visibility timeout の仕組みで動いている。つまり FIFO キューを使っていても、消費者の処理が長引いて timeout を超えれば、同じメッセージがもう一度配達されうる。

だからこそ冪等性キーには、配信の試行ごとに変わる値ではなく、注文 ID やイベント ID のような業務上不変な識別子を選ぶ必要がある。リトライのたびに新しい UUID を発行してしまうと、キー自体が毎回変わってしまい重複排除が機能しなくなる。

💼 実務でどう出会うか

決済サービスの Webhook が同じイベントを複数回送ってくる、Sidekiq や Cloud Tasks、SQS のジョブワーカーがクラッシュ直後の再起動で同じジョブをもう一度拾う、Kafka のコンシューマーグループがリバランスした際に直前のオフセットがコミットされておらず同じメッセージ群を再処理する——いずれも at-least-once 配信の当然の帰結として、冪等性を前提に設計しておかないと同じ副作用が重複して発生する。

⌨️ 手を動かす(5 分)

RabbitMQ を「ack せずに取得し続ける」ことで、同じメッセージが何度でも「未処理」として取り出せる——at-least-once の正体そのもの——を確認する。

docker run -d --rm --name mq -p 15672:15672 rabbitmq:3-management
sleep 20
AUTH="-u guest:guest -H content-type:application/json"
curl -s $AUTH -X PUT -d '{"durable":true}' \
  http://localhost:15672/api/queues/%2f/demo
curl -s $AUTH -X POST -d '{"properties":{},"routing_key":"demo","payload":"order-42","payload_encoding":"string"}' \
  http://localhost:15672/api/exchanges/%2f/amq.default/publish
curl -s $AUTH -X POST -d '{"count":1,"ackmode":"ack_requeue_true","encoding":"auto"}' \
  http://localhost:15672/api/queues/%2f/demo/get
curl -s $AUTH -X POST -d '{"count":1,"ackmode":"ack_requeue_true","encoding":"auto"}' \
  http://localhost:15672/api/queues/%2f/demo/get
docker stop mq

ackmode=ack_requeue_true はメッセージを取得してもキューに戻す指定なので、2 回の get はどちらも同じ "payload":"order-42" を返す。本物のブローカーでは、処理を終えたあとに ack を送り忘れる(=クラッシュする)だけで、これと同じことが起きる。

🙅 よくある誤解
  • 重複配信が起きるのはキューかネットワークのバグだ — at-least-once 配信を選んでいるキューでは仕様通りの動作であり、対策は配信側ではなく消費側の冪等性である。
  • FIFO キューや Kafka の順序保証があれば重複は起きない — 順序保証と重複排除は別の問題で、visibility timeout のような再配信の仕組みは順序保証キューでも働き続ける。
  • 受信した直後に ack を返しておけば安全 — 処理が終わる前に ack すると、その直後のクラッシュでメッセージの内容は失われたまま二度と再送されない。ack は処理を完了させたあとに送るのが原則である。
📖 用語ミニ辞典
at-least-once 配信
メッセージを失うより重複させる方を許容する配信方式。多くのキューサービスの既定の動作。
冪等性
同じ操作を複数回行っても、一度だけ行った場合と結果が変わらない性質。
visibility timeout
SQS で、受信されたメッセージが他の消費者から一時的に見えなくなる時間。既定は 30 秒、最大 12 時間。
未確認メッセージ (unacked message)
RabbitMQ でまだ確認応答が返っておらず、処理中扱いのままキューが記憶しているメッセージ。
冪等性キー
リクエストやメッセージが表す業務上の操作に対して発行する一意な識別子。これを使って重複実行を検知・無害化する。
🔗 もっと深く