メッセージキューの配信保証とバックプレッシャ
メッセージが消える・二重に届く・順番が狂う事故を設計で防ぐために。ack/nackと可視性タイムアウトの仕組み、順序保証の限界、デッドレター、コンシューマラグから読むバックプレッシャまで、ブローカ内部から原理を解説します。
- 1.ブローカはメッセージを消すのではなく可視性タイムアウトで一時的に隠し、ackで初めて削除する。タイムアウト内にackが返らなければ再配信され、これがat-least-onceと重複の源になる。
- 2.全体順序はパーティション内でしか保証されない。順序を保ちたいキー単位でパーティションを固定し、同時にコンシューマ並列度と再配信が順序をどう崩すかを理解する必要がある。
- 3.コンシューマラグ(未処理メッセージ量)が単調増加すれば処理が生産に追いつかない兆候で、プル型は本質的にラグを通じてバックプレッシャを効かせ、プッシュ型は明示的なフロー制御がないと過負荷で崩れる。
ブローカの中身:キューは「消す」のではなく「隠す」
メッセージキューを「入れたら出てくる箱」と捉えると、再配信も重複も順序崩れも理解できなくなります。正しい内部モデルはこうです。ブローカはメッセージを受け取るとログまたはキューに永続化し、コンシューマへ渡すときに消さずに「処理中」として一時的に不可視化します。コンシューマが処理を終えて ack(肯定応答) を返したときに初めて、ブローカはそのメッセージを削除します。
この一拍の遅れが配信保証のすべての出発点です。なぜ即座に消さないのか。コンシューマは処理の途中でクラッシュしうるからです。渡した瞬間に削除していたら、クラッシュしたメッセージは永久に失われます(at-most-once)。逆に、ack を待ってから消すなら、ack が返る前にコンシューマが落ちても再配信でき、欠落を防げます。その代償が重複です。
受信 → 永続化 → コンシューマへ配信(不可視化、削除はしない)
├─ ack 受領 → 削除(処理完了が確定)
├─ nack 受領 → 即座に再配信 or デッドレターへ
└─ 無応答のまま → 可視性タイムアウト経過後に再び可視化 → 再配信
ack/nack と可視性タイムアウト
メッセージを配信したあと、ブローカはそれを可視性タイムアウト(visibility timeout)の間だけ他のコンシューマから隠します。この時間内に処理を終えて ack すれば削除、明示的に nack(否定応答) すれば即時再配信や後述のデッドレター行きになります。問題は無応答のケースです。タイムアウトが経過するとブローカは「このコンシューマは落ちた」とみなし、メッセージを再び可視化して別のコンシューマへ配信します。
ここに二種類の事故が潜みます。第一に、可視性タイムアウトが処理時間より短いと、まだ生きて処理中のコンシューマの裏でメッセージが再配信され、二重処理が起きます。第二に、タイムアウトが長すぎると、本当にクラッシュしたメッセージの再配信が遅れ、滞留が伸びます。
良くない例: 可視性タイムアウト 30s、実処理 45s
t=0 配信、コンシューマAが処理開始(不可視)
t=30 タイムアウト → 再可視化 → コンシューマBへ配信(二重処理)
t=45 Aが処理完了し ack(だが既にBも処理済み)
対策は、処理がタイムアウトに収まらない見込みなら、コンシューマが定期的に**可視性を延長(heartbeat / change-visibility)**することです。延長しないままタイムアウトを越えれば、ブローカ側からは「死んだ」のと区別がつきません。これは故障検出の根本的な曖昧さ(遅いノードと死んだノードを区別できない)そのものであり、可視性タイムアウトは事実上のフェイルオーバ用故障検出器として働いています。
「処理を確定(DBコミット)してから ack」すれば、ack 前のクラッシュで再配信され at-least-once になります。「先に ack してから処理」すると、ack 後・処理前のクラッシュでメッセージが消え at-most-once に化けます。コミットと ack の順序が配信保証の実体を決めるため、コードレビューで必ず確認すべき点です。重複前提なら受信側を冪等にする必要があります(/devops/idempotency-exactly-once/)。
順序保証はパーティション内に閉じる
「キューに入れた順に出てくる」は、単一プロデューサ・単一コンシューマ・単一キューでしか厳密には成り立ちません。スループットを上げるために**パーティション(シャード)**で並列化した瞬間、保証は弱まります。Kafka 型のログでは、順序が保証されるのは同一パーティション内だけです。複数パーティションにまたがる全体順序(total order)は存在しません。
なぜか。各パーティションは独立した追記ログで、コンシューマはそれぞれを別々の速度で読みます。パーティションAの3番目とパーティションBの1番目の到着順は、ネットワーク遅延や処理速度に依存し決定不能です。これは分散システムで全順序が自明に得られない理由(イベント間に半順序しか存在しない場合がある)と同根です(/devops/logical-clocks/)。
実務の処方箋は、順序を保ちたい単位をパーティションキーにすることです。たとえば「同一ユーザーのイベントは順序を守りたい」なら user_id でパーティションを決め、そのユーザーのメッセージが常に同じパーティション(=同じログ、同じコンシューマ)へ流れるようにします。
| 観点 | 単一パーティション | キー単位パーティション |
|---|---|---|
| 順序保証 | 全メッセージで厳密順序 | 同一キー内のみ順序 |
| 並列度 | コンシューマ1並列に制限 | パーティション数まで並列化 |
| スループット | 低い(直列) | 高い(キー分散で並列) |
| 適する用途 | 厳密な全順序が要る台帳系 | キー内順序で足りる大半の業務 |
注意すべきは、SQS 標準キューのようにそもそも順序を保証しないブローカや、再配信が順序を崩すケースです。あるメッセージの ack が遅れて再配信されると、後続が先に処理されることがあります。コンシューマを並列化した場合も、同一パーティションを単一スレッドで読まない限り処理順は保証されません。「配信順」と「処理完了順」は別物であり、順序が要件なら両方を設計対象にします。
デッドレター:毒メッセージを隔離する
処理に失敗し続けるメッセージ、いわゆる**毒メッセージ(poison message)**を放置すると、再配信ループに陥ります。タイムアウト→再配信→また失敗→再配信、を無限に繰り返し、そのメッセージがコンシューマを占有し、後続を巻き込んで全体のスループットを潰します。
これを断ち切るのが**デッドレターキュー(DLQ)です。ブローカは各メッセージの配信試行回数(receive count / delivery attempts)**を数え、しきい値(例:5回)を超えたメッセージをメインキューから外し、専用のDLQへ退避させます。これによりメインの流れは詰まらず、失敗メッセージは別途調査・修正・再投入できます。
配信試行カウント >= maxReceiveCount なら DLQ へ移送
メイン: [正常] [正常] [正常] ... ← 詰まらず流れる
DLQ: [毒] [毒] ... ← 隔離して後で調査
設計上の勘所は二つです。しきい値は、一時障害(ネットワーク瞬断など)を吸収できる程度に大きく、しかし無限ループにならない程度に小さく取ります。再試行と相性が悪いと過剰なリトライストームになるため、指数バックオフとジッタを併用します(/devops/retry-backoff-jitter/)。もう一つ、DLQの監視を怠るとメッセージが静かに溜まり、欠落と同じ結果になります。DLQの滞留量はアラート対象にすべき一級メトリクスです。
コンシューマラグとバックプレッシャ
ブローカの健全性を一目で測る指標がコンシューマラグ(consumer lag)です。定義はシンプルで、「生産済みだがまだ消費(ack)されていないメッセージ量」、ログ型なら「ログ末尾のオフセットとコンシューマの現在オフセットの差」です。
ラグの水準ではなく傾きを読むのが要点です。リトルの法則の観点では、待ち行列長 L は到着率 λ と滞留時間 W の積(L = λW)に比例し、到着率が処理率(サービス率 μ)を上回る限りキューは際限なく伸びます。すなわちラグが単調増加していれば、処理が生産に構造的に追いついていないという決定的な兆候です。一時的なスパイクで増えても処理率が上回れば減衰して戻ります。この待ち行列の振る舞いは末尾遅延の議論と同じ数理に乗ります(/devops/queueing-theory-tail-latency/)。
ラグ ≈ 0 で安定 : 処理率 >= 到着率(健全)
スパイク後に減衰 : 一時的な遅れ、自己回復する
単調増加(傾きが正) : 到着率 > 処理率(構造的な不足、要増強)
ここで**バックプレッシャ(backpressure)**が登場します。バックプレッシャとは、下流(コンシューマ)の処理能力を超えた負荷を、上流(プロデューサ)へ押し返して流量を抑える仕組みです。これがないと、増え続けるラグはメモリやディスクを食い潰し、最終的にブローカ自体が倒れ、輻輳崩壊に至ります(/devops/congestion-collapse-backpressure/)。キューは負荷の「バッファ」として緩衝はしますが、有界である以上、流入が流出を恒常的に上回ればいつか溢れます。
プル型とプッシュ型:バックプレッシャの効き方が違う
バックプレッシャの効き方は、ブローカがメッセージを**プル型(コンシューマが取りに行く)で渡すかプッシュ型(ブローカが送りつける)**で渡すかで本質的に変わります。
プル型(Kafka、SQS のロングポーリング)では、コンシューマが自分の処理能力に応じた量だけを poll で引き取ります。処理が遅れれば引き取りが減り、ブローカ側でラグが積み上がるだけで、コンシューマは溺れません。バックプレッシャが構造に内包されているのがプル型の長所です。代わりにポーリング間隔ぶんの遅延と、空ポーリングのコストが生じます。
プッシュ型(素朴な AMQP/MQTT 配信)では、ブローカがメッセージを届くそばから送りつけます。速くて遅延は小さいものの、コンシューマの処理が追いつかなくても送信が止まらないため、明示的なフロー制御がないと過負荷で崩壊します。実際のプロトコルはこれを防ぐ仕組みを持ちます。AMQP の prefetch(QoS) は「ack されていないメッセージを最大N件までしか送らない」上限を設け、未ack枠が埋まれば送信を止めます。これは未ack数を信用枠とみなすクレジットベースのフロー制御で、プッシュ型に擬似的なプル型のバックプレッシャを与えます。
| 観点 | プル型(poll) | プッシュ型(送りつけ) |
|---|---|---|
| バックプレッシャ | 構造的に内包(取る量を自分で決める) | 明示制御が必要(prefetch等) |
| 遅延 | ポーリング間隔ぶん大きめ | 小さい(即時配信) |
| 過負荷耐性 | 高い(コンシューマが溺れにくい) | 制御なしだと崩壊しやすい |
| 代表例 | Kafka、SQS ロングポーリング | AMQP/MQTT のpush配信 |
論点は四つに集約されます。(1) 可視性タイムアウトは最大処理時間を上回るか(下回ると二重処理、長すぎると再配信遅延)。(2) 順序要件のキーでパーティションを固定しているか(全順序はパーティション内に閉じる)。(3) DLQのしきい値と滞留監視があるか(毒メッセージで詰まらせない)。(4) バックプレッシャの経路はどこか——プル型ならラグ監視と自動スケール、プッシュ型なら prefetch によるクレジット制御。加えて、ラグの増加に対しコンシューマを増やす自動スケールは制御理論的な遅れを伴うため、振動しない設計が要ります(/devops/autoscaling-control-theory/)。
まとめ
- ブローカはメッセージを消さず隠す。配信時に可視性タイムアウトで不可視化し、ack で初めて削除する。この一拍が再配信・at-least-once・重複の源。
- 可視性タイムアウトは最大処理時間を上回ること。下回れば二重処理、長すぎれば再配信遅延。長い処理は可視性延長で対応する。コミットと ack の順序が配信保証を決める。
- 順序保証はパーティション内に閉じる。順序を守りたい単位をパーティションキーにし、配信順と処理完了順が別物であることを前提に設計する。
- デッドレターキューで毒メッセージを隔離し、メインの流れを守る。しきい値設定とDLQ滞留の監視が要。
- コンシューマラグの傾きが処理不足の決定的な兆候。バックプレッシャは、プル型では構造的に内包され、プッシュ型では prefetch 等のクレジット制御で明示的に与える必要がある。
DevOps/インフラ Article
メッセージキューの配信保証とバックプレッシャを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
メッセージキュー
比較で見る軸
難易度: advanced / カテゴリ: DevOps/インフラ / タグ数: 5
導入後に効く点
全体順序はパーティション内でしか保証されない。順序を保ちたいキー単位でパーティションを固定し、同時にコンシューマ並列度と再配信が順序をどう崩すかを理解する必要がある。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- DevOps/インフラ
- タグ数
- 5
判断チェックリスト
- 自社の用途が「メッセージキュー / 配信保証」に近いか確認する。
- 強みである「ブローカはメッセージを消すのではなく可視性タイムアウトで一時的に隠し、ackで初めて削除する。タイムアウト内にackが返らなければ再配信され、これがat-least-onceと重複の源になる。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。