【Redis匠の流儀】Sorted Set集合演算の深淵:ZUNIONSTORE/ZINTERSTOREで数百万件を瞬殺する設計・実装パターン
こんにちは。テクニカルリードの私だ。
今日のコードレビューで、誰かが「複数ユーザーのスコアをマージするために、アプリケーション側で巨大なハッシュをループして結合処理しています」なんてコードを書いていたら、即座に差し戻しだ。
Redisの真価は、シングルスレッドによるメモリ上の超高速演算にある。その最たる例が、Sorted Set(ZSET)の集合演算命令、`ZUNIONSTORE` と `ZINTERSTORE` だ。
リレーショナルデータベースで何重ものJOINや一時テーブルのソートに苦しんでいるエンジニア諸君。メモリ上でこれらを一瞬で爆速処理するRedisの「重み付けと集計」のメカニズムを、今日ここで完全にマスターして帰ってほしい。
—
1. プリミティブの再確認:彼らは内部で何をしているのか?
まず、`ZUNIONSTORE` と `ZINTERSTORE` の仕様の本質を抑える。
これらは、指定した複数のZSETに対して「和集合 (Union)」または「積集合 (Intersection)」を計算し、「全く新しい別のZSETキーとして永続的(あるいは期限付きで)物理保存する」 コマンドだ。
ZUNIONSTORE destination numkeys key [key …] [WEIGHTS weight [weight …]] [AGGREGATE SUM|MIN|MAX]
ここでエンジニアとして絶対に忘れてはならない致命的な事実がある。
それは、「このコマンドはO(N + Mlog(K))の計算量を伴う『重い』操作になり得る」 という点だ。
- `N`: 入力されたすべてのZSETに含まれる要素の総数(Unionの場合)、または最小のZSETの要素数(Intersectの場合)
- `K`: 入力されるZSETの数
Redisはシングルスレッドである。数百万件規模のZSETに対して安易にこれを呼び出すと、その瞬間、他のすべてのクライアントリクエストがブロック(レイテンシスパイク)する。この特性を理解せずに「便利だから」とリクエストごとに叩く設計は、我がチームでは厳禁だ。
—
2. 実務で直面するユースケース:リアルタイム・マルチファクター・ランキング
例えば、次のようなシステムを想像してほしい。
「eコマースやオンラインゲームにおいて、プレイヤーの総合ランキングを算出したい」
- キー `ranking:2023:sales`: 2023年の売上金額によるスコア(ZSET)
- キー `ranking:2023:activity`: 2023年のログイン・活動度によるスコア(ZSET)
総合評価として、「売上の影響度を 70%、活動度を 30%」加味したランキングを瞬時に生成したい。ここで `ZUNIONSTORE` と `WEIGHTS`、`AGGREGATE` の出番だ。
実装例(Redis CLI)
1. テストデータの投入
ユーザーA, B, C の売上スコア
ZADD ranking:2023:sales 1000 “user:A” 500 “user:B” 1200 “user:C”
ユーザーB, C, D の活動度スコア
ZADD ranking:2023:activity 80 “user:B” 90 “user:C” 100 “user:D”
2. 集合演算の実行
WEIGHTS 0.7 0.3 を指定し、売上に70%、活動度に30%の重み付けをして合算
ZUNIONSTORE ranking:2023:total 2 ranking:2023:sales ranking:2023:activity WEIGHTS 0.7 0.3 AGGREGATE SUM
3. 結果の確認(スコアの降順で上位から取得)
ZRANGE ranking:2023:total 0 -1 WITHSCORES
【実行結果の裏側を読む】
- `user:A`: 売上(1000 0.7) + 活動度(0 0.3) = 70.0
- `user:B`: 売上(500 0.7) + 活動度(80 0.3) = 350 + 24 = 374.0
- `user:C`: 売上(1200 0.7) + 活動度(90 0.3) = 840 + 27 = 867.0
- `user:D`: 売上(0 0.7) + 活動度(100 0.3) = 30.0
`AGGREGATE` には `SUM`(デフォルト), `MIN`, `MAX` が指定可能だ。複数指標の「最低保証スコアを見たい」時は `MIN`、「ピーク性能を見たい」時は `MAX` と使い分ける。この柔軟性がRDBの複雑なクエリを圧倒する理由だ。
—
3. 堅牢なアーキテクチャ設計:本番環境で絶対に踏むべき「地雷」と回避策
シニアエンジニアなら、ここからが本番だ。綺麗に動くコードを書くだけならジュニアでもできる。大規模トラフィックに耐える堅牢な設計こそが我々の仕事だ。
地雷1: オンデマンド(リクエスト毎)のストア計算
「ユーザーがランキングページを開くたびに `ZUNIONSTORE` を実行する」
→ 絶対にやめろ。
数万人のアクティブユーザーが同時にアクセスした瞬間、RedisのCPU使用率が100%に張り付き、他のトランザクションがすべてタイムアウトする。
【解決策:非同期マテリアライゼーション(実体化)パターン】
計算結果を保存する `destination` キー(上記の例では `ranking:2023:total`)は、リアルタイムに計算するのではなく、バックグラウンドワーカー(Celery, Sidekiq, GoのGoroutine等)で定期的にバッチ実行(またはイベント駆動で差分更新)して「事前計算」しておけ。
ユーザーが参照するのは、常にその「静的に生成されたZSET」の `ZRANGE` だけにする。
[Cron / Worker] ──(定期実行)──> ZUNIONSTORE (重い処理) ──> Redis (Cache ZSET)
▲
[User Request] ───────────────(高速参照)────────────────────────┘ ZRANGE
地雷2: メモリリークとキーの放置
`ZUNIONSTORE` は、指定した `destination` キーが既に存在していれば上書きするが、存在しない場合は新しく作成する。
もし動的なキー名を生成して(例: `ranking:user:12345:fused` など)、クリーンアップを忘れると、Redisのメモリは瞬く間に枯渇し、OOM (Out of Memory) キラーの餌食になる。
【解決策:TTL(有効期限)の強制付与】
一時的な計算結果を保持する場合は、必ず `EXPIRE` をセットするか、Redis 7.0以降であればコマンド内で完結させられないため、パイプラインで同時にExpireをかけろ。
Python (redis-py) による安全なパイプライン実行例
pipe = redis_client.pipeline()
pipe.zunionstore(“ranking:temp:result”, 2, “k1”, “k2”, weights=[0.5, 0.5])
pipe.expire(“ranking:temp:result”, 300) # 5分後に自動消滅させる
result = pipe.execute()
—
4. パフォーマンス・極限チューニングの知見
最後に、極限までパフォーマンスを絞り出すための実践的な知見を共有する。
1. ZSETの要素数の上限管理
ZSETの要素数が10万件を超えてくると、集合演算のコストは無視できなくなる。もしスコアの上位しか必要ない場合、あらかじめ元となるZSETのサイズを `ZREMRANGEBYRANK` などでトリミングし、不要な低スコア要素をメモリ上からパージしておけ。
2. クラスタ環境(Redis Cluster)の罠
Redis Clusterを使用している場合、`ZUNIONSTORE` や `ZINTERSTORE` で指定するすべてのキー(入力元と出力先)が、「同一のハッシュスロット(Hash Slot)」に存在していなければならない。
これをクリアするためには、ハッシュタグ(例: `{user100}:sales`, `{user100}:activity`)を用いて、同じシャードにデータがルーティングされるように設計を強制する必要がある。この制約を見落としてクラスタ移行時にエラーを吐く開発者が後を絶たない。
—
結びにかえて
RedisのSorted Set集合演算は、適切に扱えば、複雑な多次元スコアリングやレコメンデーションの集計をミリ秒単位で実現する最強の武器となる。
しかし、その強力さゆえに、使い方を誤れば単一障害点(SPOF)となりうる諸刃の剣だ。
「いつ計算し、どこに保存し、いつ破棄するか」。このライフサイクルを完璧にコントロールできた時、あなたのシステムはどんな高負荷をも優々と受け流す、極上のアーキテクチャへと昇華するだろう。
次のコードレビューでは、君たちの洗練されたRedis設計を見せてくれ。期待している。
コメント