404 Not Found

404 Not Found


nginx

キャッシュメカニズム

キャッシュはパフォーマンス最適化の第一歩です。頻繁に読み取られあまり書き込まれないデータは, 一度キャッシュすれば千回のクエリを節約できます。

1. 学ぶ内容


2. パフォーマンスエンジニアのリアルストーリー

(1) ペインポイント:データベースの過負荷

商品発売イベントで, CharlieがOrderFlowのフラッシュセール機能をデモンストレーションしたところ, 瞬時に50万リクエストが殺到し, すべてが人気商品のデータベースクエリに流れ込みました。MySQL接続プールが枯渇し, システム全体が10分間ダウンし, 直接10万ドルの損失が発生しました。Aliceの分析によると, 商品詳細クエリが全リクエストの80%を占めているにもかかわらず, このデータは1時間ほとんど変化していませんでした。

(2) Spring Cacheによる解決策

1つのアノテーションでキャッシュを有効にできます:

JAVA
@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 キャッシュサポートの有効化 設定クラス
100%
graph TD
    A["Client Request"] --> B{"@Cacheable<br/>Cache Hit?"}
    B -->|Yes| C["キャッシュデータを返す"]
    B -->|No| D["メソッドを実行<br/>データベースをクエリ"]
    D --> E["結果をキャッシュに保存"]
    E --> F["結果を返す"]

(1) ▶ サンプル:@Cacheableクエリキャッシュ

JAVA
@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));
    }
}

出力:

TEXT
// 実行成功

(2) ▶ サンプル:@CachePutでキャッシュを更新

JAVA
@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);
}

出力:

TEXT
// 実行成功

(3) ▶ サンプル:@CacheEvictでキャッシュをクリア

JAVA
@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");
}

出力:

TEXT
// 実行成功
アノテーション メソッド実行 キャッシュ更新 適用操作
@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

JAVA
@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) { /* ... */ }

出力:

TEXT
// 実行成功

(2) 条件フィルタリング

(2) ▶ サンプル:conditionとunless

JAVA
// 価格 > 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) { /* ... */ }

出力:

TEXT
// 実行成功
パラメータ 実行タイミング 意味
condition メソッド実行前 条件を満たす場合のみキャッシュ
unless メソッド実行後 条件を満たす場合, キャッシュしない

5. Caffeineローカルキャッシュ設定

(1) Caffeineの特徴

特徴 説明
高パフォーマンス Guava Cacheより30%高いスループット
非同期リフレッシュ 期限切れキャッシュの非同期バックグラウンドリフレッシュをサポート
統計情報 ヒット率, ロード時間など
Window TinyLFU 最適な退去アルゴリズム

(1) ▶ サンプル:Caffeineキャッシュ設定

XML
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

出力:

TEXT
// 実行成功
JAVA
@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値のキャッシュ / ブルームフィルタ
キャッシュブレークスルー ホットキーの期限切れと同時にリクエスト急増 データベース負荷の急増 排他ロック / 期限なし + 非同期リフレッシュ
キャッシュアバランシェ 大量のキーが同時に期限切れ データベース負荷の急増 有効期限にランダムオフセットを追加
100%
graph TD
    A["キャッシュペネトレーション<br/>存在しないデータをクエリ"] --> B["解決策:null値をキャッシュ"]
    C["キャッシュブレークスルー<br/>ホットキー期限切れ"] --> D["解決策:排他ロック / 非同期リフレッシュ"]
    E["キャッシュアバランシェ<br/>多数のキーが同時期限切れ"] --> F["解決策:ランダムTTLオフセット"]

(1) ▶ サンプル:null値のキャッシュでペネトレーションを防止

JAVA
@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));
    }};
}

出力:

TEXT
// 実行成功

7. 総合例:OrderFlow商品キャッシュの完全実装

JAVA
// 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);
    }
}

❓ よくある質問

Q @Cacheableは同じクラスのメソッド同士の呼び出しで有効になりますか?
A なりません。@Transactionalと同様に, @CacheableはAOPプロキシに依存しており, 同じクラスのメソッド間の呼び出しはプロキシを経由しません。クラスを分割するかプロキシを注入する必要があります。
Q キャッシュとしてCaffeineとRedisのどちらを選ぶべきですか?
A Caffeineはローカルキャッシュ (単一プロセス)であり, 非常に高速ですがクラスタ共有をサポートしません。Redisは分散キャッシュであり, 全インスタンスでデータを共有しますが, ネットワークオーバーヘッドがあります。通常, 両者を組み合わせて使用します (L1 Caffeine + L2 Redis)。
Q キャッシュとデータベースの整合性はどう確保しますか?
A 一般的な戦略:1)Cache Aside (先にデータベースを更新, その後キャッシュを削除);2)Write Through (キャッシュとデータベースに同期的に書き込み);3)Write Behind (先にキャッシュに書き込み, その後非同期でデータベースに書き込み)。Cache Aside + 遅延二重削除の組み合わせをお勧めします。
Q maximumSizemaximumWeightの違いは何ですか?
A maximumSizeはエントリ数を制限し, maximumWeightは総重量を制限します (weigherの指定が必要)。キャッシュオブジェクトのサイズが大きく異なる場合, maximumWeightのほうが精度が高くなります。
Q キャッシュヒット率を監視するにはどうすればよいですか?
A CaffeineでrecordStats()を有効化した後, Cache.stats()でヒット率を取得できます。Micrometerと組み合わせてメトリクスをPrometheusに登録し, Grafanaで可視化します。
Q @CachePutと@Cacheableを同じメソッドに使用できますか?
A 推奨されません。@Cacheableはメソッドの実行をスキップする可能性があり (キャッシュヒット時), @CachePutは常にメソッドを実行してキャッシュを更新するため, 両者のセマンティクスが矛盾します。

📖 まとめ


📝 練習問題

  1. 基本問題 (難易度 ⭐):OrderFlowの商品クエリに@Cacheableキャッシュを追加し, 2回目のクエリでSQLログが印刷されないこと (キャッシュヒット)を確認してください。

  2. 応用問題 (難易度 ⭐⭐):Caffeineキャッシュマネージャーを設定し, キャッシュスペースごとに異なるTTLと最大容量を設定してください。商品更新時に@CachePutでキャッシュを更新し, 商品削除時に@CacheEvictでキャッシュをクリアするよう実装してください。

  3. チャレンジ (難易度 ⭐⭐⭐):2段階キャッシュ (L1:Caffeine + L2:Redis)を実装してください。キャッシュ名に基づいてローカルキャッシュか分散キャッシュかを決定するカスタムCacheResolverを作成し, 2段階キャッシュの整合性戦略を検討してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%