PSUBSCRIBEの深層:パターンマッチングPub/Subが孕む美しさと「破滅的なスケール依存性」の正体
Redisを単なる「インメモリKVS」として扱っているうちは、その真価の半分も見えていない。Pub/Sub、とりわけパターンマッチングを駆使する `PSUBSCRIBE` は、イベント駆動アーキテクチャの骨格をダイナミックに変える麻薬のような機能だ。
だが、チーフアーキテクトとして警告しておこう。
`PSUBSCRIBE` は、設計の怠慢を隠すための銀の弾丸ではない。むしろ、使い方を誤れば単一スレッドのCPUコアを完全に窒息させ、クラスタ全体を沈没させる時限爆弾になりうる。
今回は、教科書的なコマンドの使い方を説明する気はない。C言語のソースコードレベル(`pubsub.c`)の挙動、メモリ構造、そしてワイルドカードの裏で何が起きているのかを、限界まで深掘りする。
—
1. 内部データ構造:O(N) の呪縛とパターンの実体
通常の `SUBSCRIBE`(チャネル名による完全一致)は、内部のハッシュテーブル(`server.pubsub_channels`)で管理されているため、ルーティングの計算量は実質的に $O(1)$ に近い。
しかし、`PSUBSCRIBE` の世界は全く異なる。
Redisのソースコードを覗いたことがある者なら知っているはずだ。すべてのパターン(例: `orders..created`)は、連結リスト(Linked List)である `server.pubsub_patterns` に線形に格納される。
/ pubsub.c の内部構造体イメージ /
typedef struct pubsubPattern {
client client;
robj pattern;
} pubsubPattern;
ここから導き出されるアーキテクチャ上の事実を直視してほしい。
- 完全一致(`SUBSCRIBE`): ハッシュルックアップ。チャネル数が数百万あっても高速。
- パターンマッチ(`PSUBSCRIBE`): 線形探索(Linear Scan)。
`PUBLISH` コマンドが発行された瞬間、Redisサーバー内部では何が起きているのか?
1. まず、`pubsub_channels` を引いて通常サブスクライバーにメッセージをばら撒く。
2. 次に、アクティブなすべてのパターン(`server.pubsub_patterns`)を先頭から順に走査し、グロブパターン(Glob-style pattern)とのマッチング判定(`stringmatchlen`)を行う。
つまり、`PSUBSCRIBE` によるパターンマッチングの計算量は、「アクティブなパターンの総数 ($P$)」に完全に比例($O(P)$)する。
何千ものワイルドカードパターンを登録した瞬間、すべての `PUBLISH` のオーバーヘッドが線形に跳ね上がる。これを「スケールの罠」と呼ぶ。
—
2. ワイルドカードの仕様と評価エンジンの実態
Redisの `PSUBSCRIBE` で使えるワイルドカードは、POSIXの `fnmatch()` に類似した独自のグロブパターン実装(`util.c` 内の `stringmatchlen`)に依存している。
利用可能な構文のおさらい:
- `h?llo` matches `hello`, `hallo`, `hxllo`
- `hllo` matches `hllo`, `heeeeello`
- `h[ae]llo` matches `hello`, `hallo`, but not `hillo`
ここでエンジニアとして警戒すべきは、「どのワイルドカードも一律のコストではない」という点だ。
特に “ や `?` をパターンの先頭(Prefix)や中間に入れた場合、最適化の余地がほとんどない。例えば `123` のようなパターンは、すべてのチャネル名に対して文字列の後方一致や部分一致の評価を強制する。
悪夢のアンチパターン:
すべてのイベントをキャッチしようとしてこれをやると地獄を見る
PSUBSCRIBE .event..user.
このような「全方位ワイルドカード」は、チャネル名が流れるたびにCPUのキャッシュを汚染し、Redisの生命線であるシングルスレッドの処理スループットを確実に蝕む。
—
3. メモリとクライアントバッファの爆発(Output Buffers)
Pub/Subアーキテクチャにおける最大の見落としは、「スローサブスクライバー(Slow Subscriber)問題」だ。
`PSUBSCRIBE` を行うクライアントは、往々にして大量のメッセージを広範囲に受信する。もし、クライアント側の処理が追いつかず、TCPのソケットバッファが溢れ始めたらどうなるか?
Redisはクライアントごとに「出力バッファ(Output Buffer)」を持っている。
通常のKVS操作であれば応答を返せばバッファは解放されるが、Pub/Subではメッセージが生成されるたびに、購読している全クライアントの出力バッファにポインタ(または複製)が積み上げられていく。
[Publisher] —> (PUBLISH) —> [Redis Server]
│
┌────────────────────────┴────────────────────────┐
▼ ▼
[Normal Subcriber] [PSUBSCRIBE: Slow Consumer]
(Buffer: 10KB – Healthy) (Buffer: 500MB – GROWING…)
│
[OOM Killer / Crash]
`PSUBSCRIBE` は意図せず膨大な数のメッセージをヒットさせやすいため、特定のクライアントのバッファ肥大化を誘発しやすい。
これを防ぐためには、`redis.conf` で厳格なクライアント出力バッファ制限を設定することが、プロダクション環境における最低限の護身術となる。
pubsub カテゴリのクライアントに対するハードリミットとソフトリミットの強制
client-output-buffer-limit pubsub 32mb 8mb 60
- 瞬発的なバッファ消費が 32MB を超えるか、8MB 超えの状態が 60秒続いた場合、Redisはそのコネクションを強制切断する。これを設定していない `PSUBSCRIBE`運用は、ロシアンスルーレットに等しい。
—
4. クラスタ環境(Redis Cluster)における致命的な挙動
Redis Cluster(分散環境)において、`Pub/Sub`系コマンドは少し特殊な挙動をする。
Pub/Sub メッセージは、クラスタ内のすべてのノードにブロードキャストされる。
あなたがどのノードに対して `PSUBSCRIBE` を発行しようとも、クラスタ内の1つのノードで `PUBLISH` が実行されると、そのメッセージはGossipプロトコルや内部のクラスタバスを通じて、すべてのマスターノードへ転送され、それぞれのノードでパターンマッチングの評価が行われる。
つまり、
- シャーディング(データの分散)の恩恵を Pub/Sub は一切受けない。
- クラスタをスケールアウトしても、Pub/Sub のネットワーク帯域とCPU負荷はノード数倍に増幅(Amplification)される。
このアーキテクチャ上の制約を知らずに、マイクロサービスのイベントバスとして Redis Cluster + `PSUBSCRIBE` を安易に導入すると、ノード間の内部トラフィックが爆発し、クラスタ全体がクラッシュする。
—
5. チーフアーキテクトからの提言:いつ、どう使うべきか
ここまで `PSUBSCRIBE` の暗黒面を語ってきたが、もちろん正しく使えば強力なツールである。
1. パターンの総数を極力絞れ
システム全体で `PSUBSCRIBE` のパターン数は数十個、多くても100個以内に抑えろ。数千のパターンを動的に登録するような設計は、即座に捨て去るべきだ。
2. イベントバスとしての限界をわきまえろ
メッセージの永続化が必要な場合や、コンシューマーの障害耐性(At-least-once配送)が求められる場合は、Redis Pub/Sub ではなく Redis Streams (`XREAD` / `XREADGROUP`) を使え。Streamsこそが、近代分散システムのイベントソーシングにおける正しい回答だ。
3. デバッグ時の諸刃の剣
開発環境で `PSUBSCRIBE ` を叩いて流れるログを見るのは便利だが、本番環境でこれをやると何が起きているか、もう説明不要なはずだ。
技術の深淵を覗くとき、技術もまたこちらを覗き返している。
`PSUBSCRIBE` のコード行数と計算量の関係性を頭に焼き付け、リソースの限界を計算し尽くした上で、そのトリガーを引くことだ。
コメント