【実務・中級編】 PUNSUBSCRIBEコマンド – Redis

Redis Pub/Subの深層:`PUNSUBSCRIBE`の正確な挙動と、本番障害を防ぐための設計思想

こんにちは。テックリードの私だ。
今日のコードレビューで、あるジュニアエンジニアが書いたRedisのPub/Sub周りのクリーンアップ処理に目が留まった。
「なんとなく `PUNSUBSCRIBE` を呼んでおけば安全だろう」という甘い認識が、メモリリークやコネクションのゾンビ化を招く典型的なアンチパターンだった。

RedisのPub/Sub、特にパターンマッチングを伴う `PSUBSCRIBE` と `PUNSUBSCRIBE` は、その非同期性と文字列パターンの評価コストにおいて、インメモリデータベースの挙動を熟知していないと本番で痛い目を見るコンポーネントの一つだ。

今回は、`PUNSUBSCRIBE` コマンドに直球で焦点を当て、Redisの内部メカニズム、パターンマッチングの罠、そして実務で絶対網羅すべき堅牢な設計パターンを伝授する。

—

1. `PUNSUBSCRIBE` とは何か?(おさらいのその先へ)

`PUNSUBSCRIBE` は、`PSUBSCRIBE` によって確立されたパターンベースのチャンネル購読を解除するためのコマンドだ。

使用例:単一または複数のパターン指定による解除
PUNSUBSCRIBE news. sport.

だが、このコマンドを正確に使いこなすためには、Redisクライアントが内部で何をやっているかを理解しなければならない。

3つのバリエーションと挙動の差異

1. 引数なし (`PUNSUBSCRIBE`):
そのクライアントが現在購読しているすべてのパターンを解除する。
2. パターンを指定 (`PUNSUBSCRIBE news.`):
指定された正確なパターン文字列に一致する購読のみを解除する。
3. 部分的な解除:
複数のパターンを購読している状態から、特定のものだけを外す。

ここで重要なのは、「RedisのPub/Subにはトランザクションやロールバックの概念がない」ということだ。コマンドを送った瞬間から、サーバー側の購読リスト(クライアントごとのハッシュ/リンクリスト)から該当パターンが即座にパージされる。

—

2. パターンマッチングのルールと「見落とされがちなコスト」

`PSUBSCRIBE` / `PUNSUBSCRIBE` で使われるパターンは、globスタイルのパターンマッチングを採用している。

  • `h?llo` は `hello`, `hallo` などにマッチ
  • `hllo` は `hllo`, `heeeello` などにマッチ
  • `h[ae]llo` は `hello` と `hallo` にマッチ(`h[^e]llo` なら否定)

【極限の知見】なぜパターンマッチングはスケールしないのか?

Redisはシングルスレッドで動作する。通常、チャンネル名への直接配信(`SUBSCRIBE` / `PUBLISH`)は、ハッシュテーブルのルックアップになるため $O(N)$($N$ はそのチャンネルの購読者数)であり非常に高速だ。

しかし、`PSUBSCRIBE` によるパターンマッチングは話が違う。
`PUBLISH` が実行された際、Redisは現在登録されているすべてのパターン(`clients_pending_write` や内部のパターンツリー)と、発行されたチャンネル名を線形、あるいはそれに近いコストで照合する。

つまり、

  • ワイルドカードを多用した複雑なパターンを数千個登録する
  • 購読クライアントが数百万規模に膨れ上がる

このような設計を行うと、たった1回の `PUBLISH` がCPUをスパイクさせ、Redis全体のスループットを致命的に殺す。`PUNSUBSCRIBE` を適切に行わず、古いパターンがサーバー側に残り続けること自体が、潜在的なメモリおよびCPUのリークなのだ。

—

3. 実務で遭遇する「ゾンビ購読」の恐怖と対策

アプリケーション側(Node.js, Python, Goなど)でPub/Subを実装する際、最も多いバグが「切断時やエラー時のクリーンアップ漏れ」だ。

例えば、Goの `go-redis` や Node.js の `ioredis` を使っているとき、コネクションが切断されたり、コンテキストがキャンセルされたりしたとする。

// 悪い例:コンテキスト終了時にPUNSUBSCRIBEを明示的に担保していない
pubsub := rdb.PSubscribe(ctx, “orders.”)
defer pubsub.Close() // これで本当に安全か?

ライブラリによっては `Close()` の内部で `PUNSUBSCRIBE` を送信しようとするが、すでにTCPコネクションが切断されていた場合、コマンドは宙に浮き、Redisサーバー側には「まだ購読を希望している幽霊クライアント(ゾンビ)」のデータ構造が一時的に残る原因となる(タイムアウトやクライアントの再接続検知までメモリを圧迫し続ける)。

堅牢な設計パターン:明示的なライフサイクル管理

プロダクション環境で耐えうるコードを書く場合、購読の開始と終了は必ずライフサイクル管理のコンテキストに紐付けるべきだ。

以下は、Python(`redis-py`)を想定した堅牢なラッパーの概念コードだ。

import redis
import logging

logger = logging.getLogger(__name__)

class RobustPatternSubscriber:
def __init__(self, client: redis.Redis, pattern: str):
self.client = client
self.pattern = pattern
self.pubsub = self.client.pubsub()
self._is_subscribed = False

def start(self):
try:
self.pubsub.psubscribe(self.pattern)
self._is_subscribed = True
logger.info(f”Successfully psubscribed to pattern: {self.pattern}”)
except redis.RedisError as e:
logger.error(f”Failed to psubscribe: {e}”)
self.close()
raise

def close(self):
“””
確実なリソース解放とPUNSUBSCRIBEの送信を保証する
“””
if self._is_subscribed:
try:
# 明示的にPUNSUBSCRIBEを実行
self.pubsub.punsubscribe(self.pattern)
logger.info(f”Successfully punsubscribed from pattern: {self.pattern}”)
except redis.RedisError as e:
# コネクションがすでに死んでいる場合のエラーはログに留め、強制終了へ進む
logger.warning(f”Redis error during punsubscribe (connection might be dead): {e}”)
finally:
self._is_subscribed = False

try:
self.pubsub.close()
except Exception as e:
logger.error(f”Error closing pubsub connection: {e}”)

ポイント:
1. フラグ(`_is_subscribed`)で状態を管理し、二重解放を防ぐ。
2. コネクション切断時の例外(`redis.RedisError`)をハンドリングし、アプリケーションがクラッシュするのを防ぎつつ、リソースを確実に回収する。

—

4. パフォーマンス上の注意点:Pub/Subは「持続的データストア」ではない

最後に、アーキテクトとして最も強く釘を刺しておきたい。

「RedisのPub/Sub(および `PSUBSCRIBE` / `PUNSUBSCRIBE`)に、メッセージの永続化や到達保証はない」

  • 購読していない間に発行されたメッセージは永遠に消える。
  • クライアントのバッファ(`client-output-buffer-limit`)が溢れると、Redisは強制的にそのクライアントを切断する。

もし、イベント駆動アーキテクチャにおいて「メッセージの取りこぼしが許されない」「ワイルドカードを使って柔軟にイベントルーティングしたい」のであれば、Redis Pub/Subではなく、Redis Streams(`XREAD` / `XREADGROUP`) や、Apache Kafka / RabbitMQ などの専用メッセージキューを選択すべきだ。

Redis Pub/Subは、あくまで「超軽量かつ一時的なブロードキャスト」に特化している。その特性を理解した上で、不要になったパターンは速やかに `PUNSUBSCRIBE` で掃除し、サーバーのメモリとCPUサイクルを健全に保つこと。

—

まとめ

  • `PUNSUBSCRIBE` は単なるコマンドではなく、リソース衛生管理の要である。
  • パターンマッチング(`PSUBSCRIBE`)は高コストになり得るので、ワイルドカードの乱用は厳禁。
  • コネクションのライフサイクルと確実に連動させ、ゾンビ購読をサーバーに残すな。
  • 要件がメッセージの信頼性を求めるなら、Pub/SubではなくStreamsを検討せよ。

コードレビューで曖昧な実装を見つけたら、今日の話を思い出してほしい。君たちの書くコードが、極限までスケーラブルで美しいシステムを支えるのだ。

コメント

タイトルとURLをコピーしました