こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、誰かが「Redisの `SORT` コマンドを使ってリレーショナルなクエリを組みました」とドヤ顔で言い出したら、君ならどうする?
……もし即座に冷や汗をかき、ホワイトボードに向かって「今すぐそのコードを書き直せ」と言えなかったとしたら、Redisの本質を見誤っている可能性が高い。
Redisは単なる「速いKVS」ではない。だが、メモリ上で動くデータ構造サーバーであるという事実を忘れて `SORT` コマンドの魔力(特に `BY` や `GET` オプション)に溺れると、本番環境で深刻なレイテンシの爆弾を抱えることになる。
今日は、Redisの `SORT` コマンドの内部挙動、そして実務の現場でどう扱い、あるいは「どう避けるべきか」について、極限の知見を授けよう。
—
1. そもそも `SORT` コマンドとは何なのか?
`SORT` は、List、Set、Sorted Set(ZSet)の要素をソートして取得するためのコマンドだ。基本形はこうだ。
単純な数値リストのソート
SORT my_list ASC
だが、実務でこの基本形を見ることはほぼない。問題になるのは、外部のキーを引っ張ってくる魔改造オプション、`BY` と `GET` だ。
これらを使うことで、Redis上で簡易的な「リレーショナル結合」が実現できる。例えば、「ユーザーIDのリスト」を「ユーザーのスコア(別キー)」でソートしつつ、「ユーザー名(別キー)」を取得する、といった芸れんだ。
【アンチパターン例】実務でこれをやると何が起きるか?
SORT user_ids BY user:->score DESC GET user:->name GET user:->score
一見、便利に見えるだろう。MySQLの `ORDER BY` と `JOIN` がメモリ上で一瞬で終わるような幻想を抱くかもしれない。
しかし、ここからがエンジニアの腕の見せ所だ。このコマンドの裏側で何が起きているか、解剖してみよう。
—
2. 内部挙動の闇:なぜ `SORT` は危険なのか
Redisはシングルスレッドで動作する。この鉄の掟を忘れてはならない。
`SORT` コマンド(特に `BY` / `GET` 付き)が実行されたとき、Redisの内部で何が起きているのか?
1. メモリ上の全走査と一時配列の確保
対象のList/Setの要素数 $N$ に対し、ソートのためのポインタ配列がメモリ上に動的に確保される。
2. O(N log N) のソート処理
CPUをバーストさせながらソートを実行する。
3. 外部キーのO(N)ルックアップ
`BY` や `GET` が指定されている場合、ソート対象の要素ごとに `HGET` や `GET` が裏で走り、パターンマッチングやキーの組み立て(`user:`)が行われる。
結果として何が起きるか?
要素数が数万件を超えたあたりから、この単一のコマンドが数ミリ秒〜数十ミリ秒の間、Redisのメインスレッドを完全にブロックする。
Redisのレイテンシが跳ね上がり、コネクションプールが枯渇し、アプリケーション全体が雪崩式に沈没する――これが、安易な `SORT` 使用の末路だ。
さらに、Cluster環境では大惨事になる。`SORT` の `BY` や `GET` が絡むと、キーが異なるノードに分散している場合にハッシュタグ(`{…}`)の制約に引っかかり、クラスタ間でエラーになるか、極めて非効率なルーティングが発生する。
—
3. 実務における正しい設計パターン
では、Redisでソートや関連データの取得を行いたい場合、どう設計すべきか?
答えはシンプルだ。「重い処理はRedisにやらせるな。Redisは結果を返すだけに徹しろ」。
パターンA:Sorted Set(ZSET)を「最初からソート済み」で保持する(推奨)
`SORT` コマンドの出番を無くす最もエレガントな方法は、書き込み時にソートを完了させておくことだ。ZSETは常にスコア順にインデックスが維持されている。
ユーザーのスコアをZSETで管理(O(log N)の挿入)
ZADD global:user:scores 1500 “user:101”
ZADD global:user:scores 2400 “user:102”
上位10件を取得(O(log N + M)、Mは取得件数。圧倒的に速い)
ZREVRANGE global:user:scores 0 9 WITHSCORES
もし、ユーザー名などの付随情報を同時に取得したい場合は、`SORT` の `GET` を使うのではなく、取得したIDリストをもとに `HMGET` を叩くか、アプリケーション層でパイプライン(Pipeline)を使って一括取得する。
Python (redis-py) による堅牢な実装イメージ
1. ZSETから上位のユーザーIDをO(log N + M)で取得
user_ids = redis_client.zrevrange(“global:user:scores”, 0, 9)
if user_ids:
# 2. Pipelineを使ってHashから詳細情報を一括取得(ネットワークラウンドトリップを削減)
pipe = redis_client.pipeline()
for uid in user_ids:
pipe.hgetall(uid)
user_details = pipe.execute()
このアプローチであれば、計算量は予測可能であり、Redisのシングルスレッドをブロックするリスクを最小化できる。
—
4. それでもどうしても `SORT` を使わなければならない時の「免罪符」
ここまで `SORT` をディスってきたが、データ量が数件〜数百件程度で、かつ頻繁に更新されないマスターデータの簡易的な並び替えなど、限定的なユースケースにおいて `SORT` が有用な場面がゼロとは言わない。
もし君がどうしても `SORT` を使う必要があるなら、以下の「保身の条件」をクリアしているか確認しろ。
1. データ量が厳格にコントロールされていること
対象のリストが数千件以上に絶対に膨れ上がらない設計になっているか?(定期的な `LTRIM` などで切り詰められているか)
2. `STORE` オプションを活用しているか
毎回ソートを計算するな。結果を別のキーにキャッシュしろ。
ソート結果を一時的なキーに保存する(重いソートは初回、あるいはバッチでのみ実行)
SORT my_list BY weight_ DESC STORE sorted:my_list:cache
以降の頻繁なアクセスには、保存したリストのレンジ取得(LRANGE)で代用する
LRANGE sorted:my_list:cache 0 9
これを怠ると、アクセスが集中した瞬間にCPU使用率が100%に張り付き、SREから深夜の緊急アラートを受け取る羽目になる。
—
チーフアーキテクトからの最終提言
Redisの `SORT` コマンド、特に `BY` や `GET` は、「Redisにリレーショナルデータベースの真似事をさせようとした歴史的遺物」に近い側面を持っている。
モダンなRedisアーキテクチャにおいて、複雑なクエリやソートはZSETやHash、あるいはアプリケーション層(またはElasticsearch等の検索エンジン)との役割分担によって解決すべきだ。
コードレビューで `SORT … BY … GET …` を見かけたら、今日の話を思い出してほしい。
「そのソート、本当にRedisでやる必要あるかい? ZSETでインデックス維持するか、RDBに任せた方が良くないか?」と、シャープに指摘してやれるエンジニアであれ。
君たちのシステムの安定性は、こうしたプリミティブなコマンドの内部挙動に対する深い理解の上に成り立っている。設計の妥協は、いつだってシステムを裏切る。気をつけて実装にあたってくれ。
コメント