アーキテクチャ●●●●○

メッセージキュー

Message Queue ・ めっせーじきゅー

処理の依頼をいったん行列に積み、受け手が自分のペースで取り出す仕組み。波をならす緩衝材。

✦ 30秒で試す — 航海処理が追いつかないとき、行列はむしろ味方になる殺到のあいだ、利用者への応答時間は?予想してみる

概要

メッセージキューとは、処理の依頼(メッセージ)をいったん「行列(キュー)」に積んでおき、別のプログラムが後から順に取り出して処理する仕組みです。メッセージを積む側をプロデューサ(生産者)、取り出して処理する側をコンシューマ(消費者)と呼び、両者の間に立つキュー本体はブローカと呼ばれる専用のミドルウェア(RabbitMQ、Amazon SQS、Apache Kafka など)が担います。

たとえば会員登録の完了メール。ユーザーへの応答は、メールが実際に送られるのを待つ必要はありません。サーバは「メール送信」という依頼をキューに積んですぐ応答を返し、別のワーカープロセスが後からキューを読んで送信します。画像や動画の変換、帳票の生成、外部 API への連携など、「今すぐ結果が要らない処理」をリクエストの外へ逃がすのが典型的な使い方です。

本質は、送り手と受け手を時間的にも構造的にも切り離すことにあります。送り手は受け手が今動いているかを知らなくてよく、受け手は自分のペースで処理できます。互いの都合に引きずられない、この疎結合(結合度と凝集度でいう結合の弱さ)こそが、メッセージキューがシステム設計の中心的な道具である理由です。

なぜ生まれたか

システム同士を直接つなぐ同期的な連携には、宿命的な弱さがあります。呼び出し先が落ちていれば呼び出し元も失敗し、呼び出し先が遅ければ呼び出し元も待たされます。つながったシステムは、可用性と速度の運命を共有してしまうのです。企業内の多数のシステムを連携させる場面ではこの問題が深刻で、1990年代からメッセージ指向ミドルウェア(1993年の IBM MQSeries など)が「相手が今いなくても届く、非同期の郵便受け」としてこの結合を断つ役割を担ってきました。製品ごとにばらばらだった通信方式は、のちに AMQP のような標準プロトコルにまとめられていきます。

Web の時代には、もう一つの動機が加わります。負荷の波です。テレビ放映やセールで瞬間的にリクエストが殺到したとき、すべてを同期処理していればサーバは波の高さそのままの負荷を受けて倒れます。キューを挟めば、積まれる速度がどれだけ速くても、処理する速度は自分たちで決められます。波を行列に変えて平らにならす — この緩衝材としての価値が、2006年に公開された Amazon SQS のようなクラウドのキューや、2011年にオープンソース化された Kafka を通じて、メッセージキューを大規模 Web システムの標準部品に押し上げました。

詳細

積み降ろしの基本フロー

基本の流れは単純です。プロデューサはブローカにメッセージを送り、ブローカがそれを受領(永続化)した時点で自分の仕事を終えて、ユーザーに応答を返せます。コンシューマはブローカからメッセージを取り出して処理し、完了したら確認応答(ack)を返します。ここが肝心な点で、メッセージがキューから消える条件は「取り出された」ことではなく「ack が届いた」ことです。取り出されたメッセージはしばらく他の受け手から見えなくなるだけで(Amazon SQS ではこれを可視性タイムアウトと呼びます)、ack が来ないまま時間が過ぎれば、再びキューに戻って別のコンシューマに再配達されます。

コンシューマブローカのキューWebサーバブラウザコンシューマブローカのキューWebサーバブラウザ取り出し中は他から見えない自分のペースでメールを送るack を受けて初めて削除する会員登録を送信メール送信の依頼を積む受領 永続化した登録完了をすぐ返す次のメッセージを取り出す処理完了の ack

キューが「負荷の波」と「受け手の障害」を吸収する様子を、下の部品で確かめてください。Webサーバを長押しすると注文が殺到し、粒がキューに積もります。処理中の受け手を落とすと、ack の返っていない注文がどうなるかが見えます。

⚡ 体験: 注文の波をキューで受け止める
Webサーバ長押しで殺到ユーザーへの応答5 msキュー(ブローカ)— 待ち 0件先頭ack待ち受け手1待機中受け手2待機中+ 受け手を足す+ 受け手を足す待ちの件数(直近20秒)先頭が待った秒数
待ち0件ack待ち0件処理済み0件再配達0回

いまは注文がぽつぽつ届き、受け手2人がすぐに片づけています。左のWebサーバを長押しして、注文を殺到させてください。

この構造から、スケーリングの柔軟さが生まれます。キューが積み上がってきたらコンシューマの台数を増やせばよく、夜間は減らせばよい。負荷対策が「受け手の台数」という1つのつまみに集約されるのです。キューの長さ(滞留数)と、先頭のメッセージが待っている時間は、システムの健全性を示す代表的な監視指標になります。

二つの配送モデル

メッセージングには大きく二つのモデルがあります。一つはキュー型(ポイントツーポイント)で、1件のメッセージは複数のコンシューマのうちどれか1つだけが処理します。ジョブの分担に向く形で、受け手を足せばそのまま処理が速くなります。もう一つはパブサブ型(publish/subscribe)で、1件のメッセージ(イベント)を購読者全員がそれぞれ受け取ります。「注文が確定した」という一つの出来事を、在庫・通知・分析のサービスがそれぞれ独立に処理する、といったマイクロサービスのイベント駆動の連携はこちらの形です。

キュー型 — 1件を1人が処理送り手456キュー123受け手A受け手B受け手C仕事の分担。受け手を足せば速く掃けるパブサブ型 — 全員が受け取る発行者トピックEEE在庫通知分析出来事の配信。購読者を足しても互いに影響しないログ型(Kafka など)— 消さずに残し、読んだ位置を購読者ごとに持つ古い0123456789← 末尾に追記分析の位置在庫の位置
キュー型は1件を1人が処理し、パブサブ型は1件を全員が受け取る。ログ型は消さずに残し、読んだ位置を購読者ごとに持つ

Kafka のようなログ型のブローカは、図の下段のようにメッセージを ack で消さず、保持期間のあいだ残しておきます。コンシューマ側が「どこまで読んだか」(オフセット)を持つ方式なので、購読者を後から足しても過去から読めますし、位置を巻き戻せば再処理(リプレイ)もできます。キュー型の「処理したら消える」とは、ack の意味そのものが違うのです。

配送保証 — at-least-once と冪等性

メッセージキューを使うなら、配送保証を設計に織り込む必要があります。多くのブローカの現実的な保証は「少なくとも1回(at-least-once)」、つまり同じメッセージが2回届くことがあるという意味です。受け手が処理を終えた直後、ack を返す前に落ちれば、処理済みのメッセージが再配達されるからです。逆に、取り出した瞬間に消す「高々1回(at-most-once)」にすれば二重はなくなりますが、今度は途中で落ちたメッセージが失われます。

では「ちょうど1回(exactly-once)」はどうか。Kafka は2017年に、重複を捨てる冪等なプロデューサと、読み取り・加工・書き込みを1つにまとめるトランザクションを導入し、「Kafka から読んで Kafka に書く」範囲では1回きりの効果を実現しました。ただしその保証はブローカの内側に閉じています。受け手がメールを送る、外部の決済 API を呼ぶといった外の世界への副作用までは面倒を見てくれません。したがって実務の鉄則は変わらず、受け手は同じメッセージを2回処理しても結果が変わらないように冪等に作ります。メッセージに一意の ID を持たせ、処理済みの ID を記録して2回目を読み飛ばすのが定番です。

順序も無条件には保証されません。複数のコンシューマが並行処理すれば完了の順は前後しますし、再配達された1件は後続に追い越されて遅れて戻ってきます。順序が本質的な処理では、同じ注文のメッセージを同じパーティション(Kafka)や同じメッセージグループ(SQS FIFO)に集めて、その中だけ順序を守らせる設計が必要です。

典型的な落とし穴と定番の対策

何度処理しても失敗し続ける「毒メッセージ」は、再配達のたびに先頭へ戻って受け手を占有します。規定回数失敗したメッセージを退避させるデッドレターキュー(DLQ)を用意し、退避されたものは人が調べます。受け手の処理能力を超えてキューが積み上がり続けるのは、緩衝材が「遅延の貯金箱」に変わったサインです。一時的な波なら待てば掃けますが、平均の流入が処理能力を上回っているなら、受け手の増強か、入口で流入を絞るバックプレッシャーが要ります。

可視性タイムアウトの長さも罠になります。処理時間より短いと、まだ処理中のメッセージが「落ちた」とみなされて別の受け手にも配られ、同じ仕事が並行して二重に走ります。逆に長すぎると、本当に落ちたときの再配達が遅れます。処理が長引くときはタイムアウトを延長する API を呼ぶのが定石です。

非同期化はユーザー体験の設計も変えます。「注文を受け付けました(処理は完了していない)」という状態をどう見せるか、処理の失敗を後からどう通知するか。受け手が書き込んだ結果が他の画面に現れるまでには時間差があり、システム全体は結果整合性で動くことになります。技術だけでなく、体験側の設計が伴って初めて完成します。

使いどころの見極め

すべてを非同期にすればよいわけではありません。残高照会のように「今の答え」が必要な処理は同期が自然ですし、キューを挟むことは、ブローカという新たな運用対象と、分散ならではの考えること(重複・順序・時間差)が増えることを意味します。判断の軸は「その処理の結果を、リクエストの応答に含める必要が本当にあるか」です。なくてよいなら、キューに逃がすことで応答は速くなり、システム同士は互いの障害から守られます。

試問

左の Web サーバが注文を受け付け、キューに積んで利用者にすぐ応答します。右の受け手(いまは2人)がキューの先頭から1件ずつ取り出して処理し、1人がさばけるのは約0.7件/秒です。Web サーバを長押しすると注文が殺到し、点線の枠を押すと受け手を足せます(処理中の受け手を押すと落ちます)。

✦ 命題挑戦中

注文を15件以上殺到させても、どの注文もキューの先頭で15秒以上待たせずに掃き切れ

  • 注文を殺到させ、キューに15件以上ためる(最大 0件)
  • そのキューを空になるまで掃き切る
  • その間ずっと、先頭の注文の待ちを15秒以内に保つ(最長 0.0秒)
⚡ 体験: 注文の波をキューで受け止める
Webサーバ長押しで殺到ユーザーへの応答5 msキュー(ブローカ)— 待ち 0件先頭ack待ち受け手1待機中受け手2待機中+ 受け手を足す+ 受け手を足す待ちの件数(直近20秒)先頭が待った秒数
待ち0件ack待ち0件処理済み0件再配達0回

いまは注文がぽつぽつ届き、受け手2人がすぐに片づけています。左のWebサーバを長押しして、注文を殺到させてください。