Redis Pub/Subの深淵:`SUBSCRIBE`とブロッキングI/Oの内部メカニズム
Redisを単なるインメモリ・キーバリューストアとして扱っているうちは、その真価の半分も見えていない。Redisは、極限まで最適化されたイベント駆動型のインメモリ・データ構造サーバーであり、その通信レイヤとデータ構造の統合は、分散システムにおける一つの芸術品である。
今回は、Pub/Sub機構の中核である `SUBSCRIBE` コマンドに焦点を当てる。マニュアルに書いてある「チャネルを購読する」という表面的なお題目ではなく、ソースコードの地殻変動レベルまで潜り込み、クライアントが陥る「ブロッキング状態」の正体、そしてメモリとI/Oの限界を突破するためのアーキテクチャの真髄を解き明かす。
—
1. `SUBSCRIBE` 発行時に何が起きているのか?(内部データ構造の解剖)
クライアントが `SUBSCRIBE channel1 channel2` を叩いた瞬間、Redisサーバー内部では何が起きてきているのか。
Redisのコアにおいて、Pub/Subのルーティング情報は、グローバルな `redisServer` 構造体の中にハッシュテーブルとして保持されている。
// server.h の概念に近い表現
struct redisServer {
dict pubsub_channels; // 辞書: チャネル名 (robj) -> リスト (list of client)
dict pubsub_patterns; // パターン購読用の辞書
// …
};
`pubsub_channels` は、キーがチャネル名(Redis Object)、値がそのチャネルを購読しているクライアント(`client` 構造体)の双方向連結リスト(`list`)である。
`SUBSCRIBE` が実行されると、以下の処理がアトミックに(ただしシングルスレッドのイベントループ内で)実行される:
1. 指定された各チャネルについて、`pubsub_channels` ハッシュテーブルをルックアップする。
2. チャネルが存在しなければ新しく作成し、値となるクライアントリストを初期化する。
3. そのチャネルのクライアントリストの末尾に、現在の `client` 構造体をアペンドする。
4. クライアント側の状態フラグ(`client->flags`)に `CLIENT_PUBSUB` を付与する。
このデータ構造の美しさは、メッセージパブリッシュ時の計算量が $O(N)$ ($N$ はそのチャネルの購読者数)に最適化されている点にある。文字列マッチングのオーバーヘッドはハッシュのO(1)ルックアップによって極限まで削ぎ落とされている。
—
2. クライアントの「ブロッキング状態」の真実
多くのエンジニアが誤解しているが、Redisの `SUBSCRIBE` によるブロッキングは、OSのスレッドブロックやCPUのウェイトを伴うものではない。
イベントループの変質
通常、Redisのクライアント接続は、コマンドを受信し(Read)、実行し、結果を返す(Write)というラウンドトリップのライフサイクルを繰り返す。
しかし、ひとたび `SUBSCRIBE` が発行されると、そのクライアントは 「Pub/Subモード」 という特殊な状態に遷移する。このモードに入った瞬間から、クライアントは通常のCRUDコマンド(`GET`, `SET` など)を発行することが不可能になる(一部のコンテキスト管理コマンドを除き、エラーが返される)。
ネットワークI/Oの観点から見ると、これは次のような状態変化を意味する:
- サーバーのイベントループ(ae.c)は、そのクライアントソケットからの入力(Read)の監視を継続しつつ、出力(Write)バッファのフラッシュに全神経を注ぐようになる。
- 他のクライアントが `PUBLISH` を実行すると、サーバーは `pubsub_channels` から該当チャネルのリストを走査し、マッチした全クライアントの出力バッファ(`client->buf` および `client->reply`)にレスポンスプロトコル(RESP)のメッセージを直接書き込む。
[Publisher] —> PUBLISH —> [Redis Server]
|
+———————–+———————–+
| (pubsub_channels ルックアップ & 配信) |
v v
[Client A (SUBSCRIBE)] [Client B (SUBSCRIBE)]
- client->buf にRESP追加 – client->buf にRESP追加
- 出力バッファ溢れ時はクライアント出力待機 – 同左
ブロッキングの真の脅威:Output Buffer Limits
ここでアーキテクトとして最も警戒しなければならないのが、「スローコンシューマー問題(Slow Consumer Problem)」 である。
Pub/Subにおいて、メッセージの配信は「ベストエフォート」だ。Redisは購読者がメッセージを処理しきれているかどうかなんて待ってくれない。次々と `client->buf` や `client->reply` にデータを積み上げていく。
もし購読側クライアントのネットワーク帯域が細い、あるいはアプリケーションの処理が詰まってTCPウィンドウサイズがゼロになった場合、何が起きるか?
1. Redisサーバーの出力バッファが肥大化する。
2. 設定された `client-output-buffer-limit pubsub` のしきい値を超過する。
3. Redisはメモリ保護のために、そのスローなクライアントの接続を強制切断(強制クローズ)する。
大規模なストリーミング基盤やチャットシステムを設計する際、この制約を無視して `SUBSCRIBE` をばら撒くと、ネットワークのわずかな揺らぎで全購読者が一斉に切断されるというカオスな障害を引き起こす。これが、熟知すべきブロッキング状態の裏に潜む最大の牙である。
—
3. 実務で直面する限界と、それを突破するアーキテクチャ
`SUBSCRIBE` をプロダクション環境で運用する際、単一のRedisインスタンスとコネクションには明確な物理的限界が存在する。
1. コネクション枯渇問題
Pub/Subの購読は、1つの購読につき1つの専用TCPコネクションを占有する。
もし10万人のユーザーがリアルタイム通知を必要とする場合、10万本のコネクションがRedisに直撃する。これはファイルディスクリプタの制限、メモリ上のクライアント構造体オーバヘッド(1クライアントあたり数十KB〜)の観点から、単一インスタンスでは破綻する。
【極限の知見】
高スケーラビリティが求められるシステムでは、RedisのPub/Subを「エンドユーザーへの直接配信」に使ってはならない。
Redisはバックエンドのマイクロサービス間(例:APIサーバー群とワーカー群)のイベントバスに限定し、エンドユーザーへはWebSocketやSSE(Server-Sent Events)に変換するゲートウェイ層(Node.jsやGoで実装された専用プロキシ)を挟むべきである。
2. メッセージの耐久性(Durability)ゼロという仕様の受け入れ
`SUBSCRIBE` は「その瞬間に接続しているクライアント」にしかメッセージを届けない。ネットワーク断の瞬間に流れたメッセージは、永遠に失われる。
KafkaやRabbitMQのようなメッセージブローカーとは異なり、Redis Pub/Subにはオフセット概念もメッセージ永続化もない。
この特性を理解した上で、以下のように使い分けるのがアーキテクトの腕の見せ所だ。
- 完全なリアルタイム性・ロストしても再取得可能なステートレスな通知 $\rightarrow$ `SUBSCRIBE`
- メッセージのロストが許されない非同期処理・イベントソーシング $\rightarrow$ `XADD` / `XREAD` (Redis Streams) を採用する。
—
4. チーフアーキテクトからの提言
Redisの `SUBSCRIBE` は、極限まで無駄を削ぎ落とした洗練されたメカニズムを持つ。しかし、そのシンプルさゆえに、ネットワークの物理法則やメモリの有限性という現実を突きつけられるコマンドでもある。
コードを書くときは、常に以下の問いを自分に投げかけることだ。
> 「今、このコネクションに流し込もうとしているメッセージの速度と、クライアントの消化速度のバランスは保たれているか?」
> 「接続数がスケールした際、Redisの出力バッファはメモリを食い潰さないか?」
これらを制御できて初めて、あなたはRedisを真に手懐けたと言える。おもちゃのように簡単に使えるコマンドの裏側で動いている、C言語の洗練されたポインタ操作とイベントループの息吹を常に感じながら設計を行え。
コメント