【実務・中級編】 Sorted Set型:範囲操作 – Redis

Redis Sorted Set(ZSET)範囲操作の深層:O(log N) の魔力を引き出す極限のアーキテクチャ

こんにちは。テックリードの私だ。
今日のコードレビューで、また「とりあえず全件取ってアプリケーション側でフィルタリングしています」という若手のプルリクエストを見かけた。頼むから目を覚ましてほしい。君たちが扱っているのは、メモリ上で秒間数十万件を捌くためのインメモリデータストア、Redisだ。

特に `Sorted Set (ZSET)` の範囲操作系コマンド(`ZRANGE`, `ZREVRANGE`, `ZRANGEBYSCORE`, `ZREVRANGEBYSCORE` 等)は、適切に使えばRDBのインデックススキャンを遥かに凌駕する爆速のランキングや時系列・優先度管理システムを構築できる。しかし、データ構造の内部メカニズムを理解せずに雑に叩けば、平気でレイテンシのスパイクを引き起こす爆弾にもなる。

今回は、ZSETの範囲操作における「中身の仕組み」「実務での正しい使い分け」「スケーラビリティを担保する設計パターン」を、私と一緒に徹底的に紐解いていこう。

—

1. ZSETの内部構造:なぜ「範囲操作」が圧倒的に速いのか?

まず大前提として、RedisのZSETが内部でどうデータを持っているかを知る必要がある。

ZSETは、単一のデータ構造ではない。要素数とサイズが小さい初期段階では `ziplist`(連続したメモリ領域) として効率的にパッキングされ、要素が増えると `skiplist(スキップリスト)` + `dict(ハッシュテーブル)` のハイブリッド構造へと動的に変貌する。

ここでの主役は スキップリスト だ。
よく「ZSETは内部でツリー構造になっている」と誤解しているエンジニアがいるが、違う。スキップリストは、確率論的に多重のリンク構造を持つ連結リストであり、O(log N) の検索・範囲探索性能を、B木のような複雑なノード再バランスなしで実現する優れものだ。

さらに、スコア順のソート(スキップリスト)と、O(1)でのメンバ存在確認・スコア取得(dict)が完全に同期して維持されているため、「特定のスコア範囲や順位の要素を抜き出す」操作が極めて高速に行える。

—

2. 範囲操作コマンドの正しい流儀と「罠」

では、実務で頻出するコマンド群を見ていこう。
ここで重要なのは、Redisのバージョンによってコマンドのインターフェースが進化している点だ。特に Redis 6.2以降 では、古い `ZRANGEBYSCORE` や `ZREVRANGEBYSCORE` は事実上のレガシーとなり、超多機能な `ZRANGE` コマンドのオプション(`BYSCORE`, `REV`, `LIMIT`) に統合されている。

モダンな開発をするなら、新しい `ZRANGE` 一択で設計すべきだ。

パターンA: 順位(Rank)ベースの範囲取得 (`ZRANGE` / `ZREVRANGE`)

リーダーボード(ランキング)で「上位10件」を取るようなケースだ。

ユーザーのスコアを登録(ZADD)
ZADD leaderboard 1500 “alice” 2800 “bob” 2100 “charlie” 3500 “dave”

1. 昇順でインデックス 0 から 2 まで(上位3名)を取得
ZRANGE leaderboard 0 2 WITHSCORES
実行結果イメージ:
1) “alice” (1500)
2) “charlie” (2100)
3) “bob” (2800)

2. 【モダンな書き方】逆順(スコアが高い順)で上位3名を取得 (Redis 6.2以降)
ZRANGE leaderboard 0 2 REV WITHSCORES
実行結果イメージ:
1) “dave” (3500)
2) “bob” (2800)
3) “charlie” (2100)

【チーフアーキテクトの警告:計算量とメモリの罠】
インデックスベースの範囲指定(`0 2` など)は、スキップリストの先頭(または末尾)から指定位置までポインタをたどるため、`O(log(N) + M)`(Mは取得件数)で完了する。
ただし、N(要素数)が数百万〜数千万規模に達した時のメモリ断片化や、巨大なリストに対する安易な全件取得は厳禁だ。

パターンB: スコア(Score)ベースの範囲取得 (`ZRANGEBYSCORE` → モダン `ZRANGE … BYSCORE`)

「スコアが 1000 以上 3000以下のユーザーをすべて欲しい」という時や、タイムスタンプをスコア代わりにして「指定期間のログを取りたい」という時に使う。

スコア(タイムスタンプやポイント)を指定して取得
例: スコア 2000 〜 3000 の範囲のメンバを取得(境界値を含む)
ZRANGE leaderboard 2000 3000 BYSCORE WITHSCORES

境界値を含まない場合(排他的範囲: Exclusive)は ‘(‘ をつける
ZRANGE leaderboard (2000 (3000 BYSCORE WITHSCORES

【重要】ページネーションには LIMIT を使え!
スコア範囲に合致する大量データから、オフセット 0件目から 10件取得
ZRANGE leaderboard 0 3000 BYSCORE LIMIT 0 10 WITHSCORES

—

3. 実務で直面する「スケーラビリティの課題」と設計パターン

コードレビューでよくあるアンチパターンを挙げておこう。これらを本番環境に投入すると、レイテンシスパイクを引き起こしてインフラチームから呼び出されることになる。

アンチパターン1: `LIMIT` なしの巨大レンジ取得によるOOM(メモリ枯渇)

数百万件のZSETに対して、`LIMIT` なしで `ZRANGE 0 -1` を叩くエンジニアがいる。
Redisはシングルスレッドで動作するため、数MB〜数十MBに及ぶ巨大なレスポンスのシリアライズとネットワーク転送の間、他のすべてのリクエストがブロック(Redisのストール)する。

【正しい設計】
必ず `LIMIT ` を併用し、一度に取得するチャンクサイズを制御すること。無限スクロールやページネーションの実装では、オフセット方式 (`LIMIT offset count`) ではなく、前回の最後のスコアやメンバを起点にする「カーソルベース(あるいはスコア範囲ベース)のページネーション」を採用すべきだ。

前回の最後のスコア(例: 2800)を覚えておき、それより大きいスコアを次ページとして取得
ZRANGE leaderboard 2800 +inf BYSCORE LIMIT 0 20

アンチパターン2: タイムスタンプ型ZSETの肥大化とTTL管理の欠如

リアルタイムログやアクティビティフィードをZSETで実装する際、古いデータが無限に蓄積され、メモリを圧迫していく事故が後を絶たない。ZSET自体のキーには `EXPIRE` を設定できるが、「古い要素だけを綺麗に削ぎ落とす」ことはキー単位の `EXPIRE` ではできない。

【正しい設計:定期的なZREMRANGEBYSCOREのパージ】
時系列データをZSETに流し込む場合(スコアにUnixタイムスタンプを使用)、バックグラウンドワーカーなどで定期的に古いデータを切り捨てるバッチ処理を組み込む必要がある。

例: 1週間前(現在時刻 – 604800秒)より古いデータをパージする
ZREMRANGEBYSCORE activity_stream -inf 1672531199

これにより、ZSETのサイズを常に一定の許容範囲内に保ち、範囲操作のパフォーマンスを極限まで維持できる。

—

4. チーフアーキテクトからの提言

RedisのSorted Setの範囲操作は、単なる「便利な便利機能」ではない。
インメモリの特性を理解し、O(log N) のアルゴリズム特性に寄り添ったクエリ設計(適切な `BYSCORE`、`REV`、`LIMIT` の組み合わせ)を行えば、RDBでは絶対に叩き出せないリアルタイム性とスケーラビリティを手に入れることができる。

設計レビューでこのあたりの最適化が抜けているコードを見かけたら、私のこの記事を突きつけてやってほしい。
「とりあえず動く」ではなく、「スケールする構造を知り尽くした上で実装する」。それが、我々プロフェッショナルエンジニアの仕事だ。

コメント

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