@Cacheableを付けて動かしてみたら、あっさりレスポンスが速くなった。そこで満足して、Caffeineの設定は全部デフォルトのまま本番に出していませんか。実はCaffeineはキャッシュごとに有効期限やサイズ上限を細かく設定できる仕組みを持っていて、それを使わないとメモリ増大やヒット率低下に後から悩まされることになります。今回は @Cacheable の使い方そのものではなく、Caffeine固有の設定・チューニング・監視だけに絞って解説します。

デフォルト設定のままだと何が起きるか

Caffeine.newBuilder().build() だけでCacheManagerを作ると、実は上限もTTLも一切設定されていない状態になります。エントリ数は理論上無制限に増え続け、更新頻度が違うデータに同じ寿命を適用してしまうため、頻繁に変わるデータは古いまま残り、逆にあまり変わらないマスタデータまですぐ失効してヒット率が下がります。本番でヒープが右肩上がりに増えていく場合、まずCaffeineの上限設定を疑ってみてください。

キャッシュごとに設定を分ける

全キャッシュ共通の基本設定は setCaffeine() でデフォルトにしておき、個別のキャッシュだけ registerCustomCache で上書きする構成にすると、設定漏れを防ぎつつキャッシュごとの特性差にも対応できます。

@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();

        // 共通デフォルト: 更新頻度が低いデータ向けの基本設定
        manager.setCaffeine(Caffeine.newBuilder()
                .expireAfterWrite(Duration.ofMinutes(30))
                .maximumSize(5_000)
                .recordStats());

        // アクセス頻度でTTLを延ばしたいキャッシュだけ個別に上書き
        manager.registerCustomCache("userSessionCache",
                Caffeine.newBuilder()
                        .expireAfterAccess(Duration.ofMinutes(10))
                        .maximumSize(20_000)
                        .recordStats()
                        .build());

        return manager;
    }
}

デフォルトを一箇所にまとめておくと、新しいキャッシュ名を追加したときに設定漏れでデフォルト値(無制限)のまま本番投入してしまう事故を防げます。

expireAfterWriteとexpireAfterAccessの使い分け

expireAfterWrite は書き込みからの経過時間で失効します。マスタデータのように更新頻度が低いものに向いています。一方 expireAfterAccess はアクセスがない期間で失効するので、頻繁に触られている間は生かし続けたいセッション情報向けです。ここを逆にすると、アクセスの多い商品情報が短命化して裏でDBを叩き続けたり、逆に放置されたセッションキャッシュがいつまでも残ったりします。

なお expireAfterAccess は常にアクセスされ続けると事実上失効しません。上の例のように maximumSize を必ず併用して、エントリ数の上限を別途担保しておきましょう。

maximumSizeとWeigherによるサイズ管理

エントリ数が揃っているなら maximumSize(long) でシンプルに上限を切れます。内部ではアクセス頻度に基づいた退避アルゴリズム(Window TinyLFU)が働くので、単純なLRUよりヒット率が出やすいのも特徴です。

ただしエントリごとのサイズが大きくバラつく場合は、件数ではなく容量で上限を切りたくなります。そんなときは maximumWeight と Weigher を組み合わせます。

Caffeine.newBuilder()
        .maximumWeight(10_000_000) // 単位はWeigherが返す値に依存
        .weigher((String key, String value) -> value.length())
        .recordStats()
        .build();

maximumSize と maximumWeight は同時に指定できないので、どちらか一方を選ぶ必要があります。

refreshAfterWriteで非同期リフレッシュ

expireAfterWrite は失効した瞬間に読み取りがブロックされ、その場でロードが走ります。アクセス集中時にこれが重いなら、refreshAfterWrite で古い値を返しつつ裏側で非同期に再ロードさせる方法があります。

CacheLoader<String, Product> loader = key ->
        productRepository.findById(key)
                .orElseThrow(() -> new EntityNotFoundException(key));

LoadingCache<String, Product> cache = Caffeine.newBuilder()
        .refreshAfterWrite(Duration.ofMinutes(5))
        .maximumSize(5_000)
        .recordStats()
        .build(loader);

findById が返すのは Optional<Product> なので、CacheLoader<String, Product> のシグネチャに合わせて orElseThrow でアンラップしています。デフォルトでは共有のForkJoinPool.commonPool()でリフレッシュが実行されるので、ロード処理が重い場合は Caffeine.executor() で専用のExecutorを指定しておいた方が安全です。

expireAfterWriteとrefreshAfterWriteの併用

両方を設定した場合、expireAfterWrite の期限を過ぎると値は完全に失効し、refreshAfterWrite によるstale値の提供は行われません。組み合わせるなら refreshAfterWrite の間隔を expireAfterWrite より短く設定するのが基本パターンです。逆にしてしまうと、リフレッシュが走る前に完全失効してしまい、結局同期ロードが発生してrefreshAfterWriteを設定した意味がなくなります。

recordStats()でキャッシュ間を比較する

recordStats() を有効化すると cache.stats() から CacheStats が取れます。1つのキャッシュだけ見ていても異常に気づきにくいので、複数キャッシュを並べて比較するのがコツです。

cacheManager.getCacheNames().forEach(name -> {
    CaffeineCache springCache = (CaffeineCache) cacheManager.getCache(name);
    CacheStats stats = springCache.getNativeCache().stats();
    log.info("cache={} hitRate={} evictionCount={} avgLoadMillis={}",
            name, stats.hitRate(), stats.evictionCount(),
            stats.averageLoadPenalty() / 1_000_000);
});

同じアプリ内でも、マスタデータ系のキャッシュはhitRateが90%を超えるのが普通なのに、セッション系は70%程度が上限ということもよくあります。単一の閾値で全キャッシュを判断せず、キャッシュの性質ごとに正常範囲を決めておくと異常検知の精度が上がります。averageLoadPenalty が突出しているキャッシュがあれば、そのロード処理(DBクエリや外部API呼び出し)自体が遅い可能性を疑いましょう。

Micrometerでキャッシュごとにメトリクスを登録する

ログだけでは本番の傾向を継続的に追いにくいので、CaffeineCacheMetrics でMeterRegistryに登録しておきます。

@Configuration
public class CacheMetricsConfig {

    @Bean
    public MeterBinder cacheMetrics(CacheManager cacheManager) {
        return registry -> {
            for (String name : cacheManager.getCacheNames()) {
                CaffeineCache springCache = (CaffeineCache) cacheManager.getCache(name);
                CaffeineCacheMetrics.monitor(registry, springCache.getNativeCache(), name);
            }
        };
    }
}

cache.gets や cache.evictions がキャッシュ名タグ付きで出力されるので、Grafana側で cache.evictions を cache.size と重ねて見ると、退避が発生しているキャッシュだけ上限不足だと一目でわかります。Prometheus/Grafanaの構築手順はSpring BootのMicrometer + Prometheus + Grafana監視構築を参考にしてください。

本番投入前のメモリサイジング

maximumSize や maximumWeight を決める前に、ざっくりで良いのでメモリ使用量を見積もっておきましょう。考え方はシンプルで、想定エントリ数 × 1エントリあたりの平均サイズで概算します。キーと値のオブジェクトサイズはヒープダンプやJOL(Java Object Layout)で実測するのが手っ取り早いです。複数のキャッシュを同一アプリで動かす場合は、キャッシュごとの見積もりを合算してヒープ全体の予算内に収まっているかを確認してください。エントリサイズが不均一なキャッシュで見積もりが外れやすい場合は、件数ベースのmaximumSizeではなく前述のWeigherで容量ベースの上限に切り替えることも検討しましょう。

ヒット率低下・メモリ増大の切り分け方

本番で異常に気づいたら、症状ごとに疑う箇所を分けて確認すると早く原因にたどり着けます。

  • hitRateが低い: expireAfterWrite/expireAfterAccess の選び方がアクセスパターンとズレていないか確認します。更新頻度の低いデータに短いTTLを付けていないか、逆に頻繁に更新されるデータに長いTTLを付けていないかが典型的な原因です。
  • evictionCountが多い: maximumSize(またはmaximumWeight)が実際のワーキングセットに対して小さすぎる可能性が高いです。前述のメモリサイジングをやり直し、上限を引き上げられないか検討します。
  • メモリが増え続ける: maximumSize/maximumWeightを設定し忘れたキャッシュがないか、あるいはWeigherを使っているのにエントリサイズを正しく計算できていないかを疑います。キャッシュ名ごとに設定漏れがないか、CacheConfigのBean定義を見直しましょう。

なお @Transactional や @Async と同様、@Cacheable もAOPプロキシ経由で動くため、同一クラス内からの自己呼び出しではキャッシュが効かないケースがあります。ヒット率が0%に近い場合は設定ミスの前にこちらも疑ってみてください。詳しくは@Transactional/@Cacheable/@Asyncが効かない原因で解説しています。

まとめ

Caffeineはデフォルトのままでも動きますが、キャッシュごとにexpireAfterWrite/Accessとmaximumsize(またはWeigher)を設定して初めて本番で安心して使える状態になります。recordStats()とMicrometerでキャッシュ間を比較しながら数値を継続的に見る習慣をつけておけば、ヒット率低下やメモリ増大の予兆にも早めに気づけるはずです。基本的な@Cacheableの使い方はSpring Bootのキャッシュ機能を使う方法も合わせて確認してみてください。