@Cacheableを付けたのに、別の引数を渡しても同じ結果が返ってくる。nullまで律儀にキャッシュされてしまう。引数が2つあるとき、keyをどう書けばいいのか分からない。キャッシュ周りの相談は、だいたいこの3つのどれかですよね。

原因はどれも「キーと条件の設計」にあります。この記事では@EnableCachingとプロバイダの設定は済んでいる前提で、キーの決まり方、key属性のSpEL、独自KeyGenerator、conditionとunlessの違い、@Cachingのまとめ方に絞って解説します。基本から確認したい方はSpring Bootのキャッシュ機能の基本を先に読んでみてください。

前提と検証環境

Spring Boot 3.x(Spring Framework 6.x)とCaffeine 3.xで動作確認しています。設定はこれだけです。

spring:
  cache:
    type: caffeine
    caffeine:
      spec: recordStats

recordStatsは最後の検証でヒット数を読むために付けています。maximumSizeやexpireAfterWriteといったチューニングはCaffeineキャッシュのチューニング記事に任せて、ここでは触れません。サンプルはUserServiceで統一します。

keyを書かないとき、何がキーになるのか

key属性を省略すると、SimpleKeyGeneratorがキーを決めます。ルールは引数の個数で変わります。

  • 引数なしなら SimpleKey.EMPTY
  • 引数1つならその値そのもの(Longの1LやStringがそのままキー。nullや配列のときだけSimpleKeyで包まれる)
  • 引数2つ以上なら全引数を包んだSimpleKey

SimpleKeyは中の引数でequals/hashCodeを判定します。つまり検索条件をオブジェクトにまとめて渡すなら、そのクラスにequals/hashCodeが必要です。実装していない普通のクラスを引数にすると毎回別キー扱いになり、「キャッシュが全然効かない」という症状になります。record UserQuery(Long id, String locale) のようにrecordにしておけば自動で実装されるので安心です。

もう一つ大事なのは メソッド名もクラス名もキーに含まれない ことです。これが次の落とし穴につながります。

落とし穴 - 同じキャッシュ名を複数メソッドで共有するとキーが衝突する

@Service
public class UserService {

    @Cacheable("users")
    public User findById(Long id) { ... }

    @Cacheable("users")
    public UserSummary findSummaryById(Long id) { ... }
}

どちらもキーは1Lです。先にfindById(1L)を呼ぶとUserが”users”に入り、次にfindSummaryById(1L)を呼ぶとキャッシュヒットしてそのUserが返ってきます。戻り値型が違うので、呼び出し側でClassCastExceptionになります。

@Test
void 同じキャッシュ名はキーが衝突する() {
    userService.findById(1L);
    assertThrows(ClassCastException.class,
        () -> userService.findSummaryById(1L));
}

戻り値型が同じだと例外すら出ず、「別のメソッドなのに同じ結果が返る」という形で静かに壊れます。回避策は、キャッシュ名を分ける、keyに#root.methodNameを入れる、独自KeyGeneratorを使う、の3つです。後ろ2つをこのあと見ていきます。

なお「キャッシュが効かない」症状のうち、同じクラス内からの呼び出し(self-invocation)が原因のものはキー設計とは別の話です。心当たりがあればAOPプロキシとself-invocationの記事を確認してください。同じ仕組みで@Transactionalのロールバックが効かないケースはトランザクション管理の記事でも触れています。

key属性でSpELを書く

// 引数名で参照
@Cacheable(value = "users", key = "#id")
public User findById(Long id) { ... }

// オブジェクトのプロパティ
@Cacheable(value = "users", key = "#user.id")
public User refresh(User user) { ... }

// 複数引数を連結した複合キー
@Cacheable(value = "users", key = "#id + ':' + #locale")
public User findByIdAndLocale(Long id, String locale) { ... }

// メソッド名を混ぜて衝突を避ける
@Cacheable(value = "users", key = "#root.methodName + ':' + #id")
public UserSummary findSummaryById(Long id) { ... }

#idのような引数名参照は、コンパイル時の-parametersオプションに依存します。Spring Boot Gradleプラグインを使うか、Mavenならspring-boot-starter-parentを親POMにしていれば既定で付きます。親POMを使わずBOMだけimportしている構成では付かないので、maven-compiler-pluginに <parameters>true</parameters> を自分で書いてください。Spring Framework 6.1からはデバッグ情報からの名前解決もなくなったため、名前が取れないと#idはnullになり、初回呼び出しでこう落ちます。

java.lang.IllegalArgumentException: Null key returned for cache operation [...]. If you are using named parameters, ensure that the parameter names are available in the compiled class file.

素のjavacでビルドするなら、#p0や#a0(0番目の引数)で逃げられます。

#rootからはmethodName、targetClass、target、args、cachesが参照できます。#resultは@Cacheableのkeyでは使えません。キーはメソッド実行前に決まるからです。#resultが使えるのはunless、@CachePutのkey、beforeInvocation = falseの@CacheEvictの3か所で、それぞれ後で出てきます。

もう一つ怖いのがタイポです。#iddのように存在しない変数を書いてもSpELは例外を出さずnullを返します。単独キーなら上のNull keyエラーで気づけますが、#root.methodName + ':' + #idd のような連結キーだと findSummaryById:null という固定キーになり、例外すら出ずに全呼び出しが同じエントリを共有します。SpelEvaluationExceptionになるのは#user.iddのようなプロパティ名の間違いだけです。だからテストでキャッシュの中身まで見る必要があります。

独自KeyGeneratorで共通ルールを持たせる

「全メソッドでクラス名とメソッド名を含める」「テナントIDを混ぜる」といったルールは、毎回SpELで書くよりKeyGeneratorにまとめた方が安全です。

@Component("tenantKeyGenerator")
public class TenantKeyGenerator implements KeyGenerator {

    @Override
    public Object generate(Object target, Method method, Object... params) {
        // テスト等でテナントが無い場合に備えてデフォルト値を入れる
        String tenant = Objects.requireNonNullElse(TenantContext.get(), "default");
        // target がプロキシでも元クラス名を取るために ultimateTargetClass を使う
        String className = AopProxyUtils.ultimateTargetClass(target).getSimpleName();
        return tenant + ":" + className + "." + method.getName()
            + ":" + SimpleKeyGenerator.generateKey(params);
    }
}
@Cacheable(value = "users", keyGenerator = "tenantKeyGenerator")
public User findById(Long id) { ... }

keyとkeyGeneratorは同時に指定できません。両方書くと起動時にこう怒られます。

java.lang.IllegalStateException: Invalid cache annotation configuration on 'public com.example.User com.example.UserService.findById(java.lang.Long)'. Both 'key' and 'keyGenerator' attributes have been set. These attributes are mutually exclusive: ...

key未指定のメソッド全部に適用したいなら、CachingConfigurerを実装して既定のKeyGeneratorを差し替えます。上で@Component登録したBeanをそのまま注入して返せば、インスタンスが二重にできません。

@Configuration
public class CacheConfig implements CachingConfigurer {

    private final TenantKeyGenerator tenantKeyGenerator;

    public CacheConfig(TenantKeyGenerator tenantKeyGenerator) {
        this.tenantKeyGenerator = tenantKeyGenerator;
    }

    @Override
    public KeyGenerator keyGenerator() {
        return tenantKeyGenerator;
    }
}

ただしこれは全キャッシュ名に効くので、keyGenerator属性での個別指定とどちらか一方に寄せた方が読みやすいです。

conditionとunlessの違い

どちらも「条件を満たしたらキャッシュしない」ように見えますが、評価タイミングが違います。

  • conditionはメソッド実行前に評価される。参照できるのは引数だけ
  • unlessはメソッド実行後に評価される。#resultで戻り値を参照できる

「nullをキャッシュしない」は戻り値を見る必要があるので、unlessの仕事です。

// id が正のときだけ対象にし(実行前)、null は入れない(実行後)
@Cacheable(value = "users", condition = "#id > 0", unless = "#result == null")
public User findById(Long id) { ... }

// null と空リストは入れない(null 上で isEmpty() を呼ぶと例外になるので先に弾く)
@Cacheable(value = "userList", unless = "#result == null || #result.isEmpty()")
public List<User> findAll() { ... }

// Optional は Spring が中身を取り出してから評価する
@Cacheable(value = "users", unless = "#result == null")
public Optional<User> findOptional(Long id) { ... }

よくあるミスが condition = "#result != null" と書いてしまうパターンです。実行前なので#resultはnull、条件は常に偽になり、何もキャッシュされません。

Optional戻り値は、Spring Framework 4.3以降(6.xでも同じ)はキャッシュに入る前に中身が取り出されます。Optional.emptyならunlessの#resultはnullになるので、普通のnull判定で弾けます。ヒット時はOptionalに包み直して返されるので、呼び出し側は何も意識しなくて大丈夫です。

評価の順序も整理しておきましょう。conditionが偽ならキャッシュ参照も保存もされず、unlessも評価されません。キャッシュヒットした場合はメソッドが実行されないのでunlessは評価されず、保存済みの値がそのまま返ります。

@CachePut・@CacheEvictでもkeyを揃える

更新側と削除側でもkeyとconditionは同じように使えます。大事なのは 参照側と同じ式を書く ことです。式がずれると別エントリになり、更新したはずなのに古い値が返り続けます。

@CachePut(value = "users", key = "#user.id")
public User update(User user) { ... }

// @CachePut の key は実行後に評価されるので、採番された ID を #result で使える
@CachePut(value = "users", key = "#result.id")
public User create(UserForm form) { ... }

// メソッドが例外を投げても消したいなら beforeInvocation = true
@CacheEvict(value = "users", key = "#id", beforeInvocation = true)
public void delete(Long id) { ... }

ここで注意したいのが、参照側のkeyに#root.methodNameを入れている場合です。@CachePut側ではmethodNameがupdateになるのでキーが一致しません。更新も削除も必要なキャッシュは、キャッシュ名を分けるか 'summary:' + #id のような固定プレフィックスにした方が式を揃えやすいです。#root.methodNameは参照専用のキャッシュ向きと覚えておきましょう。

一覧系のキャッシュはキーを特定しづらいので、allEntries = trueでまとめて消すのが現実的です。また@CacheEvictで#resultを使えるのはbeforeInvocation = falseのときだけです。実行前に消すなら戻り値はまだ無いので当然ですね。

Redisをプロバイダにしている場合もキー設計の考え方は同じですが、キーの文字列化方法が絡むのでRedisとSpring Bootの連携記事も合わせて確認してください。

@Cachingで複数操作をまとめる

同じアノテーションを1メソッドに2つ並べることはできません。ID参照のキャッシュを更新しつつ一覧キャッシュを捨てる、といった場面では@Cachingを使います。

@Caching(
    put = @CachePut(value = "users", key = "#user.id"),
    evict = @CacheEvict(value = "userList", allEntries = true)
)
public User update(User user) { ... }

@Caching(evict = {
    @CacheEvict(value = "users", key = "#id"),
    @CacheEvict(value = "userSummary", key = "#id"),
    @CacheEvict(value = "userList", allEntries = true)
})
public void delete(Long id) { ... }

中の各操作は独立してconditionやunlessを持てます。同じ組み合わせを何度も書くなら、@Cachingをメタアノテーションにした合成アノテーション(@UserCacheEvictなど)を作ると、メソッド側がすっきりします。

Caffeineのstatsでキーが意図通りか検証する

設計したキーが本当に効いているかは、ヒット数で確認するのが一番確実です。CacheManagerからCaffeineCacheを取り出し、stats()を読みます。

@SpringBootTest
class UserCacheKeyTest {

    @Autowired UserService userService;
    @Autowired CacheManager cacheManager;

    private CaffeineCache users;
    private CacheStats before;

    @BeforeEach
    void setUp() {
        users = (CaffeineCache) cacheManager.getCache("users");
        users.clear();  // テスト間でエントリを共有しない
        before = users.getNativeCache().stats();  // 統計は累計なので差分を取る
    }

    private CacheStats delta() {
        return users.getNativeCache().stats().minus(before);
    }

    @Test
    void 同じ引数の2回目はヒットする() {
        userService.findById(1L);
        userService.findById(1L);
        assertThat(delta().missCount()).isEqualTo(1);
        assertThat(delta().hitCount()).isEqualTo(1);
    }

    // findSummaryById は key = "#root.methodName + ':' + #id" を付けた版が前提
    // (冒頭の衝突サンプルのままだと ClassCastException になる)
    @Test
    void 別メソッドとはキーが衝突しない() {
        userService.findById(1L);
        userService.findSummaryById(1L);
        assertThat(delta().hitCount()).isZero();
        // users.get() 自体も stats に乗るので、hitCount の assert より後に置く
        assertThat(users.get("findSummaryById:1")).isNotNull();
    }

    @Test
    void nullはキャッシュされない() {
        userService.findById(999L);  // 存在しない ID
        assertThat(users.get(999L)).isNull();
    }
}

Caffeineの統計はclear()してもリセットされないので、@BeforeEachで取った値との差分を見ています。2つ目のテストではキーの文字列まで直接覗いているので、前述の連結キーのタイポもここで捕まえられます。本番でのヒット率監視はCaffeineチューニング記事のMicrometer連携を参考にしてください。

まとめ

キー設計で押さえておきたいのはこのあたりです。

  • 戻り値型が違うメソッドでキャッシュ名を共有しない。参照専用なら#root.methodName、更新もあるならキャッシュ名を分ける
  • 引数オブジェクトをキーにするならrecordにしてequals/hashCodeを保証する
  • nullや空結果はunlessの#resultで弾く。conditionに#resultは書かない
  • 参照・更新・削除でkeyの式を揃え、複数操作は@Cachingにまとめる
  • recordStatsとテストでヒット数とキャッシュの中身を確認する

@Cacheableは手軽な分、キーが「なんとなく」決まっていることに気づきにくい機能です。一度テストで中身を覗いておくと、本番で不思議な挙動に悩まされることがぐっと減りますよ。