【実務・中級編】 EXPIREコマンド – Redis

Redis EXPIREの深層:TTLの裏側で何が起きているのか?

こんにちは。テックリードの私だ。
今日のコードレビューで、あるジュニアエンジニアが書いたセッション管理のコードに目が止まった。

レビュー対象のコード(※アンチパターン)
redis_client.set(f”session:{user_id}”, session_data)
redis_client.expire(f”session:{user_id}”, 86400) # 24時間

「動くからいいか」でスルーしてはいけない。この2ステップの書き方は、高負荷なプロダクション環境では致命的な原子性(Atomicity)の欠落を招く。さらに言えば、Redisが裏側でどのようにキーを消去しているかを知らなければ、メモリリークやレイテンシースパイクの罠に足を踏み入れることになる。

今回は、Redisのデータ構造詳細と操作の要である `EXPIRE` コマンドについて、単なるリファレンスを超えた「実務で生きる極限の知見」を授けよう。

—

1. `EXPIRE` のメカニズム:秒単位の寿命管理の裏側

まず、RedisがどのようにTTL(Time To Live)を管理しているかを解剖する。

すべてのキーとその有効期限は、内部のデータベース構造(`redisDb` 構造体)において、辞書(Dictionary)とは別の専用のハッシュテーブル(`expires` 辞書)で管理されている。この `expires` 辞書の値には、キーが消滅する正確なエポックミリ秒(Unix timestamp)が保持されている。

つまり、`EXPIRE key 60` を実行した瞬間、Redisは以下を行っている:
1. メインのデータ辞書で `key` を引く。
2. `expires` 辞書に `key` と「現在時刻 + 60秒」のポインタを登録する。

誤解されがちな「削除のタイミング」

ここで重要なのは、「TTLが切れた瞬間にキーが物理削除されるわけではない」という事実だ。Redisはシングルスレッドで動作するため、すべての期限切れキーを毎秒チェックして回るような愚直な実装はしていない。そんなことをすればCPUが焼き切れる。

Redisは、期限切れキーを削除するために以下の2つのハイブリッドメカニズムを採用している。

1. パッシブ削除(Lazy Expiration)
クライアントがそのキーにアクセスしようとコマンド(例: `GET`)を投げた瞬間、Redisは「おっと、このキーはもう期限切れだな」と検知し、その場で削除してから `nil` を返す。
2. アクティブ削除(Active Expiration)
Redisはバックグラウンドで定期的に(デフォルトでは1秒間に10回=100msごと)、`expires` 辞書からランダムにいくつかのキーをサンプリングし、期限切れのものを一斉にパージする。

—

2. 設計レビュー:なぜ `SET` と `EXPIRE` を分けるなと言ったのか?

冒頭のコードに戻ろう。

悪い例:2往復のネットワークコスト & アトミックではない
redis_client.set(f”session:{user_id}”, session_data)
redis_client.expire(f”session:{user_id}”, 86400)

この実装の何が問題か?
ネットワークを2回往復している点もさることながら、「`SET` が成功した直後、何らかの理由で `EXPIRE` が実行される前にプロセスがクラッシュ(あるいはネットワーク切断)したらどうなるか?」を考えてみてほしい。

答えは、「永遠に消えないゾンビキーの誕生」だ。セッションデータがメモリ上に残り続け、やばいスケールでメモリを圧迫し、OOM Killerの餌食になる。

正解:`SET` コマンドのオプションを活用せよ

現代のRedis(バージョン2.6.12以降)では、`SET` コマンド自体に有効期限を付与するオプションが統合されている。これを使えばアトミック性が保証される。

良い例:1往復で完結し、アトミックに期限が設定される
redis_client.set(
f”session:{user_id}”,
session_data,
ex=86400 # ex = 秒単位のEXPIRE
)

もしミリ単位の精度が必要なら、`px` オプション(ミリ秒)や、より柔軟な制御が必要な場合はLuaスクリプトを用いてアトミック性を担保すべきだ。「キーの生成と寿命の設定は同時に行う」。これをチームの鉄則にしてほしい。

—

3. 実務で役立つ堅牢な設計パターン

パターンA: レートリミッター(API流量制御)

APIのエンドポイントなどで、1分間に10回までのアクセス制限をかける場合のイディオムだ。`EXPIRE` は「初回アクセス時のみ」設定する必要がある。

import redis

client = redis.Redis(host=’localhost’, port=6379, decode_responses=True)

def check_rate_limit(user_id: str, limit: int = 10, window_sec: int = 60) -> bool:
key = f”rate_limit:{user_id}”

# パイプラインを用いてアトミックに実行
pipe = client.pipeline()
pipe.incr(key)
pipe.ttl(key) # 現在のTTLを確認
count, ttl = pipe.execute()

# 初回アクセス(キーが新しく作られた)の場合のみEXPIREを設定
if ttl == -1:
client.expire(key, window_sec)

return count <= limit ※注意: ここで `ttl` が `-1` を返すのは、キーが存在するが有効期限が設定されていない状態を指す。 ---

4. パフォーマンス上の注意点:OOMとレイテンシースパイクの恐怖

最後に、インフラを支えるシニアとして最も警告しておきたい「地雷」について話す。

1. 大量キーの同時失効(Expiration Storm)

もし、100万件のセッションデータを「すべて有効期限をちょうど『24時間後』」に設定して一斉に投入したとする。
24時間後、その100万件のキーが一斉にアクティブ削除のサンプリングに引っかかり、またはリクエストが集中してパッシブ削除が発動する。

結果どうなるか?
Redisのシングルスレッドが、「期限切れキーの削除処理」だけで暴走し、他のクライアントからのリクエストを一切処理できなくなる(レイテンシースパイク)。

対策:

有効期限にジッター(ランダムな揺らぎ)を加えよ。

import random

24時間 ± 5分(300秒)のランダムな揺らぎを持たせる
jitter = random.randint(-300, 300)
ttl_with_jitter = 86400 + jitter

client.set(f”session:{user_id}”, session_data, ex=ttl_with_jitter)

たったこれだけの工夫で、削除処理が時間軸上に分散され、システム全体の負荷が劇的に平準化される。

2. `EXPIRE` の戻り値の解釈を間違えるな

`EXPIRE` コマンドは、キーが存在し、正常に有効期限が設定された場合に `1` を返し、キーが存在しない場合は `0` を返す。
コード内で条件分岐を行う際は、この戻り値をハンドリングしているか確認してほしい。

—

チーフアーキテクトからの総括

`EXPIRE` は、Redisを単なる「高速なキャッシュ」から「信頼性の高いデータストア」へと昇華させるための基本にして極意だ。

1. `SET` と `EXPIRE` はアトミックに扱え(オプションやLuaを使え)。
2. キーの有効期限には必ずジッターを混ぜ、一斉失効(ストーム)を防げ。
3. パッシブ削除とアクティブ削除の仕組みを理解し、メモリの振る舞いをイメージせよ。

コードレビューでこれらのポイントがクリアされていないコードを見かけたら、即座に差し戻しを頼む。君たちの手で、プロダクション環境の堅牢性を守り抜け。

コメント

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