RedisキャッシュのTTL、エビクション、キャッシュスタンピード

1. 概要

Redisはデータキャッシュの一般的な選択肢で、データベースの負荷を軽減し、レスポンスタイムを高速化します。ただし、Redisを使うことは単にsetとgetだけではありません—TTL(Time To Live)がどのように動作するか、メモリが満杯になったときにRedisがキーを削除するエビクションメカニズムがどうなっているか、そして最も重要なことに、キャッシュスタンピードをどのように回避するか—ホットなキーが期限切れになったときに、数百のリクエストが同時にデータベースに殺到する状況です。

この記事は、基本的なTTL設定からRedisのエビクションポリシー、そしてスタンピードに対抗するための実践的なテクニック(ミューテックスロック、論理的な期限切れ、リクエスト合体)までをカバーしています。読み終わったら、高い負荷に耐えながらバックエンドのデータベースを保護するキャッシュレイヤーの設計方法を理解できるようになります。

2. Spring Boot での Redis セットアップ

まず、依存関係を追加します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

接続を設定します:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8

RedisTemplateのbeanはキー・バリューペアをカスタム型で操作するために使用されます:

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    return template;
}

GenericJackson2JsonRedisSerializerはオブジェクトをクラス情報を含むJSONとして保存し、読み込み時に正しい型でデシリアライズできます—redis-cliで読みやすいJdkSerializationRedisSerializerより便利です。

3. TTL—Time To Live

3.1. キーを書き込むときのTTLを設定

@Service
public class ProductCacheService {

    private final RedisTemplate<String, Object> redisTemplate;

    public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheProduct(Long productId, Product product) {
        String key = "product:" + productId;
        redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    }
}

set(key, product, Duration.ofMinutes(10))の行はキーを書き込み、TTLを10分に設定します。この時間が経過すると、Redisは自動的にキーを削除します—これをパッシブエスピレーションとアクティブエスピレーションの組み合わせと呼びます(Redisは100msごとにTTL付きのキーをランダムにスキャンして、読み込まれるのを待つ期限切れキーをRAMに放置しないようにします)。

TTLの残り時間を確認します:

redis-cli TTL product:123
(integer) 542

値-1はキーが存在するがTTLがないことを意味します(永遠に存在します)。値-2はキーが存在しないことを意味します。

3.2. 固定TTL対ジッター付きTTL(ジッター)

数千のキーを同時に同じTTLでキャッシュすると、それらすべてが同時に期限切れになります—「キャッシュミス」の波を引き起こします。解決策は、ジッターを追加することです:TTLに小さなランダムな値を加えます。

public void cacheProductWithJitter(Long productId, Product product) {
    long baseSeconds = 600;
    long jitter = ThreadLocalRandom.current().nextLong(0, 60);
    redisTemplate.opsForValue()
        .set("product:" + productId, product, Duration.ofSeconds(baseSeconds + jitter));
}

各キーは異なる時間に期限切れになります(600~660秒)。大量のキーが同時に期限切れになるときの「鳴り響く群れ」効果を回避します。

注釈: ジッターは、単一のホットキー(例:ホームページ、売上トップ製品)のキャッシュスタンピードを解決しません。その問題は、セクション5のテクニックが必要です。

4. Redis がメモリに達したときのエビクションポリシー

TTLはキーを時間経過で自動削除するのを処理します。しかし、Redisがmaxmemory制限に達すると、TTLの有無に関わらず、エビクションポリシーに従ってキーを削除する必要があります。

redis.confでの設定:

maxmemory 512mb
maxmemory-policy allkeys-lru

一般的なポリシー:

ポリシー 説明
noeviction キーを削除しません。メモリ書き込み時にエラーOOMを返します。Redisのデフォルト。
allkeys-lru 最近使用されたキーを最も削除します(Least Recently Used)、すべてのキーに適用。
volatile-lru 上記と同じですが、TTL付きキーのみ削除します。
allkeys-lfu アクセス頻度が最も低いキーを削除します(Least Frequently Used)。
volatile-ttl 最も期限切れが近いキーを優先削除します(TTLが最短)。
volatile-random TTL付きキーの集合からランダムに削除します。

純粋なキャッシュユースケースの場合、allkeys-lruまたはallkeys-lfuが安全な選択です。Redisはエラーを返すのではなく、自動的にメモリを解放します。Redisをキャッシュと失わないようにすべき重要なデータの両方に使用する場合(セッショントークンなど)、インスタンスを分けるか、TTLをマークされたキーのみを削除するためにvolatile-lruを使用してください。

注釈: noeviction(デフォルト)は、Redisのメモリが満杯になったときにアプリケーションにエラーOOM command not allowedを返します。Redisをキャッシュとして使用する場合は、常に適切なmaxmemory-policyを設定し、デフォルトのままにしないでください。

現在の設定を確認します:

redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"

5. キャッシュスタンピードとは何か

キャッシュスタンピード(dogpile effectとも呼ばれます)は、ホットキー—毎秒数百~数千のリクエストで読み込まれている—が予期せず期限切れになるときに発生します。すべてのリクエストがほぼ同時にキャッシュミスし、データを再構築するためにデータベースに殺到し、数百ミリ秒間データベースが過負荷になります。

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
    // キーがちょうど期限切れになった -> 数百のスレッドが同時にここに流入
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    return product;
}

売上トップの製品キーの場合、上記のコードは通常は完全に合理的ですが、TTLが期限切れになる正確な時点で、productRepository.findByIdは同じidに対して数百回同時に呼ばれる可能性があります。

5.1. 解決策:分散ロック(ミューテックス)

1つのリクエストだけがキャッシュを再構築でき、他のリクエストは待つか古い値を使用します。

public Product getProductSafely(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }

    String lockKey = "lock:product:" + id;
    Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

    if (Boolean.TRUE.equals(acquired)) {
        try {
            Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
            redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // ロックを取得できなかった -> 短く待ってからキャッシュを読み直す
        return waitAndRetry(key, id);
    }
}

private Product waitAndRetry(String key, Long id) {
    for (int i = 0; i < 10; i++) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        Product cached = (Product) redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }
    }
    // タイムアウト、フォールバックでDBを直接呼び出す
    return productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
}

setIfAbsentはRedisのSET lockKey 1 NX EX 5コマンドと同等です—アトミックで、1つのスレッドだけがロックを正常に作成します。ロックを取得できなかったスレッドは、データベースを呼び出すのではなく、100msごとにキャッシュをポーリングします。

5.2. 解決策:論理的な期限切れ(実TTLを使用しない)

キーをRedisで自動削除する代わりに、TTLの概念を値に埋め込み、キーが実際に消えないようにします。データが「論理的に期限切れ」であることが検出されると、1つのスレッドがバックグラウンドで再構築し、他のスレッドは古いデータをすぐに返します(stale-while-revalidate)。

public record CachedValue<T>(T data, Instant logicalExpireAt) {}

public Product getProductWithLogicalTtl(Long id) {
    String key = "product:" + id;
    CachedValue<Product> cached =
        (CachedValue<Product>) redisTemplate.opsForValue().get(key);

    if (cached == null) {
        return rebuildAndCache(id, key);
    }

    if (Instant.now().isAfter(cached.logicalExpireAt())) {
        String lockKey = "lock:product:" + id;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
        if (Boolean.TRUE.equals(acquired)) {
            // 非同期で再構築、現在のリクエストに古いデータをすぐに返す
            CompletableFuture.runAsync(() -> {
                try {
                    rebuildAndCache(id, key);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }
    }
    return cached.data(); // 常にデータを返す、古くなっているかもしれませんが
}

private Product rebuildAndCache(Long id, String key) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    CachedValue<Product> value = new CachedValue<>(product, Instant.now().plusSeconds(600));
    // 物理的なTTLは論理的なTTLより長く、再構築中にキーが消えないようにします
    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(15));
    return product;
}

重要なポイント:Redisの物理的なTTL(15分)は常に論理的なTTL(10分)より長いです。キーはRedisによって突然削除されることはありません—単に「古い」とマークされて、バックグラウンドで再構築されます。リクエストはデータベースを待つ必要がなく、データが論理的に期限切れになった直後でさえ。

6. スタンピード対策の動作テスト

複数のスレッドがキャッシュミスするときにデータベースへの呼び出しが1つだけであることを確認するテストを書きます:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
class ProductCacheServiceTest {

    @Autowired
    private ProductCacheService cacheService;

    @MockBean
    private ProductRepository productRepository;

    @Test
    void chiMotThreadDuocPhepGoiDatabaseKhiCacheMiss() throws InterruptedException {
        Long productId = 42L;
        Product product = new Product(productId, "メカニカルキーボード");
        given(productRepository.findById(productId)).willReturn(Optional.of(product));

        int threadCount = 50;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                cacheService.getProductSafely(productId);
                latch.countDown();
            });
        }
        latch.await(5, TimeUnit.SECONDS);
        executor.shutdown();

        verify(productRepository, times(1)).findById(productId);
    }
}
@Test
void khongCoLockThiDatabaseBiGoiNhieuLan() {
    Long productId = 99L;
    Product product = new Product(productId, "メカニカルキーボード");
    given(productRepository.findById(productId)).willReturn(Optional.of(product));

    List<Product> results = IntStream.range(0, 50)
        .parallel()
        .mapToObj(i -> cacheService.getProductUnsafe(productId))
        .toList();

    assertThat(results).hasSize(50);
    verify(productRepository, atLeast(2)).findById(productId); // スタンピードが発生したことを証明
}

最初のテストはgetProductSafely(ロック付き)を使用し、50のスレッドが実行されてもfindByIdが正確に1回だけ呼ばれることを期待します。2番目のテストはロックなしのバージョンを使用して逆を示します—findByIdが複数回呼ばれるのを確認し、ロックなしだとスタンピードが実際に発生することを証明します。

7. よくあるエラー

  • TTLを設定し忘れ:キーは永遠に存在し、Redisは徐々にRAMがいっぱいになり、完全にエビクションポリシーに頼らざるを得なくなります—必要なキーが誤って削除される可能性があります。
  • ホットキーに対するTTLが短すぎる:キャッシュミスの頻度が増加し、スタンピードが起こりやすくなります。高トラフィックのキーの場合、短い物理的なTTLではなく論理的な期限切れを優先すべきです。
  • ロックにTTLがない:ロック保持中のインスタンスがクラッシュした場合、ロックは永遠に存在し、他のスレッドは永遠に待ちます。常にロックに(TTL付きsetIfAbsentの)TTLを設定してください。
  • オブジェクトの連続性なしでシリアライズ:Productクラスのフィールドを変更してもバージョン管理を処理しない場合、古いキャッシュからのデシリアライズが失敗する可能性があります。スキーマを変更するときはキャッシュ削除メカニズム(キープレフィックスの変更)を追加すべきです。

8. 結論

TTLはキャッシュデータが時間経過で自動的にリフレッシュされるようにします。ただし、多くのキーが同時に期限切れになるのを避けるためにジッターを追加する必要があります。エビクションポリシーはRedisがメモリに達したときにどう対応するかを決定します—純粋なキャッシュユースケースにはallkeys-lru/allkeys-lfuを選択し、デフォルトのnoevictionを避けてください。ホットキーの場合、単純なTTLでは十分ではありません:1つのスレッドだけがキャッシュを再構築し、残りはデータベースに殺到する代わりに古いデータを読むことを確認するため、ミューテックスロックまたは論理的な期限切れが必要です。この3つのテクニックを正しく適用すれば、キャッシュレイヤーは単純なset/getと比べて高い負荷をはるかに上手く処理できるようになります。

rediscachespring-bootperformancejava