Redis Set型「集合演算」の真実:O(N)の罠とスケールするアーキテクチャ設計
技術リードの私だ。コードレビューや設計レビューで、RedisのSet型(集合演算)の扱いを誤り、プロダクション環境でレイテンシを跳ね上がらせているコードに何度も直面してきた。
「複数のユーザーの共通フォロワーを求めたい」「タグの絞り込み検索をしたい」。
こうした要件で `SINTER` や `SUNION` を思考停止で呼び出す。その結果何が起きるか? データ量が数百万件を超えた瞬間、シングルスレッドのRedisがブロックされ、全APIが沈黙する。
今回は、`SUNION`, `SINTER`, `SDIFF` およびそれらの `STORE` 系コマンドの本質的な挙動、計算量(Big-O)、そして実務で絶対につまずかないための設計パターンを、容赦なく解説していく。
—
1. 集合演算の数学的定義とRedis内部での残酷な現実
まずは基本だ。RedisのSetは、ハッシュテーブル(実態は値がすべてNULLのDict)で実装されているため、要素の重複は許されず、追加・削除・メンバーシップ確認(`SISMEMBER`)は `O(1)` で動作する。
しかし、集合演算(Union, Intersect, Diff)は話が全く違う。
各コマンドの計算量と挙動
| コマンド | 演算内容 | 計算量 (Time Complexity) | 注意点 |
| :— | :— | :— | :— |
| `SUNION` | 和集合 (A ∪ B) | O(N) (全セットの要素数の総和) | 結果が大きい場合、ネットワーク帯域も圧迫する |
| `SINTER` | 積集合 (A ∩ B) | O(N M) (最小のセットを基準に走査) | 順序が重要。Redisは自動でサイズ順にソートして処理するが、ベースが大きいと重い |
| `SDIFF` | 差集合 (A \ B) | O(N) (最初のセットの要素数に依存) | 第1引数のセットの大きさがそのままコストになる |
※ `STORE` 系コマンドは、上記コストに加えて結果の書き込みコストが追加される。
チーフアーキテクチャの視点:なぜ「重い」のか?
Redisはシングルスレッドで動作する。つまり、`SINTER` や `SUNION` が実行されている間、そのRedisインスタンスの他のすべてのリクエストが完全にブロック(BLOCKED)される。
数百万件の要素を持つSet同士で `SINTER` を実行すれば、数百ミリ秒〜数秒間、そのRedisは「停止」するに等しい状態に陥る。これが本番障害の王道パターンだ。
—
2. コマンドの直感的な挙動と実務的な使い方
まずは挙動を正確に把握するため、具体的なコマンドラインの動きを見ていこう。ここでは「ユーザーの興味関心タグ」を例にする。
ユーザー1の興味タグ
SADD user:1:interests tech ai crypto music
ユーザー2の興味タグ
SADD user:2:interests tech music fashion
— 1. 共通の興味(積集合: SINTER) —
ユーザー1と2の両方が持っているタグを取得
SINTER user:1:interests user:2:interests
1) “tech”
2) “music”
— 2. 全ての興味(和集合: SUNION) —
ユーザー1と2のタグをマージ
SUNION user:1:interests user:2:interests
1) “tech”
2) “ai”
3) “crypto”
4) “music”
5) “fashion”
— 3. 差分(差集合: SDIFF) —
ユーザー1にはあって、ユーザー2にはないタグ
SDIFF user:1:interests user:2:interests
1) “ai”
2) “crypto”
ここまでは教科書通りだ。問題は、これをリアルタイム性の求められるWebアプリケーションのホットパス(リクエスト毎に実行されるパス)でどう扱うかにある。
—
3. 実務で破綻しないための設計パターン:`STORE` の活用と非同期化
前述の通り、巨大なSetに対するオンザフライ(都度計算)の集合演算は自殺行為だ。では、どう設計すべきか?
パターンA: 演算結果を永続化・キャッシュする (`STORE`)
もし頻繁に参照される集合演算があるなら、`SINTERSTORE`, `SUNIONSTORE`, `SDIFFSTORE` を使って、結果を別のキーにあらかじめマテリアライズ(実体化)しておくべきだ。
ユーザー1とユーザー2の共通趣味を計算し、専用のキャッシュキーに保存する
ついでに EXPIRE でTTLを設定し、メモリリークを防ぐのがプロの作法
SINTERSTORE cache:user:1:2:common user:1:interests user:2:interests
EXPIRE cache:user:1:2:common 60
以降の参照は O(1) に近い感覚、あるいは単なるキー参照のコストで済む(実際は結果セットの要素数に依存するが都度計算より遥かに軽い)
SMEMBERS cache:user:1:2:common
設計上の注意点:
- キャッシュ無効化のジレンマ: 元のSet(`user:1:interests`など)が更新されたら、このストアされたキャッシュはどうするのか?
- 厳密な整合性が必要な場合、ストア系を使うべきではない。
- 「数秒〜数分の遅延が許容されるレコメンドや共通フォロワー表示」といったユースケースにのみ適用すること。
パターンB: バッチ処理(Worker)による事前計算
大規模なSNSの「共通のフォロワー」機能などを想定しよう。数百万人のフォロワーを持つインフルエンサー同士の積集合をリアルタイムで計算するなど狂気の沙汰だ。
正しい設計アプローチ:
1. フォロー・フォロワーの関係変更イベントをメッセージキュー(Kafka / RabbitMQ等)に流す。
2. バックグラウンドワーカーがイベントを拾い、あらかじめ `SINTERSTORE` などで「共通フォロワー数」や「共通フォロワーのリスト(上位N件)」を計算し、RedisやRDBにスナップショットとして書き込んでおく。
3. クライアントからのリクエストには、その事前計算済みデータを返すだけにする。
—
4. パフォーマンス・チューニングとアンチパターン
最後に、コードレビューで私が必ずチェックする「やってはいけないアンチパターン」を叩き込んでおく。
❌ アンチパターン1: 多すぎるキーの同時処理
`SINTER set1 set2 set3 … set100` のように、一度に膨大な数のセットを渡すこと。
Redisの内部でソートや走査のオーバーヘッドが増大し、CPUを100%食いつぶす原因になる。どうしても複数セットを扱う場合は、段階的に `SINTERSTORE` で中間結果を作りながら処理を分割せよ。
❌ アンチパターン2: メモリ断片化(Fragmentation)の放置
Set型は要素の追加・削除が頻繁に行われると、Redisのメモリ管理(Jemalloc)においてメモリの断片化を引き起こしやすい。
特に大きなSetに対して `SDIFFSTORE` や `SUNIONSTORE` を高頻度で実行し、一時的なキーを大量に作っては消すを繰り返すと、メモリ使用量が肥大化する。
`INFO memory` を常に監視し、`mem_fragmentation_ratio` が1.5を超えるような環境では、適切なメモリポリシー(volatile-lru等)の見直しや、キー設計の再検討が必要となる。
💡 プロからの提言:ZSET(Sorted Set)との使い分けを見極めろ
「順位」や「スコア」が必要ない場合においてのみ、Set型を使うべきだ。もし「スコア順に上位10件の共通項が欲しい」という要件であれば、Set型ではなく Sorted Set (ZSET) の `ZINTERSTORE` / `ZUNIONSTORE` を選択するべきだ。
ZSETの集合演算は重いが、重い処理を行う「理由(スコアの加重平均や順位の算出など)」が正当化される。用途を取り違えるな。
—
結び
RedisのSet型集合演算は、正しく使えば極めて強力な武器となり、RDBでは絶望的な複雑さを持つクエリをミリ秒単位で解決してくれる。
しかし、それは「データ量と計算量を正しく見積もっていること」が大前提だ。
「とりあえず便利だから `SINTER` でいいか」と思った瞬間、そのコードは将来の障害の芽を育てている。
今日の設計レビューから、そのクエリが本当にホットパスで実行されても問題ないか、`STORE` や非同期化によるバイパスができないかを徹底的に精査してほしい。
プロの仕事とは、美しく、かつスケールする設計を淡々と実装することだ。期待している。
コメント