Redisの死活問題:なぜ本番環境で `KEYS ` を叩くエンジニアは即座にクビになるのか
コードレビュー中、ジュニアエンジニアが書いた次のようなコードを見かけたとしよう。
お蔵入りすべきアンチパターン
def get_all_user_sessions(redis_client):
keys = redis_client.keys(“session:”)
return {k: redis_client.get(k) for k in keys}
この瞬間、シニアエンジニアやテックリードであるあなたは冷汗をかくべきだ。なぜか? この一見無害に見える `KEYS “session:”` というクエリは、数百万件のキーを持つ本番Redisインスタンスを一撃で沈黙させる「時限爆弾」だからだ。
Redisはシングルスレッドモデルで動作する。つまり、1つのコマンドの実行中は他のすべてのリクエストが完全にブロックされる。$O(N)$ の計算量を持つ `KEYS` コマンドは、キー空間全体のハッシュテーブルを全走査するため、データ量がスケールするにつれてレイテンシが跳ね上がり、最終的にはコネクションタイムアウトの嵐を引き起こす。
では、数百万、数千万のキーが存在する巨大なキー空間から、安全に目的のキーを特定するにはどうすればいいのか?
答えは一つ。`SCAN` ファミリーの完全習得だ。今回は、Redisの深部構造を知り尽くしたアーキテクチャの視点から、`SCAN` のメカニズム、致命的な罠、そして実務でそのまま使える堅牢な設計パターンを伝授する。
—
1. `SCAN` のメカニズム:なぜ安全なのか?
`SCAN` は、カーソルベースのイテレータである。一度の呼び出しで全キーを返すのではなく、部分的な結果と「次のカーソル」を返す。これをループさせることで、非ブロッキングかつ安全にキー空間を走査できる。
だが、ここでRedisの内部構造に踏み込むエンジニアなら一つの疑問を持つはずだ。
「シングルスレッドのインメモリDBで、どうやって途中で状態(ステート)を保持しながら走査するのか? キーの追加や削除がリアルタイムで行われる分散・並行環境で、カーソルはどう機能するのか?」
リバース・ビビット・インクリメント(Reverse Bit Increment)の魔術
Redisのキー空間はハッシュテーブル(`dict.c`)で管理されている。通常のインクリメントカーソル(0, 1, 2, 3…)を使うと、走査中にハッシュテーブルのサイズが拡大・縮小(Rehashing)した際に、大量のキーの取りこぼし(Missing)や重複(Duplication)が発生する。
この問題を解決するため、Redisの `SCAN` は 「リバース・ビビット・インクリメント」 というビット演算ベースの特殊なカーソルアルゴリズムを採用している。
- ハッシュテーブルが拡大・縮小しても、データの再配置(Rehash)の規則性に逆行しないよう、ビットを反転させてインクリメントする。
- これにより、走査中にキーの追加や削除、Rehashが発生しても、「走査開始から終了までの間に一度でも存在し、かつ削除されなかったキーは、原則として最低1回はフェッチされる」ことが保証される(※同時期の動的な変更により重複して返されるケースはある)。
この設計思想の美しさを理解してほしい。完全な整合性を犠牲にする代わりに、「O(1)に近い極小の計算量」「ブロッキングの回避」「メモリの省力化(ステートレスなクライアント側へのカーソル委譲)」を完璧に両立させているのだ。
—
2. 実践:`SCAN` の正しい実装パターン
`SCAN` を使う際、開発者が陥りがちな罠がいくつかある。特に重要なのは以下の2点だ。
1. `COUNT` オプションは「返す件数」ではなく、「走査するスロット数のヒント」に過ぎない点。
2. カーソルが `0` に戻ったときが、走査の「完全な終了」を意味する点。
Python(`redis-py`)を用いた、実務でそのまま耐えうる堅牢なジェネレータ関数の実装を見てほしい。
import redis
from typing import Generator, Optional
def safe_scan_keys(
client: redis.Redis,
match_pattern: str,
count: int = 1000
) -> Generator[str, None, None]:
“””
本番環境で安全にキー空間を走査するジェネレータ。
O(1)のノンブロッキングな走査を維持しつつ、メモリ枯渇を防ぐ。
“””
cursor = 0
while True:
# SCANコマンドの実行
# cursor: 次回のエントリポイント
# keys: 該当バッチで見つかったキーのリスト
cursor, keys = client.scan(cursor=cursor, match=match_pattern, count=count)
# バッチ内のキーをyieldで逐次処理(メモリ効率の最大化)
for key in keys:
# bytesで返ってくる場合があるためデコードを考慮
yield key.decode(‘utf-8′) if isinstance(key, bytes) else key
# カーソルが0に戻ったら走査完了
if cursor == 0:
break
— 実行例 —
client = redis.Redis(host=’localhost’, port=6379, db=0)
例:「user:session:」にマッチするキーを安全に一括削除するバッチ処理
KEYSコマンドの代わりにこれを回す
deleted_count = 0
for user_key in safe_scan_keys(client, “user:session:”, count=2000):
client.delete(user_key)
deleted_count += 1
print(f”Successfully cleaned up {deleted_count} session keys.”)
このコードの優れている点
- メモリバウンドの回避: 一度にすべてのキーをメモリ上に展開せず、ジェネレータを使ってストリーム処理している。
- スケーラビリティ: `count=2000` と指定することで、一度のRedisへの負荷を一定に抑えつつ効率的にスキャンしている。
—
3. 実務におけるパフォーマンス上の重大な注意点
`SCAN` は銀の弾丸ではない。アーキテクトとして、以下のトレードオフとリスクをチームに周知徹底する必要がある。
① `MATCH` パターンによるCPUバウンドの罠
`SCAN 0 MATCH user::profile` のように、ワイルドカードが先頭や中間に来るパターンを指定した場合、Redisはキー空間全体をスキャンしつつ、CPUを使って文字列マッチング(パターン評価)を裏側で実行し続ける。
キー数が数千万件ある場合、このパターンマッチング自体がCPUを圧迫し、結果としてRedis全体のスループットが低下する。
- 対策: キー設計の段階でプレフィックスを前方一致に固定する(例: `profile:user:`)。どうしても部分一致が必要な場合は、Redisearchモジュールや、専用の二次索引(SetやZSet)を別途用意するべきだ。
② 走査中のデータ変更(Mutation)の不確実性
前述の通り、`SCAN` はスナップショットを保証しない。走査の最中にキーが追加・削除された場合、次のような挙動になる。
- 走査済みの領域に追加されたキー:今回のイテレーションでは検出されない可能性がある。
- 未走査の領域から削除されたキー:当然返されない。
- 同じキーが重複して返される可能性:あり得る(アプリケーション側で `set` などを用いて重複排除を担保する必要がある場合がある)。
クリティカルなデータ整合性が求められるビジネスロジックで `SCAN` を使うのは設計ミスだ。あくまで「キャッシュのパージ」「孤立データのクリーンアップ(ゴミ掃除)」「メトリクスの集計」といった、結果整合性(Eventual Consistency)が許容されるバックグラウンド処理に限定すべきである。
—
4. テックリードからの総括:アーキテクチャ設計の指針
Redisは「速い」からといって、雑なクエリを受け止めてくれる魔法の箱ではない。その驚異的なパフォーマンスは、開発者が内部構造の制約(シングルスレッド、メモリ効率)を正しく理解し、敬意を払ってコードを書くことによって初めて維持される。
1. プロダクションコードで `KEYS`、`FLUSHALL`、`FLUSHDB` を見かけたら、それだけでプルリクエストを即座にReject(差し戻し)せよ。
2. キー空間の全探索が必要な要件が出てきたら、まず `SCAN` ファミリー(`SCAN`, `HSCAN`, `SSCAN`, `ZSCAN`)の適用を検討せよ。
3. それでもパフォーマンスや表現力に限界を感じたなら、Redisのデータモデリングそのものが間違っている。リレーショナルDBや検索エンジンへのオフロードを検討するタイミングだ。
技術の本質を知る者だけが、スケールに耐えうるシステムを作り上げることができる。あなたの書くコードが、明日の本番環境の平穏を支えていることを忘れるな。
コメント