Redisでキャッシュとデータベースの一貫性を保つ

1. 概要

Redisをデータベースの前に置くことで、負荷を減らし、読み込み速度を上げられます。その代わり、同じデータが2つの場所に保存され、ズレが生じる可能性があります。ユーザーが製品の価格を変更しても、商品詳細ページは10分間、古い価格を表示し続けることがあります。これが典型的な一貫性の問題です。

この記事ではcache-asideパターンを使用します。読み込み時はキャッシュから先に取得し、書き込み時はデータベースを更新してからtransaction commitの後にキャッシュを削除します。残されたrace conditionの問題と、それを最小化する方法について説明します。最後に、Testcontainersを使ってRedisと連携する統合テストの例を示します。

2. キャッシュとデータベースがズレる理由

データベースとRedisは同じトランザクションを共有しません。両方に対して原子的に書き込む方法はありません。操作の順序と時期が、データのズレが生じるかどうかを決定します。

最も一般的な3つのエラーは次のとおりです。

  • キャッシュを削除する代わりに更新する。 2つの同時書き込みリクエストは、データベースにはA→Bの順序で書き込むが、キャッシュにはB→Aの順序で書き込む可能性があります。キャッシュはAの値を永遠に保持するか、TTLが切れるまで保持します。
  • commitの前にキャッシュを削除する。 読み込みリクエストがキャッシュ削除とcommitの間に割り込まれます。そのリクエストはデータベースから古い値を読み込んで、キャッシュに再度ロードします。
  • キャッシュ削除に失敗しても誰も気づかない。 Redisがタイムアウトしたり、例外が飲み込まれたりして、キャッシュが古いデータを保持し続けます。

記事の残りの部分では、これらの各エラーに対処します。

3. プロジェクトのセットアップ

Redis、JPA、およびテスト用データベースのdependencyを追加します:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>runtime</scope>
</dependency>

Redis接続の設定:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.timeout=500ms

タイムアウトが短いのは意図的です。Redisが遅い場合は、リクエストを素早くデータベースに戻したいので、数秒間ハング状態にしたくありません。

EntityとRepository:

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private BigDecimal price;

    protected Product() {}
    public Product(String name, BigDecimal price) { this.name = name; this.price = price; }

    public Long getId() { return id; }
    public String getName() { return name; }
    public BigDecimal getPrice() { return price; }
    public void setPrice(BigDecimal price) { this.price = price; }
}

public interface ProductRepository extends JpaRepository<Product, Long> {}

Entityをキャッシュするのではなく、不変なレコードをキャッシュします:

public record ProductView(Long id, String name, BigDecimal price) {
    static ProductView from(Product p) {
        return new ProductView(p.getId(), p.getName(), p.getPrice());
    }
}

このアプローチはHibernateプロキシのシリアライズエラーを避けます。また、キャッシュ内のフォーマットをテーブル構造から独立させます。

4. Cache-asideパターンでの読み込み

@Service
public class ProductService {

    private static final Duration TTL = Duration.ofMinutes(10);

    private final ProductRepository repository;
    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;
    private final ApplicationEventPublisher events;

    public ProductService(ProductRepository repository, StringRedisTemplate redis,
                          ObjectMapper mapper, ApplicationEventPublisher events) {
        this.repository = repository;
        this.redis = redis;
        this.mapper = mapper;
        this.events = events;
    }

    static String key(Long id) { return "product:" + id; }

    public ProductView findById(Long id) {
        String cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return read(cached);
        }
        ProductView view = repository.findById(id)
                .map(ProductView::from)
                .orElseThrow(() -> new NoSuchElementException("Product " + id));
        redis.opsForValue().set(key(id), write(view), TTL);
        return view;
    }

    private ProductView read(String json) {
        try { return mapper.readValue(json, ProductView.class); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }

    private String write(ProductView view) {
        try { return mapper.writeValueAsString(view); }
        catch (JsonProcessingException e) { throw new IllegalStateException(e); }
    }
}

処理フローは次のとおりです:

  • redis.opsForValue().get(...) はキャッシュからの取得を試みます。存在すればすぐに返します。これがcache hitです。
  • 存在しない場合(cache miss)、serviceはデータベースから読み込んで、TTL 10分でRedisに書き込みます。
  • TTLはセーフティネットです。 後の全てのメカニズムは失敗する可能性があります。TTLは不正なデータが10分以上存在しないことを保証します。

id = 1 での最初の呼び出し後、Redisには以下が含まれます:

127.0.0.1:6379> GET product:1
"{\"id\":1,\"name\":\"Ban phim co\",\"price\":1200000}"
127.0.0.1:6379> TTL product:1
(integer) 598

5. 書き込み: データベース更新後、commitの後にキャッシュを削除

5.1. 更新ではなく削除

価格が変わるとき、キャッシュに新しい価格を書き込む代わりにkeyを削除します。次の読み込みはデータベースからデータを再ロードします。データベースが真実の源です。削除はべき等操作です。2回削除しても同じ結果になります。削除コマンド間の順序も重要ではありません。これにより、セクション2の「書き込み順序のズレ」エラーが消えます。

5.2. トランザクションcommitを待つ

@Transactional メソッド内で redis.delete(...) を呼び出すと、削除コマンドは commit の前に 実行されます。以下のようなシナリオが起こり得ます:

T1 (書き込み)                     T2 (読み込み)
UPDATE price = 900 (未commit)
DEL product:1
                                 GET product:1 -> miss
                                 SELECT -> price = 1200 (古い値)
                                 SET product:1 = 1200
COMMIT
=> キャッシュはTTLが切れるまで1200を保持

解決策はイベントを発行して、AFTER_COMMITフェーズで処理することです:

public record ProductChangedEvent(Long productId) {}

ProductService に書き込みメソッドを追加します:

@Transactional
public void updatePrice(Long id, BigDecimal newPrice) {
    Product product = repository.findById(id).orElseThrow();
    product.setPrice(newPrice);
    events.publishEvent(new ProductChangedEvent(id));
}

Listenerはキャッシュ削除の責任を負います:

@Component
public class ProductCacheEvictor {

    private static final Logger log = LoggerFactory.getLogger(ProductCacheEvictor.class);
    private final StringRedisTemplate redis;

    public ProductCacheEvictor(StringRedisTemplate redis) {
        this.redis = redis;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void onProductChanged(ProductChangedEvent event) {
        evict(ProductService.key(event.productId()));
    }

    void evict(String key) {
        try {
            redis.delete(key);
        } catch (RuntimeException e) {
            log.warn("キャッシュ {} の削除に失敗しました。TTLに依存します", key, e);
        }
    }
}

いくつかの重要なポイント:

  • @TransactionalEventListener(phase = AFTER_COMMIT) はトランザクションのcommitが成功したときのみ実行されます。トランザクションがrollbackされた場合、キャッシュは削除されません。これは正しい動作です。データが変更されていないからです。
  • キャッシュ削除のエラーは、書き込みリクエストを壊しません。データベースは既にcommitされています。ここで例外をスローすると、クライアントは操作が失敗したと考えるだけです。
  • ログ警告はエラーの頻度を追跡するのに役立ちます。その間、TTLは古いデータが存在する時間を制限します。

注意: publishEvent が トランザクションなしで呼ばれた場合、@TransactionalEventListener はデフォルトでイベントをスキップし、キャッシュは削除されません。書き込みメソッドが @Transactional を持つことを確認してください。必要に応じて、fallbackExecution = true を設定してください。

6. 残存するrace conditionと遅延ダブル削除

commitの後に削除する場合でも、稀ですが以下のようなシナリオが起こり得ます:

T2 (読み込み)                      T1 (書き込み)
GET product:1 -> miss
SELECT -> price = 1200
                                  UPDATE price = 900, COMMIT
                                  DEL product:1
SET product:1 = 1200   <- 遅延書き込み

T2はT1のcommit前に古い値を読み込みますが、T1が削除した後にキャッシュに書き込みます。このシナリオはT2がちょうど遅延する必要があります。例えば、GC pauseやネットワークの遅延が原因です。稀ですが、実際に起こります。

単純な軽減策は遅延ダブル削除です。短い間隔の後に再度削除します。

@Component
public class DelayedCacheEvictor {

    private final ProductCacheEvictor evictor;
    private final TaskScheduler scheduler;

    public DelayedCacheEvictor(ProductCacheEvictor evictor, TaskScheduler scheduler) {
        this.evictor = evictor;
        this.scheduler = scheduler;
    }

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    public void scheduleSecondDelete(ProductChangedEvent event) {
        String key = ProductService.key(event.productId());
        scheduler.schedule(() -> evictor.evict(key),
                Instant.now().plusMillis(500));
    }
}

Spring Bootは @EnableScheduling を設定クラスで有効にすると、自動的に TaskScheduler を設定します。500ms後の2回目の削除により、T2が遅延して書き込んだ古い値が削除されます。遅延は通常のデータベース読み込み時間より長く選択してください。

注意: 遅延ダブル削除はエラーウィンドウを狭めるだけで、完全には排除しません。スケジュールはメモリ内にあり、アプリケーションが再起動されると失われます。絶対的な保証と見なさないでください。

7. キャッシュ削除が確実に成功する必要がある場合

残高または在庫などのデータの場合、「TTLに依存する」のは受け入れられない場合があります。その場合、2つの一般的なアプローチがあります:

  • トランザクショナルoutbox: cache_invalidation 行をデータベース自体に書き込みます。これはビジネスロジックの変更と同じトランザクション内で行われます。定期的なジョブがこのテーブルを読み込み、Redisのkeyを削除し、削除が成功した場合のみその行をマーク済みにします。outbox行はデータと同じトランザクション内でcommitされるため、キャッシュ削除イベントは決して失われません。
  • Change Data Capture (CDC): データベースのbinlog/WALを読み込むプロセスが対応するキャッシュを削除します。このアプローチはキャッシュロジックをビジネスコードから完全に分離しますが、インフラストラクチャはより複雑になります。

どちらも結果整合性(eventual consistency)です。キャッシュが最終的には正しくなることを保証しますが、すぐに正しくなることは保証しません。

8. 実際のRedisでのテスト

StringRedisTemplate をモックするだけではcommitのタイミングエラーを検出できません。Testcontainersを通じて実際のRedisでテストしてください:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class ProductServiceIT {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redisContainer =
            new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @Autowired ProductService productService;
    @Autowired ProductRepository repository;
    @Autowired StringRedisTemplate redis;

    @Test
    void updatePrice_evictsCache_andNextReadReturnsNewPrice() {
        Long id = repository.save(new Product("Ban phim co", new BigDecimal("1200000"))).getId();

        productService.findById(id); // キャッシュをロード
        assertThat(redis.hasKey(ProductService.key(id))).isTrue();

        productService.updatePrice(id, new BigDecimal("900000"));

        assertThat(redis.hasKey(ProductService.key(id))).isFalse();
        assertThat(productService.findById(id).price())
                .isEqualByComparingTo("900000");
    }
}

このテストは3つのことをチェックします:

  • @ServiceConnection(name = "redis") は spring.data.redis.* を自動的にコンテナに向けるため、追加の設定は不要です。
  • updatePrice 後、keyはRedisに存在しなくなります。これはlistenerの AFTER_COMMIT が実行されたことを証明しています。
  • 次の読み込みはデータベースから新しい価格を返します。

注意: このテストメソッドを @Transactional でマークしないでください。Springはテストの最後でテストトランザクションをrollbackするため、AFTER_COMMIT は決して起動されず、assertionが失敗します。

実行結果:

[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0

9. このアプローチを使用しない場合

  • 強い一貫性が必要なデータ(トランザクション承認の判定に使用するアカウント残高など)。キャッシュから読み込まないでください。データベースから直接、適切なロックで読み込んでください。
  • 書き込みが読み込みより多いデータ。 キャッシュは継続的に削除されるため、hit rateが低くなります。複雑さの代価を払っても、ほぼ何も得られません。
  • 複数のエンティティから構成されるkey(例えば「トップ10製品リスト」)。製品が変更されたときにどのkeyを削除すべきかを判断するのが難しい。このタイプのkeyでは、短いTTLは正確なinvalidationより一般的にシンプルで安全です。
  • 複数のサービスが同じテーブルに書き込む。 アプリケーションのイベントは他のサービスからの変更を認識できません。この場合、CDCがより適切です。

10. 結論

Redisとデータベースの一貫性を保つことは、常に完璧に同期させることではありません。目的は、ズレのウィンドウを小さく、制限することです。基本的な公式は4つのステップで構成されています:

  1. Cache-asideパターンで読み込み、常にTTLを設定してください。
  2. データベースに書き込み、その後keyを削除してください。updateではなく削除です。
  3. commitの後にのみ削除してください。@TransactionalEventListener(phase = AFTER_COMMIT) を使用します。
  4. データの機密性に応じて、遅延ダブル削除、outbox、またはCDCを追加してください。

最後に、実際のRedisでテストしてください。テスト上の @Transactional がcommit後の動作を隠さないようにしてください。

rediscachespring-bootconsistencyjava