Redis Pub/Subの深淵:`PSUBSCRIBE`の正しい使い所と、本番障害を防ぐための設計思想
こんにちは。テクニカルリードの私だ。
今日のコードレビューで、あるジュニアエンジニアがこんなコード書いてきた。
「複数のマイクロサービスからのイベントをまとめて受け取りたいので、`PSUBSCRIBE`で “ を指定して全キャッチするようにしました!」
……待て待て待て。
その設計、今すぐ止めろ。ベンチマークを取るまでもなく、本番環境が数ヶ月でスローダウンするか、最悪の場合はメモリバーストで沈む未来が見える。
Redisの `PSUBSCRIBE`(パターンマッチング購読)は強力だ。だが、その強力さゆえに、「何も考えずに使うとシステムを内側から破壊する毒」にもなり得る。
今日は、Redisのデータ構造とPub/Subメカニズムの内部挙動を踏まえ、`PSUBSCRIBE`を実務のアーキテクチャに正しく組み込むための知見を叩き込む。
—
1. `PSUBSCRIBE` とは何か?(表面的な理解の排除)
まず、基本のおさらいだ。`SUBSCRIBE` が完全一致のチャネル名で購読するのに対し、`PSUBSCRIBE` はglobスタイルのワイルドカードパターンを使って複数のチャネルを一度に購読できる。
ログ関連のチャネル(app.log.info, app.log.error等)をすべて購読
PSUBSCRIBE app.log.
使えるワイルドカードの仕様は以下の通りだ。
- `h?llo` は `hello`, `hallo`, `hxllo` にマッチ
- `hllo` は `hllo`, `heeeeello` にマッチ
- `h[ae]llo` は `hello`, `hallo` にマッチ(`h[e-u]llo` のような範囲指定も可)
- 文字クラスのエスケープには `\` を使う(例: `h\llo`)
一見すると、「チャネル設計を厳密にしなくても、ワイルドカードで全部拾えて便利じゃん」と思うかもしれない。
ここが地獄への入り口だ。 なぜ危ないのか、Redisの内部実装から説明しよう。
—
2. なぜ `PSUBSCRIBE` は「諸刃の剣」なのか?(内部アーキテクチャの視点)
Redisはシングルスレッド(正確にはネットワークI/Oやイベントループがシングルスレッド)で動作するインメモリDBだ。Pub/Subにおいて、メッセージの配信処理は購読者の数にO(N)で比例する。
通常(`SUBSCRIBE`)の場合、Redisはチャネル名ハッシュテーブルをルックアップしてクライアントのリストにO(1)に近いコストでメッセージをばら撒く。
しかし、`PSUBSCRIBE` が絡むと話が変わる。
Redisの内部では、パターン購読者は通常のチャネルリストとは別に、パターン(パターン文字列とクライアントのペア)のリストとして管理される。メッセージが `PUBLISH` された際、Redisは以下の処理を行う。
1. 通常のチャネルに一致するクライアントへ配信(`SUBSCRIBE`)
2. すべての有効な `PSUBSCRIBE` のパターンと、配信されたチャネル名を線形走査(またはそれに近いコストで)照合
そう、パターンマッチングは計算コストが高いのだ。
システムが成長し、チャネルの数が何千、何万になり、かつ `PSUBSCRIBE` のパターンが複雑化(または乱立)すると、1回の `PUBLISH` が引き起こすCPU負荷は跳ね上がる。さらに、一致したクライアントごとの出力バッファ(Output Buffer)が肥大化し、最優遇すべきRedisのレイテンシが劇的に悪化する。
—
3. 実務で使える:堅牢な設計パターン
では、`PSUBSCRIBE` は使ってはいけないのか?
いや、正しく使えば強力な武器になる。実務の設計レビューで私が許可する、数少ないユースケースと実装パターンを共有しよう。
パターンA:監査ログ・メトリクスの「オブザーバー」としての利用
アプリケーションのメインのビジネスロジック(注文処理や決済など)のパス上では絶対に `PSUBSCRIBE` を使ってはいけない。
しかし、「監査」「デバッグ」「メトリクス収集」といった、メインのトランザクションパスから完全に切り離された非同期のオブザーバー・プロセスであれば、`PSUBSCRIBE` は非常に有効だ。
import redis
import sys
コネクション設定(実務ではタイムアウトやプールを適切に設定すること)
r = redis.Redis(host=’localhost’, port=6379, decode_responses=True)
def audit_worker():
# 決済システムの全イベント(payment.success, payment.failed, payment.refund等)を監視
pubsub = r.pubsub()
pubsub.psubscribe(‘payment.’)
print(“Audit worker started. Listening for payment events…”)
for message in pubsub.listen():
if message[‘type’] == ‘pmessage’:
channel = message[‘channel’]
pattern = message[‘pattern’]
data = message[‘data’]
# ログ基盤や時系列DBへ非同期で流し込む
process_audit_log(pattern, channel, data)
def process_audit_log(pattern, channel, data):
print(f”[AUDIT] Pattern: {pattern} | Channel: {channel} | Payload: {data}”)
if __name__ == ‘__main__’:
try:
audit_worker()
except KeyboardInterrupt:
sys.exit(0)
パターンB:マルチテナント環境における特定テナント群のイベント集約
SaaSなどのマルチテナントシステムで、テナントIDをチャネル名に含める設計はよくある。
`tenant:1001:events`, `tenant:1002:events` ……
ここで、特定の管理用サービスが「特定のリージョン(例: `region-east`)に属する全テナントのイベント」をまとめて受け取りたい場合、プレフィックスを使ったパターンマッチングが輝く。
東日本リージョンの全テナントのイベントをキャッチ
PSUBSCRIBE tenant::region-east:
ただし、この場合でもワイルドカードの位置(先頭に “ を置かないなど)に注意せよ。前方一致に近い形であれば、ハッシュやプレフィックスの評価コストをある程度抑えられる。
—
4. パフォーマンスと運用の注意点(チーフからの警告)
設計レビューで必ずチェックするポイントを挙げておく。これを破ったら容赦なく差し戻す。
1. 出力バッファの肥大化(Client Output Buffer Limits)
`PSUBSCRIBE` を使っているクライアントの処理が遅い(CPUが詰まっている、I/Oが重いなど)場合、Redis側のそのクライアント向け出力バッファが無限に膨れ上がる。
結果として、Redisのメモリが枯渇し、OOM Killerに殺されるか、他の高速なリクエストまで巻き込んでスローダウンする。
`redis.conf` の `client-output-buffer-limit` は、Pub/Subクライアントに対しても適切に設定しておけ。
例: pubsubクライアントの出力バッファが32MBを超えるか、
過去8秒間にわたり継続して8MBを超えている場合、強制切断する
client-output-buffer-limit pubsub 32mb 8mb 60
2. 「Pub/Subか、Streamか」の再考
もし「メッセージがロストしては困る」「コンシューマーが落ちても再接続時に過去のメッセージをキャッチアップしたい」という要件があるなら、Redis Pub/Sub(および `PSUBSCRIBE`)を使うこと自体がアーキテクチャの誤りだ。
Pub/Subは「Fire-and-forget(投げっぱなし)」であり、メッセージの永続化は一切行われない。
信頼性が必須の要件であれば、`PSUBSCRIBE` ではなく、Redis Streams の Consumer Groups を検討すべきだ。Streamsであれば、コンシューマーごとのオフセット管理やメッセージの永続化、ACK機構が標準で備わっている。
—
5. まとめ
- `PSUBSCRIBE` は強力だが、計算コストとメモリプレッシャーを伴う「諸刃の剣」である。
- メインのビジネスロジックのクリティカルパスでは絶対に使用するな。監査、ロギング、非同期メトリクス収集などの周辺システムに限定せよ。
- クライアントの処理遅延によるバッファ肥大化(OOMリスク)に備え、`client-output-buffer-limit` を必ずチューニングしろ。
- メッセージの信頼性(ロスト防止)が必要なら、Pub/Subではなく Redis Streams を選定しろ。
技術の裏側の挙動を理解せず、便利だからと安易にワイルドカードを使うな。
我々が書くコードは、高負荷に耐え、スケールするシステムの一部なのだから。
さて、コードの修正に戻るとしよう。レビューを通す準備ができたら、また私を呼んでくれ。
コメント