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