Redis Pub/Subの限界と真実:`PUBLISH` の低レイヤアーキテクチャ解体新書
世の多くのチュートリアルは「`PUBLISH channel message` を叩けばメッセージが飛ぶ、簡単ですね」で終わる。だが、我々のような極限のスループットとミリ秒以下のレイテンシを要求される分散システムを設計するアーキテクトにとって、その薄っぺらい理解は致命的な障害となり得る。
Redisの Pub/Sub は、Kafka や RabbitMQ のような「永続化を持つメッセージ・ブローカー」ではない。あれは純粋無垢なインメモリのファンアウト(配信)エンジンである。
今回は、`PUBLISH` コマンドが Redis のシングルスレッドイベントループ上でどのように爆速で処理され、メモリとネットワークの境界で何が起きているのか。その内部メカニズムをコードとアーキテクチャの観点から丸裸にする。
—
1. `PUBLISH` の計算量:$O(N + M)$ の真実
公式ドキュメントには、`PUBLISH` の時間計算量は $O(N+M)$($N$ はチャネルの直接購読者数、$M$ はパターンマッチング購読者数)と記載されている。この数式がシステムアーキテクチャに何を意味するか、深く考えたことがあるか?
Redis内部において、チャネルと購読者の関係は以下のように管理されている。
- 直接チャネル購読 (`SUBSCRIBE`):
`server.pubsub_channels` という `dict`(ハッシュテーブル)構造によって管理されている。キーがチャネル名、値がそのチャネルを購読しているクライアント(`client`構造体)の linked list。
- パターンマッチング購読 (`PSUBSCRIBE`):
`server.pubsub_patterns` という `list` 構造によって管理されている。各要素はパターンとクライアントのペア。
処理のフロー
`PUBLISH` が実行された瞬間、Redisのメインスレッドで以下の処理が同期的に実行される。
1. 直接購読者への配信 ($O(N)$):
`pubsub_channels` から該当チャネルを O(1) でルックアップし、紐づくクライアントリストをイテレート。各クライアントの出力バッファ(Output Buffer)にメッセージのフレームを書き込む。
2. パターンマッチング購読者への照合 ($O(M)$):
`pubsub_patterns` のリストを全走査(linear scan)し、チャネル名がパターンに一致するか `stringmatchlen` で判定。一致すれば、該当クライアントの出力バッファに書き込む。
> ⚠️ 建築的警告:
> $M$(パターン購読者の数)が増大すると、たった1回の `PUBLISH` がメインスレッドをブロックする原因(CPUバウンドなボトルネック)となる。大規模システムで `PSUBSCRIBE` を安易に使うアーキテクトは、自らシステムの急所を握らせているようなものだ。
—
2. 戻り値「購読者数」の罠
`PUBLISH` コマンドの戻り値は、そのメッセージを受信したクライアントの数(整数)である。
127.0.0.1:6379> PUBLISH “events:orders” “order_id:10928”
(integer) 3
この「3」という数値、どこから計算されているか?
答えは単純に、前述のリストの長さの総和(直接マッチ + パターンマッチ)である。
ここで重要なのは、「メッセージがクライアントに正常に届いた(TCPパケットがACKされた)」ことを保証するものでは一切ないという点だ。
Redisの視点における配信完了とは、単に「該当クライアントの出力バッファ(Output Buffer)にペイロードのバイト列をアペンドし、イベントループに書き込みイベント(AE_WRITABLE)を登録した瞬間」を指す。
もし、クライアント側のネットワークが詰まっており、出力バッファの上限(`client-output-buffer-limit`)に達した場合、Redisはそのクライアントを容赦なく切断する。しかし、`PUBLISH` を発行した側には、その非同期的な悲劇は通知されない。Pub/Sub は「完全な火縄銃(Fire-and-forget)」なのだ。
—
3. ソースコードから見る `pubsub.c` の極意
Redisのソースコード(`pubsub.c`)を覗くと、メッセージ配信の核心部である `pubsubPublishMessage` 関数の美しさに気付く。
/ 疑似コードによる概念実装 /
int pubsubPublishMessage(robj channel, robj msg) {
int receivers = 0;
dictEntry de;
listNode ln;
listIter li;
/ 1. 直接チャネル購読者への送信 /
de = dictFind(server.pubsub_channels, channel);
if (de) {
list list = dictGetVal(de);
listIterInit(&li, list, AL_HEAD_NEXT);
while ((ln = listNext(&li)) != NULL) {
client c = listValue(ln);
addReplyPubsubMessage(c, channel, msg);
receivers++;
}
}
/ 2. パターンマッチング購読者への送信 /
if (listLength(server.pubsub_patterns) > 0) {
listIterInit(&li, server.pubsub_patterns, AL_HEAD_NEXT);
while ((ln = listNext(&li)) != NULL) {
pubsubPattern pat = listValue(ln);
if (stringmatchlen(pat->pattern, sdslen(pat->pattern),
channel->ptr, sdslen(channel->ptr), 0)) {
addReplyPubsubMessage(pat->client, channel, msg);
receivers++;
}
}
}
return receivers;
}
このループ構造を見て、何を感じるべきか?
スレッドセーフティのためのロック?Mutex?そんなものは存在しない。Redisのシングルスレッドモデル(Event Loop)が、すべてのデータ構造の整合性を暗黙的に保証している。
マルチスレッド化されたRDBのような複雑なラッチ競合やデッドロックの恐怖から解放された、極限まで洗練された純粋なメモリ操作がここにある。
—
4. ネットワーク層(Output Buffer)の限界とチューニング
`PUBLISH` によって各クライアントの出力バッファに積まれたメッセージは、イベントループの次のサイクルで `ae.c` を通じてソケットに書き出される。
ここで問題になるのが、「遅いコンシューマー(Slow Consumer)」問題だ。
高速なプロデューサーが膨大な `PUBLISH` を送り続け、コンシューマー側の処理が追いつかない場合、Redisサーバー内の当該クライアント向け出力バッファが肥大化し、メモリを圧迫する。
これを防ぐための防衛策として、`redis.conf` でのハードリミット/ソフトリミットの設定が不可欠である。
pubsub クライアントに対する出力バッファ制限の設定例
client-output-buffer-limit
client-output-buffer-limit pubsub 32mb 8mb 60
- hard limit: バッファが 32MB を超えた瞬間、即座にコネクションを切断。
- soft limit & seconds: バッファが 8MB を超えた状態が 60 秒間継続した場合に切断。
アーキテクトとして、Pub/Sub を採用するシステムでは、必ずこの制限値をワークロードに合わせてチューニングしなければならない。デフォルト値のまま本番稼働させることは、時限爆弾を抱えるのと同義である。
—
5. チーフアーキテクトからの最終提言
Redis の `PUBLISH` は、そのシンプルさゆえに過小評価されがちだが、インメモリデータベースのアーキテクチャの美しさが凝縮された機能である。
1. $O(N+M)$ の呪縛を理解せよ: チャネル数とパターンマッチの乱用は、確実にメインスレッドのレイテンシを悪化させる。パターン購読は必要最小限に抑えよ。
2. Fire-and-forget の限界を受け入れろ: 信頼性(At-least-once 配送)が必要なユースケースにおいて、Redis Pub/Sub を選定してはならない。その場合は Redis Streams (`XADD` / `XREADGROUP`) を選択すべきだ。
3. バッファ監視を怠るな: 出力バッファの溢れによる切断を前提としたクライアント側の再接続ロジックと、サーバー側のメモリ制限 (`client-output-buffer-limit`) の設計を徹底せよ。
道具の特性を極限まで理解した者だけが、高可用かつスケーラブルなシステムを構築できる。コードの背後にあるイベントループの息吹を感じながら、次のアーキテクチャ設計に臨んでほしい。
コメント