キャッシング: cache-aside、write-through、TTL

1. 概要

キャッシュはデータベースの負荷を軽減し、レスポンス時間を短縮します。その代わり、データが古くなる可能性があることを受け入れる必要があります。この記事では、キャッシュの読み書きに関する2つの一般的な戦略であるcache-asideとwrite-throughを説明します。また、**TTL (time-to-live)**を使用してデータの鮮度を管理する方法も解説します。

この記事を読み終わったら、純粋なJavaで両方の戦略を実装し、Spring Cacheのアノテーションに切り替え、CaffineとRedisのTTLを設定し、よくある落とし穴を回避できるようになります。

2. 基本概念

コードを書く前に、いくつかの用語を定義しましょう:

  • キャッシュヒット: キャッシュにデータが存在し、即座に返されます。
  • キャッシュミス: キャッシュにデータがなく、元のソース(データベース、別のサービス)から読み込む必要があります。
  • 古いデータ: キャッシュ内のデータがデータベースと異なっています。
  • TTL: エントリの有効期間です。TTLが終了すると、エントリは無効と見なされます。

すべてのキャッシング戦略は、次の質問を中心に展開されます:データが変更されたとき、誰がキャッシュの更新を担当し、どのタイミングで更新するのか?

この記事全体で使用する単純なドメインは次の通りです:

public record Product(long id, String name, BigDecimal price) {}

public interface ProductRepository {
    Optional<Product> findById(long id);
    Product save(Product product);
}

3. TTL付きキャッシュの手作り実装

仕組みを理解するために、TTL付きのシンプルなキャッシュを自作します。時刻はSupplier<Instant>を通じて取得するので、テストで時間を操作できます。

public class TtlCache<K, V> {

    private record Entry<V>(V value, Instant expiresAt) {}

    private final Map<K, Entry<V>> store = new ConcurrentHashMap<>();
    private final Duration ttl;
    private final Supplier<Instant> now;

    public TtlCache(Duration ttl, Supplier<Instant> now) {
        this.ttl = ttl;
        this.now = now;
    }

    public Optional<V> get(K key) {
        Entry<V> entry = store.get(key);
        if (entry == null) {
            return Optional.empty();
        }
        if (now.get().isAfter(entry.expiresAt())) {
            store.remove(key, entry);
            return Optional.empty();
        }
        return Optional.of(entry.value());
    }

    public void put(K key, V value) {
        store.put(key, new Entry<>(value, now.get().plus(ttl)));
    }

    public void evict(K key) {
        store.remove(key);
    }
}

このクラスの動作方法は以下の通りです:

  • putは値を有効期限のタイムスタンプexpiresAt = now + ttlと共に保存します。
  • getは有効期限をチェックします。エントリが期限切れの場合、削除され、Optional.empty()が返され、これはキャッシュミスです。
  • store.remove(key, entry)は読み取ったエントリのみを削除します。これにより、別のスレッドが新しい値をputしたばかりの場合、誤削除されることはありません。

このクラスはデモンストレーション目的には十分です。プロダクション環境では、CaffineまたはRedisを使用してください(セクション 6)。

4. Cache-aside

4.1. 概念

cache-aside(lazy loadingとも呼ばれる)では、アプリケーション自身がキャッシュを管理します:

  1. 読み込み:キャッシュを最初に探します。ミスの場合、データベースから読み込み、結果をキャッシュに書き込んでから返します。
  2. 書き込み:最初にデータベースを更新し、その後キャッシュのエントリを削除します。次回の読み込みで新しいデータが再ロードされます。
public class ProductService {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public ProductService(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Optional<Product> findById(long id) {
        Optional<Product> cached = cache.get(id);
        if (cached.isPresent()) {
            return cached;                       // キャッシュヒット
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> cache.put(id, p)); // キャッシュに読み込む
        return fromDb;
    }

    public Product updatePrice(long id, BigDecimal newPrice) {
        Product current = repository.findById(id).orElseThrow();
        Product saved = repository.save(new Product(id, current.name(), newPrice));
        cache.evict(id);                         // DB書き込み後に削除
        return saved;
    }
}

説明:

  • findByIdはキャッシュミス時のみrepositoryを呼び出します。製品が存在しない場合、何もキャッシュしません。
  • updatePriceは最初にデータベースに書き込み、その後evictします。この順序は重要であり、セクション 8 で理由を説明します。
  • 新しい値を上書きする代わりにエントリを削除することを選択しました。これはより簡単です。なぜなら、キャッシュは読み取りパス1か所からのみロードされるからです。

4.2. いつ使うべきか

cache-asideは以下の場合に適しています:

  • 読み取り操作が書き込み操作よりはるかに多い場合。例えば、製品カタログやユーザープロフィール。
  • 実際にアクセスされるデータのみをキャッシュしたい場合。
  • キャッシュが故障した場合、システムが引き続き実行される必要があります。その場合、すべてのリクエストがデータベースに直接送られます。遅くなりますが、エラーにはなりません。

欠点:ミスのたびに最初のリクエストは常に遅くなります。さらに、別のプロセスがキャッシュを削除せずにデータベースに直接書き込んだ場合、データはTTLが終了するまで古いままになります。

5. Write-through

5.1. 概念

write-throughでは、すべての書き込み操作がキャッシュ層を通じて行われます。この層はデータベースに同期的に書き込み、その後キャッシュを新しい値で更新します。書き込みコマンドが戻るとき、キャッシュとデータベースは一致しています。

public class WriteThroughProductStore {

    private final ProductRepository repository;
    private final TtlCache<Long, Product> cache;

    public WriteThroughProductStore(ProductRepository repository, TtlCache<Long, Product> cache) {
        this.repository = repository;
        this.cache = cache;
    }

    public Product save(Product product) {
        Product saved = repository.save(product); // 1. DB書き込み(同期)
        cache.put(saved.id(), saved);             // 2. キャッシュ更新
        return saved;
    }

    public Optional<Product> findById(long id) {
        return cache.get(id).or(() -> {
            Optional<Product> fromDb = repository.findById(id);
            fromDb.ifPresent(p -> cache.put(id, p));
            return fromDb;
        });
    }
}

cache-asideとの違い:

  • saveは削除する代わりに保存した値でキャッシュを上書きします。その直後の読み込みはキャッシュヒットになります。
  • repository.saveが成功した場合のみキャッシュに書き込みます。データベースが例外をスローした場合、キャッシュは影響を受けないため、キャッシュに保存されたことのないデータが含まれることはありません。
  • findByIdはフォールバックブランチが必要です。なぜなら、エントリは既にTTLが切れているか、saveを通じて書き込まれたことがない可能性があるからです。

5.2. いつ使うべきか

write-throughは、書き込み直後にデータが再び読み込まれる場合に適しています。例えば、買い物かごやユーザー設定。代償として以下があります:

  • 2か所に書き込む必要があるため、各書き込みが少し遅くなります。
  • キャッシュには誰も読まないデータが多く含まれる可能性があります。TTLとサイズ制限と組み合わせる必要があります。

注意:write-throughをwrite-behind(write-back)と混同しないでください。write-behindは先にキャッシュに書き込み、その後データベースに非同期的に書き込みます。書き込みが速いですが、キャッシュノードが flush する前に故障すると、データが失われます。write-behindは、そのリスクを受け入れられる場合にのみ使用してください。

6. Spring Cacheの使用

手作り実装は仕組みを理解するのに役立ちますが、実際のプロジェクトではSpring CacheとCaffeine(ローカルキャッシュ)またはRedis(分散キャッシュ)を使用すべきです。依存関係を追加:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

@EnableCachingでキャッシュを有効にします:

@SpringBootApplication
@EnableCaching
public class ShopApplication {
    public static void main(String[] args) {
        SpringApplication.run(ShopApplication.class, args);
    }
}

6.1. @Cacheableと@CacheEvictを使用したCache-aside

@Service
public class CatalogService {

    private final ProductRepository repository;

    public CatalogService(ProductRepository repository) {
        this.repository = repository;
    }

    @Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
    public Optional<Product> findById(long id) {
        return repository.findById(id);
    }

    @CacheEvict(cacheNames = "products", key = "#product.id")
    public Product update(Product product) {
        return repository.save(product);
    }
}
  • @Cacheableは宣言的形式のcache-asideです。Springは先にキャッシュをチェックし、ミスしたらメソッドを呼び出して結果を保存します。
  • Optionalの戻り値型の場合、Springは内部の値をキャッシュします。unless = "#result == null"は見つからない場合のキャッシング を防ぎます。
  • @CacheEvictはデフォルトでメソッドが正常に戻った後に実行されます。これは「DB書き込みの後にキャッシュ削除」という正しい順序です。

6.2. @CachePutを使用したWrite-through

@CachePut(cacheNames = "products", key = "#result.id")
public Product save(Product product) {
    return repository.save(product);
}

@CachePutは常にメソッドを実行し、その後戻り値をキャッシュに書き込みます。これはセクション 5 のWriteThroughProductStore.saveと同等です。

**注意:**アノテーションキャッシュはプロキシを通じて機能します。同じクラス内の別のメソッドからthis.findById(...)を呼び出した場合、キャッシュは完全に無視されます。別のbeanを通じて呼び出してください。

7. TTL の設定

7.1. Caffeine

spring:
  cache:
    type: caffeine
    cache-names: products
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=10m
  • expireAfterWrite=10m:エントリは最後の書き込みから10分後に有効期限切れになります。
  • maximumSize=10000:制限を超えると、Caffeine は使用頻度の低いエントリを削除します。ヒープを使い尽くさないよう、常にこの制限を設定してください。

7.2. Redis

spring-boot-starter-data-redisを追加してから、以下のように設定します:

spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=shop::

Redis はTTL期限切れ時に自動的にキーを削除します。このキャッシュはアプリケーションの複数のインスタンス間で共有されます。

7.3. TTLはどのくらいに設定すべきか?

すべてのケースに共通する数値はありません。自問自答してください:データが古くなることが許されるのは最大どのくらいか?

為替レート、在庫        : 数秒~ 1分
商品価格、カタログ : 5~ 30分
設定、都道府県リスト : 数時間~ 1日

短いTTLはデータがより新鮮になりますが、ヒット率は低くなります。長いTTLはその逆です。能動的にevictしていても、TTLを忘れたevictのセーフティネットとして設定する必要があります。

7.4. 同時失効を避けるため jitter を追加

10,000個の製品をTTL 10分で同時にロードしたと仮定します。それらは同時に失効し、データベースは一度にリクエストが集中します。この現象はcache stampedeと呼ばれます。対策はTTLにランダムな間隔を追加することです:

public void put(K key, V value) {
    long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 60);
    Instant expiresAt = now.get().plus(ttl).plusSeconds(jitterSeconds);
    store.put(key, new Entry<>(value, expiresAt));
}

Spring + Caffeine の場合、@Cacheable(..., sync = true)を追加できます。複数のスレッドが同じキーについてミスした場合、1つのスレッドのみがデータベースに呼び出され、他のスレッドは結果を待ちます。

8. テストを書く

テストでは、ダミーリポジトリを使用してデータベースアクセスの回数を数え、制御可能なクロックを使用します:

class ProductServiceTest {

    static class CountingRepository implements ProductRepository {
        final Map<Long, Product> data = new HashMap<>();
        int findCalls = 0;

        public Optional<Product> findById(long id) {
            findCalls++;
            return Optional.ofNullable(data.get(id));
        }

        public Product save(Product p) {
            data.put(p.id(), p);
            return p;
        }
    }

    AtomicReference<Instant> clock = new AtomicReference<>(Instant.parse("2026-01-01T00:00:00Z"));
    CountingRepository repository = new CountingRepository();
    ProductService service = new ProductService(
            repository, new TtlCache<>(Duration.ofMinutes(10), clock::get));

    @BeforeEach
    void setUp() {
        repository.save(new Product(1L, "Nón lá", new BigDecimal("50000")));
    }

    @Test
    void secondReadIsServedFromCache() {
        service.findById(1L);
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(1);
    }

    @Test
    void entryExpiresAfterTtl() {
        service.findById(1L);
        clock.set(clock.get().plus(Duration.ofMinutes(11)));
        service.findById(1L);

        assertThat(repository.findCalls).isEqualTo(2);
    }

    @Test
    void updateEvictsStaleValue() {
        service.findById(1L);
        service.updatePrice(1L, new BigDecimal("45000"));

        assertThat(service.findById(1L))
                .get()
                .extracting(Product::price)
                .isEqualTo(new BigDecimal("45000"));
    }
}
  • secondReadIsServedFromCache:2回目の読み込みはデータベースに触れません。
  • entryExpiresAfterTtl:時計を10分以上進めた後、キャッシュミスが発生し、データベースが再度呼び出されます。
  • updateEvictsStaleValue:更新後、読み込み側は古いキャッシュ値ではなく新しい価格を取得します。

9. よくある落とし穴

DB書き込み前にキャッシュを削除する。次のシナリオを考えてください:スレッド A がキャッシュを削除します。スレッド B は読み込み、ミスして、データベースから古い値を取得し、キャッシュに書き込みます。その後、スレッド A がデータベースに新しい値を書き込みます。結果として、キャッシュはTTL終了まで古い値を保持します。DB書き込みを先に行い、キャッシュ削除を後にしてください。

コミット前のトランザクション内のキャッシュ。@Transactionalメソッド内で@CachePutを使用してからトランザクションがロールバックされた場合、キャッシュにはデータベースに存在しないデータが含まれます。キャッシュ操作をトランザクションの外層に配置するか、TransactionAwareCacheManagerProxyを使用してください。

**複数インスタンス上のローカルキャッシュ。**Caffeine は各インスタンスの JVM 内に存在します。インスタンス A の@CacheEvictはインスタンス B のキャッシュを削除できません。複数インスタンスを実行している場合で一貫性が必要な場合、Redis を使用するか、短いTTLで古さを受け入れてください。

**変更可能なキャッシュオブジェクト。**Caffeine はキャッシュ内に存在する参照そのものを返します。そのオブジェクトを変更したら、キャッシュも変更したことになります。例のようにrecordまたは不変オブジェクトを使用してください。

**注意:**データが頻繁に変更され、強い一貫性が必要な場合はキャッシュすべきではありません。例えば、口座残高や決済時点での空席数です。こうした場合、適切なロック付きでデータベースから直接読み込む方が安全です。

10. 結論

この記事では、2つの主要なキャッシング戦略を比較しました:

  • Cache-aside:アプリケーションがミス時にキャッシュをロードし、書き込み後に削除します。これはシンプルで耐障害性に優れた方法であり、読み取りが多いほとんどのケースに適しています。
  • Write-through:すべての書き込み操作がデータベースとキャッシュの両方を同期的に更新する代わりに、書き込み直後に新鮮なデータを提供します。

どの戦略を選択しても、TTLをセーフティネットとして、キャッシュサイズを制限し、stampede を避けるために jitter を追加する必要があります。@Cacheable + Caffeine で始めましょう。アプリケーションが複数インスタンスにスケールしたら Redis に切り替え、キャッシュがデータベースアクセスを実際に減らしていることを証明するテストを常に保持してください。

system-designcachingspring-bootcaffeineredis