スポンサーリンク

Caffeineでアプリ内キャッシュ入門:DBアクセスをムダなく速くする

スポンサーリンク
この記事は約19分で読めます。
スポンサーリンク

同じDBアクセスや同じ計算を、気づかないうちに何度も何度も回してませんか?そのせいで「ページ表示が遅い」「APIが詰まる」「サーバーのCPUがずっと高い」みたいな症状が出がちです。しかも厄介なのが、SQLやロジック自体は正しくても、回数が多いだけで遅くなるところなんですよね。

そこで登場するのが、アプリ内ローカルキャッシュの Caffeine です。ざっくり言うと「よく使う結果をアプリのメモリに置いておいて、次から秒速で取り出す」仕組みです。まずは“アプリの中だけ”で完結するので、Redisみたいな別サーバーをいきなり立てなくても、手軽にスピードアップを狙えます(ただし再起動で消える、複数台だとズレる、みたいな注意もあります)。

筆者
筆者

この記事では、Caffeineを 最短で1回動かすところから始めて、Cache/Loading/Asyncの使い分け、設定の3点セット(容量・期限・更新)、キー設計と同時アクセスのコツ、そしてSpring Bootの @Cacheable 設定まで、一気に迷子にならない形で整理します。今日から「ムダなDBアクセス」を減らしていきましょう。

  1. ローカルキャッシュって何?まずは図でつかむ
    1. 「冷蔵庫(メモリ)に置く」イメージで理解する
    2. 1台なら速い/複数台だとバラバラ問題
    3. どんな処理が“キャッシュ向き”?
  2. 最短で動く:素のJavaでCaffeineを1回動かす
    1. いちばん小さい例(Cache+get(key, loader))
    2. 「同じキーなら1回だけ計算される」安心ポイント
    3. まず付ける設定はmaximumSizeだけでOK
  3. 3つの型をやさしく整理(Cache/LoadingCache/AsyncLoadingCache)
    1. Cache:手動で入れる・取る(超シンプル)
    2. LoadingCache:足りない時に自動で作る(楽で安全)
    3. AsyncLoadingCache:待ち時間を減らす(重い処理向け)
    4. 迷った時の選び方(判断フローチャート)
  4. 設定は「3点セット」で覚える(容量・期限・更新)
    1. 容量:maximumSizeの決め方(怖いメモリを守る)
    2. 期限:expireAfterWrite vs expireAfterAccess(具体例で)
    3. 更新:refreshAfterWrite(“アクセスされたら更新が走る”)
    4. expireとrefreshが混ざる問題を1枚で解決
  5. キャッシュ設計で9割決まる:キー・値・例外のコツ
    1. キー設計:userIdだけ?条件も入れる?(失敗例つき)
    2. 値が大きい問題:軽くする/持ち方を変える
    3. null・例外をどうする?(方針を決めるテンプレ)
  6. 運用の基本操作セット(消す・更新する・効いてるか見る)
    1. invalidate / invalidateAll(更新・削除の基本)
    2. 手動更新(refresh/put)の使いどころ
    3. 統計(hit率)で“効いてるか”確認する手順
  7. Spring Boot最短手順:@Cacheableで迷わない設定場所
    1. 依存関係(starter-cache+caffeine)
    2. application.ymlのspec例(maximumSize/expire/refresh)
    3. @Cacheable / @CacheEvict / @CachePut を1画面で整理
    4. 「DB更新とキャッシュ整合」よくある事故の避け方
  8. Guava Cacheからの乗り換え&ローカル卒業の判断表
    1. 置き換え対応表(だいたい同じ、ここが違う)
    2. いつローカルじゃ足りない?判断表
    3. 複数台ならRedis等(ハイブリッド案の考え方)
  9. まとめ:今日やるチェックリスト+おすすめテンプレ(CTA)
    1. まず1つだけテンプレ(最小構成)
  10. よくある質問
スポンサーリンク

ローカルキャッシュって何?まずは図でつかむ

ローカルキャッシュは、めちゃくちゃ雑に言うと「よく使う答えを、アプリのメモリに一時保存しておく仕組み」です。毎回DB(買い物)に行くんじゃなくて、いったん冷蔵庫(メモリ)に入れておいて、次からはサッと取り出す感じです。速いのは当たり前ですよね。

「冷蔵庫(メモリ)に置く」イメージで理解する

  • DBや外部API:買い物(時間がかかる)
  • ローカルキャッシュ:冷蔵庫(すぐ取り出せる)

なので「同じuserIdで同じプロフィールを何回も取る」みたいな場面は、キャッシュが超効きます。

図:1台構成(基本の勝ちパターン)

ポイントは、2回目以降の“取りに行く時間”が消えることです。

1台なら速い/複数台だとバラバラ問題

ただしローカルキャッシュは「そのアプリのメモリの中だけ」です。つまり、サーバーが複数台あると話が変わります。

図:複数台構成(値がズレるあるある)

これが「複数台だとバラバラ問題」です。さらに言うと、サーバー再起動したらキャッシュは全部消えます。ローカルなので当然ですね。

なので結論としてはこうです。

  • 1台構成:ローカルキャッシュはかなり素直に効きます
  • 複数台構成:効くけど「ズレ」を前提に設計が必要です(後半で判断表も出します)

どんな処理が“キャッシュ向き”?

キャッシュが向いてるのは、だいたいこのタイプです。

  • 同じ入力 → 同じ結果になりやすい(例:userId→ユーザー情報)
  • 読み取りが多く、更新が少ない(例:マスタデータ、設定、権限)
  • DBや外部APIがボトルネックで、待ち時間がもったいない
  • 多少古くても許せる(または短い期限で割り切れる)

逆に、向いてないのはこんな感じです。

  • 更新が激しくて、常に最新が必要(在庫や残高などは要注意)
  • キー設計が曖昧で、同じ結果にならない(条件が漏れてバグりやすい)
  • 値がデカすぎてメモリを圧迫する(一覧丸ごとドン、みたいなやつ)

ここまでで「ローカルキャッシュ=冷蔵庫」「1台は強いけど複数台はズレる」「向き不向きがある」まで掴めたらOKです。

筆者
筆者

次の章で、難しい話は後回しにして、Caffeineを1回動かして“速くなる感”を先に取りにいきます

最短で動く:素のJavaでCaffeineを1回動かす

難しい理屈の前に、まず“動く成功体験”いきましょう。やることは超シンプルで、Cacheを作る → get(key, loader)で取るだけです。

いちばん小さい例(Cache+get(key, loader))

まず「やってはいけない例」。毎回DB(や重い計算)に行くやつです。

String loadFromDb(String userId) {
  // 毎回重い処理(DBアクセス想定)
  System.out.println("[DB] load " + userId);
  return "profile-" + userId;
}

// 2回呼ぶと2回DBへ…
loadFromDb("u1");
loadFromDb("u1");

次にCaffeine版。同じキーなら2回呼んでも1回しか作られないのがポイントです。

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

public class Main {
  public static void main(String[] args) {
    Cache<String, String> cache = Caffeine.newBuilder()
        .maximumSize(10_000) // まずはこれだけでOK
        .build();

    String v1 = cache.get("u1", key -> {
      System.out.println("[LOAD] " + key);
      // ここがDBアクセス/重い計算の置き場所
      return "profile-" + key;
    });

    String v2 = cache.get("u1", key -> {
      System.out.println("[LOAD] " + key);
      return "profile-" + key;
    });

    System.out.println(v1);
    System.out.println(v2);
  }
}

期待するログはこんな感じです(LOADが1回だけ):

[LOAD] u1
profile-u1
profile-u1

「同じキーなら1回だけ計算される」安心ポイント

この cache.get(key, loader) が偉いところは、同じキーに対して“作る処理”をまとめてくれる点です。つまり「同じuserIdが同時に来て、全員がDBを叩いて地獄」みたいな事故を減らせます(同時アクセスの話は後の章でもう少し触れますね)。

まず付ける設定はmaximumSizeだけでOK

最初に悩むと止まるので、設定はこれで十分です。

  • maximumSize:キャッシュの上限個数(メモリ暴走を防ぐ命綱)

期限(expire)や更新(refresh)は、動かして効果が見えてからでOKです。まずは「DBに行く回数が減った」ことをログで確認しちゃいましょう。

3つの型をやさしく整理(Cache/LoadingCache/AsyncLoadingCache)

Caffeineで迷う一番の原因は「型が3つある」ことです。覚え方は固定でOKです。

  • 自分で入れる
  • 足りない時に自動で作る
  • 非同期で作る

これだけです。

Cache:手動で入れる・取る(超シンプル)

いちばん素朴なやつです。getIfPresentで取り、なければ自分でDBへ行って、putで入れます。
「どのタイミングで入れるか」を自分で決めたい時に向きます。反面、書き方をミスると「結局毎回DB」になりやすいです。

LoadingCache:足りない時に自動で作る(楽で安全)

「なければ作る」をキャッシュ側に任せます。つまり get(key) するだけで、裏でloaderが呼ばれます。
さっきの章の cache.get(key, loader) を“いつも使う版”みたいなイメージです。迷ったら基本これが安定です。

AsyncLoadingCache:待ち時間を減らす(重い処理向け)

「作る処理」を別スレッドで走らせて、呼び出し側の待ちを減らすための型です。結果は CompletableFuture みたいな“あとで取れる箱”で返ってきます。

ただしここ、勘違いしがちポイントです。
Async=速い魔法ではないです。DBが遅いならDBは遅いままです。できるのは主にこれです。

  • 呼び出し側のスレッドを塞がない(待ち行列を減らす)
  • ついでに並列化しやすい(ただしスレッド数管理が必要)

「重い処理 × 同時アクセスが多い」なら強いですが、スレッドプール設計を雑にすると逆に詰まります。

迷った時の選び方(判断フローチャート)

※非同期の待ち時間や詰まりを見たい時(無料の組み合わせ):

  • MicrometerPrometheus/Grafana で「待ち時間」「キューっぽい増え方」を見える化すると安心です

設定は「3点セット」で覚える(容量・期限・更新)

Caffeineって設定が多く見えてビビりがちですが、覚える順番を固定すると一気に楽になります。合言葉はこれです。

  • 容量=maximumSize(メモリを守るフタ)
  • 期限=expire(古くなったら捨てる)
  • 更新=refresh(捨てる前に作り直す)

容量:maximumSizeの決め方(怖いメモリを守る)

まずは小さく始めるが正解です。最大の事故は「キャッシュが増えすぎてメモリ不足」です。
目安は雑でOKなので、こう考えると止まりません。

  • 「だいたい何件キャッシュしたい?」を決める(例:直近ユーザー1万件)
  • まず 1万〜10万くらいのキリがいい数で開始
  • hit率(当たり具合)ヒープ使用量を見て増減

※メモリの安全確認(無料):heap dump / Eclipse MAT
※もっと強力に見る(有料):YourKit / JProfiler(数万円〜) あたりの価格帯を検討枠に置く感じです。

期限:expireAfterWrite vs expireAfterAccess(具体例で)

  • expireAfterWrite:保存してから○分で捨てる(“作ってからの寿命”)
    • 例:プロフィールは「10分たったら取り直す」でOK、みたいな時に相性良いです。
  • expireAfterAccess:触られなくなって○分で捨てる(“使われなかったら処分”)
    • 例:一時的にしか見られないデータを、放置されたら片付けたい時に便利です。

更新:refreshAfterWrite(“アクセスされたら更新が走る”)

refreshは「期限が来たら即捨てる」じゃなくて、期限が来た状態でアクセスされた瞬間に“作り直し”が始まるイメージです。
多くのケースで、更新中でも古い値を返しつつ裏で作り直すので、待ち時間を増やしにくいのが良いところです(※動きは型や実装次第なので、後で軽く検証するのが安全です)。

expireとrefreshが混ざる問題を1枚で解決

やりたいこと使うのは?起きること
古くなったら“捨てる”expire消える → 次のアクセスで作り直し(待つことがある)
古くなる前に“作り直す”refreshアクセスをきっかけに更新開始(古い値でつなげることが多い)

混乱しないコツはこれです。
refreshは「更新のきっかけ」/expireは「強制退場」

両方使うなら、だいたい refresh<expire(例:refresh 5分、expire 10分)にして、「更新するチャンス」を先に作ると運用が安定しやすいです。

キャッシュ設計で9割決まる:キー・値・例外のコツ

キャッシュって「入れる」より先に、設計で勝負がほぼ決まります

ここを雑にすると、だいたいこうなります。

「全然当たらない(効かない)」
「メモリが苦しい」
「条件漏れで別ユーザーの結果が混ざってバグる」

なので、キー・値・例外の3点をテンプレ化しちゃいましょう。

キー設計:userIdだけ?条件も入れる?(失敗例つき)

失敗あるあるはこれです。

  • 失敗例:キーが userId だけ
    → 実は「言語」「権限」「フィルタ条件」で表示が変わるのに、同じキー扱いになって結果が混ざります。

おすすめの型は3つです。

  • 単体キーuserId(条件が増えないなら最強)
  • 複合キーuserId + 言語 + 権限 + フィルタ(表示条件があるなら入れる)
  • 検索キー:条件をまとめた「検索条件オブジェクト」をキーにする

→ この場合は「同じ条件なら同じキー」になるように、equals/hashCode(=同じか判定するルール)をちゃんと実装するのがコツです。

値が大きい問題:軽くする/持ち方を変える

値がデカいと、キャッシュは一気にメモリ食いになります。対策はこのへんが現実的です。

  • DTO化して必要な項目だけ持つ(全部盛りをやめる)
  • 一覧はページごとに持つ(「1ページ目」「2ページ目」で分ける)
  • どうしても大きいなら「ID一覧だけキャッシュ」→詳細は別、みたいに分割もアリです

null・例外をどうする?(方針を決めるテンプレ)

ここは“先に宣言”が大事です。迷うとバグります。

ケース方針テンプレ(おすすめ)
データなし(null相当)「短い期限でキャッシュする」or「キャッシュしない」を決め打ち
例外(DB落ち/タイムアウト等)基本:キャッシュしない(失敗を保存すると復旧後も死にます)

※キー実装ミスを減らしたい時:

  • SpotBugs / Checkstyle(「equals/hashCodeのうっかり」みたいなミスを早めに潰せます)

運用の基本操作セット(消す・更新する・効いてるか見る)

キャッシュは「入れたら終わり」じゃなくて、更新が入った時にどう直すかと、本当に効いてるかが超大事です。ここを手順化しておくと、事故が激減します。

invalidate / invalidateAll(更新・削除の基本)

基本はこれだけ覚えればOKです。

  • 1件だけ消す:そのキーだけ無効化
  • まとめて消す:関連キーをまとめて無効化
  • 全部消す:緊急避難(雑に直す時の最終手段)
cache.invalidate(key);          // 1件
cache.invalidateAll(keys);      // 複数件
cache.invalidateAll();          // 全部

置き方のコツはシンプルで、「DB更新が起きる場所」から逆算してinvalidateを置くだけです。
例:ユーザー更新APIが成功したら userId のキャッシュを消す、みたいに「更新イベント→削除」をセットで考えると迷いません。

手動更新(refresh/put)の使いどころ

「消す」以外に「更新する」もあります。ただし使いどころは限定でOKです。

  • refresh:LoadingCache系で「作り直してね」をお願いする
  • put:値がもう手元にある時に、そのまま入れ直す
loadingCache.refresh(key);  // 裏で更新してね(※型による)
cache.put(key, newValue);   // 新しい値があるなら即入れ替え

使いどころの例:

  • 管理画面で設定を変えた直後に、すぐ最新を反映したい
  • バッチ処理で新しい値を作ったので、そのままキャッシュも更新したい
  • 起動直後に“よく使うキー”を温めたい(ウォームアップ)

統計(hit率)で“効いてるか”確認する手順

「速くなった気がする」だと危ないので、数字で見ましょう。Caffeineは統計が取れます。

Cache<K, V> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .recordStats()
    .build();

// どこかで
var stats = cache.stats();
System.out.println("hitRate=" + stats.hitRate());
System.out.println("evictionCount=" + stats.evictionCount());

おすすめ手順:

  • まずキャッシュなしの処理時間を測る(基準)
  • recordStats()hit率追い出し回数 を見る
  • hit率が低いなら「キーがズレてる/期限が短すぎ」疑い
    追い出しが多いなら「maximumSizeが小さすぎ」疑い
  • 1個ずつ直して、また測る(計測→調整→計測)

運用で見える化したい時:

  • 監視ダッシュボード:Grafana(無料)
  • アプリ全体の遅さ調査:APM(Datadog / New Relic等)(有料)

Spring Boot最短手順:@Cacheableで迷わない設定場所

Spring Bootだと迷いポイントは「設定どこ?」ですが、答えはだいたい二択です。①まずは application.yml の spec、足りなくなったら ②Beanで細かく、でOKです。

依存関係(starter-cache+caffeine)

implementation "org.springframework.boot:spring-boot-starter-cache"
implementation "com.github.ben-manes.caffeine:caffeine"

application.ymlのspec例(maximumSize/expire/refresh)

spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m

refreshAfterWriteCacheLoader(LoadingCache)前提で効くやつなので、@Cacheable だけで「勝手に裏更新」まではしません。必要になったらBeanでLoadingCacheを作る、くらいの理解で大丈夫です。

@Cacheable / @CacheEvict / @CachePut を1画面で整理

役割何する?いつ使う?
@Cacheable読む(なければ作る)読み取りが多い処理
@CachePut更新して入れる更新後に“新しい値”を即反映したい
@CacheEvict削除するDB更新したので古いキャッシュを消す

「DB更新とキャッシュ整合」よくある事故の避け方

事故はだいたいこれです:DBは更新したのにキャッシュが残って古いまま。対策はシンプルで、更新系メソッドに @CacheEvict(or @CachePut)をセットしてください。さらに安全にするなら「更新が成功してから消す(入れる)」を意識です。
おまけで、効いてるか見るなら Actuator+メトリクス出力が便利枠です(“本当に当たってる?”がすぐ分かります)。

Guava Cacheからの乗り換え&ローカル卒業の判断表

置き換え対応表(だいたい同じ、ここが違う)

GuavaCaffeineひとこと
CacheBuilder.newBuilder()Caffeine.newBuilder()入口が違うだけです
maximumSize / expireAfterWrite同名ほぼそのまま移せます
LoadingCacheLoadingCacheこれも同名です
(弱め)AsyncLoadingCache非同期を使いやすいのが強みです

いつローカルじゃ足りない?判断表

状況結論
1台構成ローカル推しです(速い・簡単)
複数台で“同じ値”を共有したいRedis等を検討です
常に最新が必須(ズレ厳禁)共有キャッシュ or そもそもキャッシュ慎重にです

複数台ならRedis等(ハイブリッド案の考え方)

現実解は ローカル(短い期限)+Redis(共有) が多いです。まずローカルで速くして、共有が必要な所だけRedisに寄せると、移行も事故も減らせます。
商品カテゴリとしては「分散キャッシュ=Redis(OSS/マネージド)」「監視=Grafana」あたりが定番枠です。

まとめ:今日やるチェックリスト+おすすめテンプレ(CTA)

最後に「今日やる」だけに絞ったチェックリストです。これだけやれば、だいたい最初の改善は出ます。

  • まず1つだけキャッシュする対象を決める(例:userId→プロフィール)
  • テンプレ設定で開始:maximumSizeexpireAfterWrite
  • キー設計を確認:条件(言語/権限/フィルタ)があるならキーに入れる
  • 更新が入る場所invalidate/@CacheEvict を置く(事故防止)
  • recordStats()hit率 を見る → 低いならキー/期限を見直す
  • 効いたら次に:LoadingCache/Async、refresh、複数台ならRedis検討

まず1つだけテンプレ(最小構成)

  • 容量:maximumSize=10000
  • 期限:expireAfterWrite=10m

Spring BootならこれをコピペでOKです。

spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m

もし「最小サンプルコード」や「チートシートPDF」みたいな配布物を作る想定なら、この記事のテンプレ(spec例・キー例・invalidate配置例)をそのまままとめるのが一番ラクです。

まずは1か所だけキャッシュして、DBアクセス回数が減る快感を取りにいきましょう。

よくある質問

Q
Caffeineって、結局なにが速くなるんですか?
A

同じ結果を何度も作らないので速くなります。DBアクセス・外部API呼び出し・重い計算を、2回目以降はメモリからサッと返せるのが効きます。

Q
ローカルキャッシュって、再起動したらどうなりますか?
A

全部消えます。アプリのメモリに置いてるだけなので、再起動・デプロイで初期化されます。「消えても困らない」「すぐ作り直せる」前提で使うのが安全です。

Q
maximumSize はいくつにすればいいですか?
A

最初は 小さめでOKです。目安は「よく出るキーが何件あるか」から決めて、recordStats()hit率 とメモリ使用量を見て調整がラクです。いきなり大きくするとメモリ不足で事故りやすいです。

Q
expireAfterWrite と expireAfterAccess、どっちを使えばいいですか?
A

迷ったら expireAfterWrite が無難です。

  • expireAfterWrite:作ってから○分で捨てる(わかりやすい)
  • expireAfterAccess:触られなくなって○分で捨てる(“使われないもの掃除”向き)
Q
refreshAfterWrite は expire と何が違うんですか?
A

expireは捨てる、refreshは作り直すです。

  • expire:期限が来たら「もう使わない」扱い → 次のアクセスで作り直し(待ちが出ることあり)
  • refresh:期限が来たら「更新してね」扱い → アクセスをきっかけに裏で更新(古い値でつなぐ運用がしやすい)
Q
同時アクセスが多いと、同じキーでDBが何回も叩かれませんか?
A

cache.get(key, loader) / LoadingCache なら、同じキーの作成をまとめやすいです(いわゆる「同時に押し寄せて全員DB」事故を減らせます)。ただし、設計や実装が雑だと起きるので、重要キーは負荷テストで確認が安心です。

Q
null(データなし)や例外はキャッシュしていいんですか?
A

方針を決め打ちがおすすめです。

  • 例外は基本キャッシュしない(復旧後も失敗を引きずるので危険)
  • nullはケース次第:
    • 「存在しない」が多くてDBが無駄なら 短い期限でキャッシュ
    • すぐ作られる可能性があるなら キャッシュしない もアリです
Q
Springの @Cacheable でキーってどうなりますか?
A

基本は「メソッド引数から自動でキー」が作られます。
ただし落とし穴があって、引数が複雑だったり、条件(言語/権限/フィルタ)をキーに入れてないと、違う結果が同じキー扱いになってバグります。必要なら key = "..."
で明示が安全です。

Q
DB更新したのに、古いキャッシュが返る事故を防ぐには?
A

更新処理のところで、必ずどれかをやります。

  • 変更したキーを @CacheEvict で消す(基本これ)
  • 更新後の値が手元にあるなら @CachePut で入れ直す
    「更新が成功したら消す(入れる)」の流れにしておくと安定します。
Q
複数台構成(スケールアウト)でもローカルキャッシュでいいですか?
A

“いい場合”と“ダメな場合”があります。

  • いい:多少ズレてもOK/短い期限で割り切れる/まず速くしたい
  • ダメ:全台で同じ値を共有したい/ズレが許されない
    ズレが問題なら Redis等の共有キャッシュ、現実解なら ローカル(短命)+Redis(共有) のハイブリッドが多いで
タイトルとURLをコピーしました