Article

🧠 RedisのTTLとEvictionを混同しない:Spring Bootのキャッシュ設計でハマるポイント

redis, springboot, java, cache

Redisをキャッシュとして使い始めると、よく出てくるのが次の2つです。

  • TTL(Time To Live)
  • Eviction(メモリ不足時のキー追い出し)

どちらも「Redisからキーが消える」ため混同しやすいのですが、発動する条件はまったく違います。

この記事では、Spring Boot + Redisでキャッシュを実装するときに知っておきたいTTLとEvictionの違いを、実装例と運用上の注意点まで含めて整理します。

先に結論

TTLは「このキーを何秒後に期限切れにするか」というキー単位の有効期限です。

Evictionは、Redisがmaxmemoryで設定されたメモリ上限に達したとき、どのキーを追い出して空きを作るかという仕組みです。

つまり、

TTL
キー自身の有効期限が来た
        ↓
キーが消える

Eviction
Redisのメモリが上限に近づいた
        ↓
ポリシーに従ってキーを追い出す

という違いがあります。

TTLとは

TTLは Time To Live の略で、「あと何秒そのキーを生存させるか」を表します。

Redisでは例えば次のように設定できます。

SET user:123 "Alice"
EXPIRE user:123 300

EXPIRE user:123 300 は、user:123を300秒後に期限切れにするコマンドです。

残り時間はTTLで確認できます。

TTL user:123

Redis公式ドキュメントでは、TTLが経過するとキーは自動的に削除されます。またSETのオプションを使えば、値の保存と有効期限設定をまとめて行えます。

SET user:123 "Alice" EX 300

Spring BootでTTLを設定する

Spring Data RedisのRedisCacheManagerを使う場合は、entryTtlでキャッシュエントリのTTLを設定できます。

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    RedisCacheManager cacheManager(
            RedisConnectionFactory connectionFactory) {

        RedisCacheConfiguration config =
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(10));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(config)
                .build();
    }
}

これでキャッシュに保存されたエントリには10分のTTLが設定されます。

例えばサービス側ではSpring Cacheを使って、

@Cacheable(cacheNames = "users", key = "#userId")
public User findUser(String userId) {
    return userRepository.findById(userId)
            .orElseThrow();
}

のように書けます。

初回アクセスではDBから取得し、以降はRedisキャッシュを利用します。TTLが切れた後のアクセスでは再びDBから取得されます。

「アクセスされたらTTLが延びる」とは限らない

ここはハマりやすいポイントです。

通常のTTLでは、キャッシュをGETしただけでは有効期限はリセットされません。

例えばTTLが5分なら、途中で何回読まれても、書き込みから5分後には期限切れになります。

一方、TTI(Time To Idle) は「最後にアクセスしてから何分」という考え方です。

Spring Data RedisのCache実装にはTTI相当の動作を有効化する仕組みがあります。

RedisCacheConfiguration config =
        RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5))
                .enableTimeToIdle();

Spring Data Redisの公式ドキュメントによると、この機能では読み取り時にRedisのGETEXを利用してTTLを更新します。

注意点として、GETEXはRedis 6.2以降で利用できます。対応していないRedisに対してTTIを有効化するとコマンド実行エラーになります。

Evictionとは

TTLとは別に、RedisにはEvictionがあります。

Redisではmaxmemoryを設定すると、利用メモリに上限を設けられます。

maxmemory 2gb

上限に達したときの挙動を決めるのがmaxmemory-policyです。

代表的なものは次の通りです。

ポリシー 動作
noeviction キーを追い出さず、新しいデータを書き込むコマンドでエラーを返す
allkeys-lru 全キーから、最近使われていないキーを優先して追い出す
allkeys-lfu 全キーから、利用頻度が低いキーを優先して追い出す
allkeys-random 全キーからランダムに追い出す
volatile-lru TTLが設定されたキーから、最近使われていないものを追い出す
volatile-lfu TTLが設定されたキーから、利用頻度が低いものを追い出す
volatile-ttl TTLが設定されたキーから、残りTTLが短いものを優先して追い出す

LRUは Least Recently Used、つまり「最近使われていないもの」。

LFUは Least Frequently Used、つまり「利用頻度が低いもの」です。

TTLが1時間でも5分後に消えることはある

ここがTTLとEvictionを分けて理解する理由です。

例えば、

TTL = 1時間
maxmemory = 2GB
maxmemory-policy = allkeys-lru

という設定だったとします。

キーのTTLがあと55分残っていても、Redisがメモリ上限に達してLRUの追い出し対象になれば、そのキーは消える可能性があります。

そのため、

TTLを1時間にしたから、必ず1時間キャッシュされる

とは考えない方が安全です。

TTLは「期限切れになる時刻」を決める仕組みであり、Evictionによる削除を防ぐ保証ではありません。

キャッシュ用途ならどう設計するか

Redis公式ドキュメントでは、すべてのキーをキャッシュとして扱うケースではallkeys-lruなどを検討できます。

例えば、

maxmemory 2gb
maxmemory-policy allkeys-lru

という構成です。

ただし、Redisにキャッシュだけでなく重要なデータも混在させている場合は注意が必要です。

例えば、

session:xxx
cache:user:xxx
cache:product:xxx
important:job-state:xxx

が同じRedisにあり、allkeys-lruを使えば、important:job-state:xxxも追い出し候補になり得ます。

「Redisだから消えてもよい」ではなく、キーごとの役割を明確にする必要があります。

キャッシュミス前提で実装する

Redisキャッシュを使うコードでは、キャッシュが存在しないケースを正常系として扱うことが重要です。

基本的なCache-Asideパターンは、

1. Redisを見る
2. あれば返す
3. なければDBを見る
4. Redisへ保存する
5. 結果を返す

です。

Springの@Cacheableを使うと、このパターンをかなり簡潔に実装できます。

RedisのキーはTTLやEvictionで消える可能性があるため、キャッシュ消失をアプリケーション障害として扱わない設計にします。

TTLを短くしすぎるとDB負荷が増える

TTLを短くするとデータの鮮度は上げやすくなりますが、その分キャッシュミスが増えます。

例えばアクセスの多いデータでTTLを10秒にすると、期限切れのたびにDBへ問い合わせが発生します。

逆にTTLを長くするとDB負荷は減りますが、更新後も古いキャッシュを返す時間が長くなります。

そのため、TTLは単純に短いほど良いわけではありません。

更新頻度
データ鮮度の要求
アクセス頻度
DBへの負荷
キャッシュ削除の仕組み

を合わせて決めます。

更新時にキャッシュを消す方法もある

例えばユーザー情報を更新したタイミングで、対応するキャッシュを削除できます。

@CacheEvict(cacheNames = "users", key = "#user.id")
public User updateUser(User user) {
    return userRepository.save(user);
}

この場合、次回のfindUserでDBから最新値を読み込み、再びRedisへキャッシュします。

TTLだけに頼るより、更新処理とキャッシュ削除を組み合わせた方が古いデータを返す時間を短くできます。

運用ではevicted_keysを見る

RedisのEvictionが頻発しているか確認したい場合はINFOが役立ちます。

INFO stats

特に確認したいのが、

evicted_keys
expired_keys

です。

expired_keysは期限切れになったキー、evicted_keysはメモリ制約などによって追い出されたキーを把握するための指標です。

evicted_keysが想定以上に増えている場合、

  • Redisのメモリ容量が不足していないか
  • Eviction Policyが用途に合っているか
  • キャッシュするデータ量が多すぎないか

を確認します。

まとめ

Redisでキーが消える理由を調査するときは、まずTTLとEvictionを分けて考えると整理しやすくなります。

TTL
→ キーに設定した有効期限で消える

Eviction
→ メモリ上限に達したとき、ポリシーに従って消える

Spring BootでRedisをキャッシュとして使うなら、

  1. entryTtlでキャッシュの寿命を決める
  2. 必要なら更新時に@CacheEvictする
  3. キャッシュミスを正常系として扱う
  4. Redis側のmaxmemory-policyも確認する
  5. expired_keysevicted_keysを監視する

という流れで考えると、キャッシュ周りのトラブルを切り分けやすくなります。

参考資料