Spring BootでのキャッシュとRedisの@Cacheable設定

1. 概要

多くのアプリケーションは同じ遅いクエリを何度も呼び出しています。例えば、商品情報の取得、設定情報、または為替レートなどです。Spring Cache Abstractionはいくつかのアノテーションでこの問題を解決します。Redisは複数のインスタンス間で共有される共有キャッシュストアとして機能します。

この記事を読み終わると、次のようなことができるようになります:

  • キャッシュプロバイダーとしてRedisを設定する。
  • @Cacheable、@CachePut、@CacheEvictを使用する。
  • キャッシュごとに個別のTTLを設定し、JSON形式でデータを保存する。
  • JUnit 5とTestcontainersを使用してキャッシュの動作をテストする。

2. Maven依存関係

Spring CacheとRedis用に2つのスターターが必要です。

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

テスト用に、Testcontainersを追加します。Spring Bootはこれらの依存関係のバージョンを管理しています:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

3. Redis接続の設定

application.ymlでRedisアドレスを宣言し、Redisをキャッシュプロバイダーとして選択します:

spring:
  data:
    redis:
      host: localhost
      port: 6379
  cache:
    type: redis

各プロパティの意味:

  • spring.data.redis.*は接続を設定します。Spring BootはデフォルトでLettuceクライアントを使用します。
  • spring.cache.type: redisはプロバイダーを明示的に指定します。Redisがクラスパスにある場合、Spring Bootは自動的にRedisを選択します。明示的に指定することで、クラスパスに複数のプロバイダーがある場合の混乱を避けられます。

DockerでローカルのRedisをすぐに実行できます:

docker run -d --name redis -p 6379:6379 redis:7-alpine

4. @Cacheableの基本的な例

4.1. モデルとリポジトリで遅いクエリをシミュレート

商品用の単純なrecordを使用します:

public record Product(Long id, String name, BigDecimal price) {}

以下のリポジトリはデータベースが遅いことをシミュレートします。また、クエリが呼び出された回数をカウントするため、キャッシュを検証することができます:

@Repository
public class ProductRepository {

    private final Map<Long, Product> store = new ConcurrentHashMap<>(Map.of(
            1L, new Product(1L, "Nón lá Huế", new BigDecimal("150000")),
            2L, new Product(2L, "Áo bà ba", new BigDecimal("320000"))
    ));
    private final AtomicInteger queryCount = new AtomicInteger();

    public Optional<Product> findById(Long id) {
        queryCount.incrementAndGet();
        sleep(1000); // クエリに1秒かかることをシミュレート
        return Optional.ofNullable(store.get(id));
    }

    public Product save(Product product) {
        store.put(product.id(), product);
        return product;
    }

    public void deleteById(Long id) {
        store.remove(id);
    }

    public int getQueryCount() { return queryCount.get(); }

    public void resetQueryCount() { queryCount.set(0); }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

4.2. キャッシュを有効にしてメソッドをマーク

@EnableCachingでキャッシングメカニズムを有効にします。このアノテーションを@Configurationクラスに配置します(次のセクション5で説明)またはメインクラスに配置します。その後、キャッシュが必要なメソッドにアノテーションを付けます:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "products", key = "#id")
    public Product findById(Long id) {
        return productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
    }
}

処理フローは次のようになります:

  • cacheNames = "products"はキャッシュ名です。Redisではデフォルトキーの形式はproducts::<key>です。
  • key = "#id"はSpEL式でパラメータidをキーとして使用します。メソッドにパラメータが1つだけの場合、このプロパティを省略することができます。
  • 最初の呼び出し時、Springはキャッシュにキーを見つけません。メソッドを実行してから結果をRedisに保存します。
  • 同じidでの後続の呼び出しでは、Springはキャッシュから結果を返します。メソッドは実行されません。
  • メソッドが例外(ProductNotFoundException)をスローする場合、Springはキャッシュに何も保存しません。

findById(1L)を2回呼び出してからRedisを確認します:

redis-cli KEYS '*'
redis-cli TTL products::1
1) "products::1"
(integer) 1798

最初の呼び出しは約1秒かかります。2番目の呼び出しは数ミリ秒だけかかります。TTLは約1800秒残っています。これは次のセクションでproductsキャッシュの設定が30分のためです。

5. RedisCacheManagerの設定:TTLとシリアライザー

デフォルトでは、RedisCacheManagerはJDKシリアライゼーションを使用します。この方法には3つの欠点があります:

  • クラスはSerializableを実装する必要があります。
  • Redisのデータはバイナリなので、読むのが難しいです。
  • クラス構造を変更すると、データが破損しやすくなります。

デフォルトでキャッシュもTTLがなく、エントリは永遠に存在します。この両方の問題を修正します:

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration defaultCacheConfig() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues()
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new GenericJackson2JsonRedisSerializer()));
    }

    @Bean
    public RedisCacheManagerBuilderCustomizer productCacheCustomizer() {
        return builder -> builder.withCacheConfiguration("products",
                RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(30))
                        .disableCachingNullValues()
                        .serializeValuesWith(RedisSerializationContext.SerializationPair
                                .fromSerializer(new Jackson2JsonRedisSerializer<>(Product.class))));
    }
}

各beanの説明:

  • RedisCacheConfiguration beanはすべてのキャッシュのデフォルト設定です。Spring Bootはこのbeanを自動的に認識します。すべてのエントリはTTL 10分を持ち、nullを保存しないようにし、値はJSON形式で保存されます。
  • RedisCacheManagerBuilderCustomizerはproductsキャッシュ用の設定をオーバーライドします。このキャッシュはTTL 30分を持ち、特定のタイプProduct.classのシリアライザーを使用します。

なぜproductsはJackson2JsonRedisSerializer<>(Product.class)を使用するのですか?GenericJackson2JsonRedisSerializerはタイプ情報(@class)をJSONに保存する必要があります。recordのようなfinalクラスでは、このメカニズムはしばしば正しいタイプの代わりにLinkedHashMapを返し、ClassCastExceptionが発生します。特定のタイプのシリアライザーはこの問題を完全に回避します。

RedisのデータはもはやJSONで直接読み取り可能です:

redis-cli GET products::1
"{\"id\":1,\"name\":\"Nón lá Huế\",\"price\":150000}"

注意: RedisCacheConfiguration beanを自分で宣言する場合、application.ymlのspring.cache.redis.time-to-live、spring.cache.redis.key-prefixなどのプロパティは無視されます。2つの構成方法のいずれかを選択し、混在させないようにしてください。

6. キャッシュの更新と削除:@CachePutと@CacheEvict

キャッシュは、そのデータが正確である場合にのみ有効です。商品が変更されると、対応するエントリを更新または削除する必要があります。

@CachePut(cacheNames = "products", key = "#product.id()")
public Product update(Product product) {
    return productRepository.save(product);
}

@CacheEvict(cacheNames = "products", key = "#id")
public void delete(Long id) {
    productRepository.deleteById(id);
}

@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() {
    // 例:大量の商品をインポートした後に呼び出す
}

各アノテーションの違い:

  • @CachePutは常にメソッドを実行してから、キー#product.id()でキャッシュに戻り値を書き込みます。したがって、メソッドはキャッシュするべき正しいオブジェクトを返す必要があります。voidを返す場合、キャッシュに正しいデータがありません。
  • @CacheEvictはメソッドが正常に実行した後、キーに応じてエントリを削除します。メソッド実行前に削除する必要がある場合は、beforeInvocation = trueを追加してください。
  • allEntries = trueはproductsキャッシュ全体を削除します。Redisでは、この操作はパターンに応じてキーをスキャンします。本当に必要な場合だけ使用してください。

7. キャッシュ制御:condition、unless、sync

@Cacheableにはいくつかの便利なプロパティがあります:

@Cacheable(cacheNames = "productSearch",
           key = "#keyword.toLowerCase()",
           condition = "#keyword.length() >= 3",
           unless = "#result.isEmpty()")
public List<Product> search(String keyword) {
    // 遅いクエリ...
    return List.of();
}

@Cacheable(cacheNames = "products", key = "#id", sync = true)
public Product findHotProduct(Long id) {
    return findFromDatabase(id); // 同じクラスのprivateメソッド、データベースから直接読み取る
}

各プロパティの意味:

  • conditionはメソッドが実行される前に評価されます。ここでは、3文字未満のキーワードは検索されず、キャッシュにも保存されません。
  • unlessはメソッドが実行された後に評価され、#resultを使用できます。ここでは、空のリストは保存されません。
  • key = "#keyword.toLowerCase()"は「Nón」と「nón」が同じエントリを使用することを保証します。
  • sync = trueはキャッシュスタンピードを防ぎます。複数のスレッドが同じキーをミスする場合、1つのスレッドだけがメソッドを実行し、他のスレッドが結果を待ちます。このメカニズムは単一JVM内でのみ同期し、インスタンス間の分散ロックではありません。

productSearchキャッシュは個別の設定がないため、セクション5のデフォルト設定(TTL 10分、GenericJackson2JsonRedisSerializer)を使用します。値がrecordを含むList<Product>の場合、productsと同じ方法で特定のタイプのシリアライザーを宣言すべきです。このようにして、セクション5で説明したClassCastExceptionエラーを避けることができます。

8. キャッシュの動作をテストする

最も信頼できる方法は、Testcontainersを使用して実際のRedisでテストすることです。@ServiceConnectionアノテーションはホストとポートをSpring Bootに自動的に設定するため、application.ymlを変更する必要はありません。

@SpringBootTest
@Testcontainers
class ProductServiceCacheTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
                    .withExposedPorts(6379);

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Autowired
    private CacheManager cacheManager;

    @BeforeEach
    void setUp() {
        cacheManager.getCache("products").clear();
        productRepository.resetQueryCount();
    }

    @Test
    void whenFindByIdCalledTwice_thenRepositoryIsQueriedOnce() {
        Product first = productService.findById(1L);
        Product second = productService.findById(1L);

        assertThat(first).isEqualTo(second);
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
        assertThat(cacheManager.getCache("products").get(1L, Product.class))
                .isEqualTo(first);
    }

    @Test
    void whenProductUpdated_thenCacheReturnsNewValue() {
        productService.findById(2L);

        productService.update(new Product(2L, "Áo bà ba lụa", new BigDecimal("450000")));
        Product cached = productService.findById(2L);

        assertThat(cached.name()).isEqualTo("Áo bà ba lụa");
        assertThat(productRepository.getQueryCount()).isEqualTo(1);
    }
}

これらの2つのテストは次の点を検証します:

  • 最初のテスト:2回呼び出されても、リポジトリは1回だけ呼び出されます。エントリは正しいタイプProductでキャッシュに実際に存在します。これはシリアライザーが正しく機能していることを確認します。
  • 2番目のテスト:@CachePutは新しい値を書き込むため、その後の読み込みはキャッシュから取得し、リポジトリに追加で呼び出しません。
  • @BeforeEachはキャッシュとカウンターをクリアするため、テストは互いに独立しています。コンテナはstaticなので、クラス全体で1回だけ起動します。

9. よくあるエラーと使用すべきでない場合

自己呼び出し。 Spring Cacheはプロキシを使用して機能します。ProductService内のメソッドがthis.findById(id)を呼び出す場合、呼び出しはプロキシを経由せずにオブジェクトに直接移動するため、キャッシュは適用されません。これが、セクション7のfindHotProductの例がデータベースを直接読み込む理由です。privateメソッドのアノテーションも効果がありません。修正方法は、キャッシュが必要なメソッドを別のbeanに分けることです。

nullを有効にした場合のnull値のキャッシュ。 disableCachingNullValues()を使用する場合、メソッドがnullを返すと、エラーが発生します:

java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values;
Avoid storing null via '@Cacheable(unless="#result == null")' ...

2つの処理方法があります:unless = "#result == null"を追加するか、セクション4の例のように例外をスローします。

クラス構造の変更。 Productにフィールドを追加したり、フィールド名を変更したりしても、Redisにはまだ古い形式のJSONがあります。結果として、デシリアライズエラーまたは欠落したフィールドが発生する可能性があります。デプロイ時にキャッシュ名(例:products-v2)を変更するか、キャッシュをクリアする必要があります。

JPAエンティティをキャッシュ。 エンティティにはlazyプロキシと双方向の関係があり、シリアライズ時にエラーが発生しやすいです。代わりにDTOまたはrecordをキャッシュしてください。

注意: 口座残高やインベントリなど、絶対的な一貫性が必要なデータをキャッシュすべきではありません。また、データが頻繁に変更される場合もキャッシュすべきではありません。その場合、キャッシュヒット率は低くなり、Redisへの追加の呼び出しでのみ消費します。1つのインスタンスのみを実行し、データが小さい場合は、Caffeineのようなインメモリキャッシュの方が高速で単純です。

10. 結論

Spring BootのキャッシュプロバイダーとしてRedisを設定しました。@Cacheableを使用して読み取り、@CachePutを使用して更新、@CacheEvictを使用してエントリを削除します。キャッシュごとに個別のTTLとJSONシリアライザーを設定し、Testcontainersを使用した実際のRedisでのテストで動作を検証しました。

覚えておくべき3つのこと:

  • 常にTTLを設定します。
  • エンティティの代わりにDTOをキャッシュします。
  • 自己呼び出しに注意します。

これら3つのことを正しく行えば、キャッシュを本番環境にデプロイするときの大部分の問題を回避できます。

spring-bootrediscachespring-cachetestcontainers