TT Lab
はじめる
学ぶ 学習パス コース

Redisとキャッシュ

TTLを付けなかったキーが作るもの

TT Labで続きを見る

一言でいうと

期限切れは、時刻になってもすぐには起きません。アクセスしたときに消す(遅延削除)か、バックグラウンドがサンプルを取って消します(能動的な期限切れ処理)。

なぜ必要なのか

Redisインスタンスのメモリが増え続けるのに、原因が見つからない状況はよくあります。たいてい答えは1つです。TTLを付けていないキーが混ざっています。

問題は、それが目立たないことです。キャッシュのコードはたいていSETEXを使っていますが、どこか1か所でSETだけを使っていたり、SET key valで更新しながら、もともとあったTTLを消してしまったりします。この2つ目が特に巧妙です。SETは既存のTTLを取り除きます。TTLを維持するには、KEEPTTLオプションが必要です。

どう動くのか

期限切れの仕組みを、まず正確に知る必要があります。Redisは2つの方式を併用します。遅延削除は、キーにアクセスした瞬間に期限切れを確認して消します。能動的な期限切れ処理は、バックグラウンドでTTLのあるキーをランダムにサンプリングして、期限切れのものを消します。そのため、期限切れの時刻が過ぎているのに誰もアクセスしないキーは、しばらくメモリに残っています。DBSIZEが予想より大きい理由が、これです。

maxmemoryに達すると、エビクションポリシーが働きます。選択肢は8種類ですが、実務で重要なのは3つです。

noevictionはデフォルト値で、上限に達すると書き込みを拒否します。キャッシュとして使うときにこの設定だと、ある日突然、すべての書き込みが失敗します。

allkeys-lruまたはallkeys-lfuは、すべてのキーをエビクションの対象と見ます。純粋なキャッシュ用途なら、こちらです。筆者の推奨はallkeys-lfuです。最近性よりも頻度のほうが、キャッシュのヒット率によく合うからです。

volatile-lruは、TTLが設定されたキーだけをエビクトします。キャッシュと永続データが1つのインスタンスに混ざっているときに使います。ただし、この設定でキャッシュキーにTTLを付け忘れると、そのキーは永遠にエビクトされないままメモリを食います。

キャッシュが崩れる3つのパターン

TTL設計は、この3つを防ぐためのものです。名前が似ていて紛らわしいですが、原因と対策が違います。

名前 何が起きるか 防ぐ方法
スタンピード(stampede) 人気のキーが期限切れになる瞬間に、数千のリクエストが同時にDBへ ジッター、ロック、早期再計算
貫通(penetration) 存在しないキーを調べ続ける。キャッシュに何も残らず、毎回DBへ 「ない」も短くキャッシュする、ブルームフィルター
雪崩(avalanche) 多くのキーが一度に期限切れになる、またはキャッシュサーバーがダウンする ジッター、TTLの分散、多層キャッシュ

スタンピードはロックで防ぎます。最初のリクエストだけがDBに行き、残りは待ちます。

def get_or_load(key, loader, ttl=300):
    v = r.get(key)
    if v is not None:
        return v
    # SET NX 로 잠금 하나만 얻는다. 얻지 못한 요청은 잠깐 기다렸다 다시 읽는다.
    if r.set(f"lock:{key}", "1", nx=True, ex=10):
        try:
            v = loader()
            r.set(key, v, ex=ttl + random.randint(-30, 30))   # 지터
            return v
        finally:
            r.delete(f"lock:{key}")
    time.sleep(0.05)
    return r.get(key) or loader()      # 그래도 없으면 어쩔 수 없이 직접

より良い方法は早期再計算です。TTLが終わる前に確率的に先に更新すれば、期限切れの瞬間そのものがなくなります。残りのTTLが短いほど更新確率を高める方式(probabilistic early expiration)が、広く使われています。

貫通は、「ない」もキャッシュします。ただし短く(30–60秒)設定する必要があり、そうすると本当のデータができたときに、すぐ反映されます。

キャッシュと原本がずれる瞬間

書き込みがあれば無効化が必要で、無効化には順序の問題があります。

❌ 캐시를 먼저 지우고 DB 를 쓴다
   1. DEL cache          2. (다른 요청이 옛 값을 읽어 캐시에 다시 넣는다)
   3. UPDATE db          → 캐시에 옛 값이 영원히 남는다

✅ DB 를 먼저 쓰고 캐시를 지운다 (cache-aside)
   1. UPDATE db          2. DEL cache
   → 사이에 읽은 요청은 옛 값을 보지만, 곧 사라진다

それでも、まれにずれます。完全に防ぐには、遅延二重削除(書いて消したあと、数百ミリ秒後にもう一度消す)や、変更ログ(CDC)の購読を使います。ほとんどのサービスでは、短いTTLのほうが安上がりな答えです。ずれても、TTLの分だけしかずれません。

現場での姿

TTLにジッターを入れることも重要です。デプロイ直後にキャッシュを一斉に埋めると、TTLがまったく同じなので、1時間後にすべてが同時に期限切れになります。その瞬間がスタンピードです。ttl = base + random(-spread, +spread)の1行で、期限切れが時間軸に散らばります。

そして、監査が必要です。SCANで走査しながらTTLが-1のキー(期限なし)を数えるスクリプトを定期的に実行すれば、新しく入ったコードがTTLを付け忘れたときに、すぐ捕まります。

次のラボですること

TTLをかけて確認し、SETがTTLを消してしまう落とし穴を再現し、KEEPTTLで防ぎ、エビクションポリシーを変えて実際にキーがエビクトされるのを確認し、最後にTTLのないキーを見つける監査スクリプトを作ります。