キャッシュメカニズム
キャッシュはパフォーマンス最適化の第一歩です。頻繁に読み取られあまり書き込まれないデータは, 一度キャッシュすれば千回のクエリを節約できます。
1. 学ぶ内容
@EnableCachingと@Cacheable/@CachePut/@CacheEvictアノテーション- キャッシュキー戦略と「condition」 / 「unless」条件フィルタリング
- Caffeineローカルキャッシュ設定:TTL / 最大容量 / 退去ポリシー
- キャッシュペネトレーションとキャッシュブレークスルー問題への対処戦略
- Aliceが1日数百万アクセスの人気商品リストにCaffeineキャッシュを追加
2. パフォーマンスエンジニアのリアルストーリー
(1) ペインポイント:データベースの過負荷
商品発売イベントで, CharlieがOrderFlowのフラッシュセール機能をデモンストレーションしたところ, 瞬時に50万リクエストが殺到し, すべてが人気商品のデータベースクエリに流れ込みました。MySQL接続プールが枯渇し, システム全体が10分間ダウンし, 直接10万ドルの損失が発生しました。Aliceの分析によると, 商品詳細クエリが全リクエストの80%を占めているにもかかわらず, このデータは1時間ほとんど変化していませんでした。
(2) Spring Cacheによる解決策
1つのアノテーションでキャッシュを有効にできます:
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepository.findById(id).orElseThrow();
}
最初のクエリはデータベースにアクセスし, 以降のクエリはキャッシュから直接読み取ります。
(3) 成果
Aliceが人気商品にCaffeineキャッシュを追加した後, 80%のクエリがキャッシュにヒットし, データベース負荷は5分の1に減少し, P99レイテンシは500msから5msに低下し, 次回のフラッシュセールを成功裏にサポートしました。
3. Spring Cacheアノテーション体系
(1) コアアノテーション
| 説明 | 機能 | 典型的なシナリオ |
|---|---|---|
@Cacheable |
クエリ時にまずキャッシュを確認。一致すれば即座に結果を返す | クエリ操作 |
@CachePut |
メソッドを実行しキャッシュを更新 | 更新操作 |
@CacheEvict |
キャッシュをクリア | 削除 |
@Caching |
複数キャッシュ操作の組み合わせ | 複雑なシナリオ |
@EnableCaching |
キャッシュサポートの有効化 | 設定クラス |
graph TD
A["Client Request"] --> B{"@Cacheable<br/>Cache Hit?"}
B -->|Yes| C["キャッシュデータを返す"]
B -->|No| D["メソッドを実行<br/>データベースをクエリ"]
D --> E["結果をキャッシュに保存"]
E --> F["結果を返す"]
(1) ▶ サンプル:@Cacheableクエリキャッシュ
@Service
@EnableCaching
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
log.info("Cache miss, querying database for product {}", id);
return productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
}
@Cacheable(value = "product-list", key = "#keyword + '-' + #page + '-' + #size")
public Page<Product> searchProducts(String keyword, int page, int size) {
return productRepository.findByNameContaining(keyword,
PageRequest.of(page, size));
}
}
出力:
// 実行成功
(2) ▶ サンプル:@CachePutでキャッシュを更新
@CachePut(value = "products", key = "#result.id")
public Product updateProduct(Long id, UpdateProductRequest request) {
Product product = getProduct(id);
product.setName(request.name());
product.setPrice(request.price());
product.setStock(request.stock());
return productRepository.save(product);
}
出力:
// 実行成功
(3) ▶ サンプル:@CacheEvictでキャッシュをクリア
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
@CacheEvict(value = "product-list", allEntries = true)
public void clearProductListCache() {
log.info("Cleared all product list cache entries");
}
出力:
// 実行成功
| アノテーション | メソッド実行 | キャッシュ更新 | 適用操作 |
|---|---|---|---|
@Cacheable |
キャッシュミス時のみ実行 | キャッシュミス時に更新 | クエリ |
@CachePut |
常に実行 | 常に更新 | 更新 |
@CacheEvict |
常に実行 | 指定キャッシュをクリア | 削除 |
4. キャッシュキー戦略
(1) キー生成ルール
| キー戦略 | SpEL式 | 生成例 |
|---|---|---|
| 単一パラメータ | key = "#id" |
products::123 |
| 複数パラメータの組み合わせ | key = "#keyword + '-' + #page" |
product-list::laptop-0 |
| オブジェクトプロパティ | key = "#request.category" |
products::electronics |
| デフォルト | 指定なし | 全パラメータ組み合わせのhashCode |
(1) ▶ サンプル:カスタムKeyGenerator
@Configuration
@EnableCaching
public class CacheConfig {
@Bean("customKeyGenerator")
public KeyGenerator customKeyGenerator() {
return (target, method, params) -> {
StringBuilder sb = new StringBuilder();
sb.append(target.getClass().getSimpleName()).append(":");
sb.append(method.getName()).append(":");
for (Object param : params) {
sb.append(param != null ? param.toString() : "null").append(".");
}
return sb.toString();
};
}
}
// 使用方法
@Cacheable(value = "products", keyGenerator = "customKeyGenerator")
public Product getProduct(Long id) { /* ... */ }
出力:
// 実行成功
(2) 条件フィルタリング
(2) ▶ サンプル:conditionとunless
// 価格 > 100の商品のみキャッシュ
@Cacheable(value = "expensive-products", key = "#id",
condition = "#id != null")
public Product getExpensiveProduct(Long id) { /* ... */ }
// 結果の在庫 == 0の場合はキャッシュしない
@Cacheable(value = "products", key = "#id",
unless = "#result.stock == 0")
public Product getProduct(Long id) { /* ... */ }
出力:
// 実行成功
| パラメータ | 実行タイミング | 意味 |
|---|---|---|
condition |
メソッド実行前 | 条件を満たす場合のみキャッシュ |
unless |
メソッド実行後 | 条件を満たす場合, キャッシュしない |
5. Caffeineローカルキャッシュ設定
(1) Caffeineの特徴
| 特徴 | 説明 |
|---|---|
| 高パフォーマンス | Guava Cacheより30%高いスループット |
| 非同期リフレッシュ | 期限切れキャッシュの非同期バックグラウンドリフレッシュをサポート |
| 統計情報 | ヒット率, ロード時間など |
| Window TinyLFU | 最適な退去アルゴリズム |
(1) ▶ サンプル:Caffeineキャッシュ設定
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
出力:
// 実行成功
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(caffeineCacheBuilder());
return manager;
}
@Bean
public Caffeine<Object, Object> caffeineCacheBuilder() {
return Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(30)) // TTL:書き込み後30分
.expireAfterAccess(Duration.ofHours(1)) // 1時間アイドル後に退去
.maximumSize(10_000) // 最大10,000エントリ
.recordStats(); // 統計を有効化
}
// キャッシュごとの設定
@Bean
public CacheManager multiCacheManager() {
Map<String, CaffeineCache> caches = Map.of(
"products", buildCache(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofHours(1))
.maximumSize(5_000)),
"product-list", buildCache(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.maximumSize(1_000))
);
return new SimpleCacheManager() {{
setCaches(caches.values());
}};
}
private CaffeineCache buildCache(Caffeine<Object, Object> builder) {
return new CaffeineCache("cache", builder.build());
}
}
| 設定オプション | 説明 | 推奨値 |
|---|---|---|
expireAfterWrite |
書き込み後の有効期限 | 30分 - 1時間 |
expireAfterAccess |
最終アクセス後の有効期限 | 1時間 - 24時間 |
maximumSize |
キャッシュエントリの最大数 | メモリに応じて調整 |
recordStats |
統計を有効化 | 開発環境で有効化 |
6. キャッシュペネトレーションとキャッシュブレークスルー
(1) 一般的なキャッシュ問題
| 問題 | 原因 | 結果 | 解決策 |
|---|---|---|---|
| キャッシュペネトレーション | 存在しないデータのクエリ | 毎回データベースにクエリ | null値のキャッシュ / ブルームフィルタ |
| キャッシュブレークスルー | ホットキーの期限切れと同時にリクエスト急増 | データベース負荷の急増 | 排他ロック / 期限なし + 非同期リフレッシュ |
| キャッシュアバランシェ | 大量のキーが同時に期限切れ | データベース負荷の急増 | 有効期限にランダムオフセットを追加 |
graph TD
A["キャッシュペネトレーション<br/>存在しないデータをクエリ"] --> B["解決策:null値をキャッシュ"]
C["キャッシュブレークスルー<br/>ホットキー期限切れ"] --> D["解決策:排他ロック / 非同期リフレッシュ"]
E["キャッシュアバランシェ<br/>多数のキーが同時期限切れ"] --> F["解決策:ランダムTTLオフセット"]
(1) ▶ サンプル:null値のキャッシュでペネトレーションを防止
@Cacheable(value = "products", key = "#id", unless = "#result == null && #result != ''")
public Product getProduct(Long id) {
return productRepository.findById(id).orElse(null);
}
// 代替案:短いTTLでnullをキャッシュ
@Bean
public CacheManager cacheManager() {
return new CaffeineCacheManager() {{
setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5))
.maximumSize(10_000));
}};
}
出力:
// 実行成功
7. 総合例:OrderFlow商品キャッシュの完全実装
// CacheConfig.java
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
Map<String, Cache> caches = new HashMap<>();
caches.put("products", Caffeine.newBuilder()
.expireAfterWrite(Duration.ofHours(1))
.maximumSize(5_000)
.recordStats()
.build());
caches.put("product-list", Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.maximumSize(500)
.build());
caches.put("product-stats", Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(5))
.maximumSize(100)
.build());
SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(caches.values().stream()
.map(c -> new CaffeineCache(c.getClass().getName(), c))
.toList());
return manager;
}
}
// ProductService.java with caching
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product", id));
}
@Cacheable(value = "product-list", key = "#keyword + '-' + #pageable.pageNumber + '-' + #pageable.pageSize")
public Page<Product> searchProducts(String keyword, Pageable pageable) {
return productRepository.findByNameContaining(keyword, pageable);
}
@CachePut(value = "products", key = "#result.id")
@CacheEvict(value = "product-list", allEntries = true)
public Product updateProduct(Long id, String name, BigDecimal price, Integer stock) {
Product product = getProduct(id);
product.setName(name);
product.setPrice(price);
product.setStock(stock);
return productRepository.save(product);
}
@CacheEvict(value = {"products", "product-list"}, allEntries = true)
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
}
❓ よくある質問
maximumSizeとmaximumWeightの違いは何ですか?maximumSizeはエントリ数を制限し, maximumWeightは総重量を制限します (weigherの指定が必要)。キャッシュオブジェクトのサイズが大きく異なる場合, maximumWeightのほうが精度が高くなります。recordStats()を有効化した後, Cache.stats()でヒット率を取得できます。Micrometerと組み合わせてメトリクスをPrometheusに登録し, Grafanaで可視化します。📖 まとめ
@Cacheableキャッシュ確認 → キャッシュミス時はデータベースをクエリ → キャッシュに書き込み@CachePut常にメソッドを実行してキャッシュを更新。更新操作に適している@CacheEvictキャッシュをクリア。allEntries = trueでキャッシュ全体をクリア- キーポリシーはSpEL式で表現。
condition/unlessでキャッシュフィルタリング条件を指定 - CaffeineのTTL, 最大容量, 退去ポリシーを設定して高パフォーマンスローカルキャッシュを実現
- 3大キャッシュ問題:キャッシュペネトレーション (null値キャッシュ), キャッシュブレークスルー (排他ロック), キャッシュアバランシェ (ランダムTTL)
📝 練習問題
-
基本問題 (難易度 ⭐):OrderFlowの商品クエリに
@Cacheableキャッシュを追加し, 2回目のクエリでSQLログが印刷されないこと (キャッシュヒット)を確認してください。 -
応用問題 (難易度 ⭐⭐):Caffeineキャッシュマネージャーを設定し, キャッシュスペースごとに異なるTTLと最大容量を設定してください。商品更新時に
@CachePutでキャッシュを更新し, 商品削除時に@CacheEvictでキャッシュをクリアするよう実装してください。 -
チャレンジ (難易度 ⭐⭐⭐):2段階キャッシュ (L1:Caffeine + L2:Redis)を実装してください。キャッシュ名に基づいてローカルキャッシュか分散キャッシュかを決定するカスタム
CacheResolverを作成し, 2段階キャッシュの整合性戦略を検討してください。



