Redis:キャッシュアサイド、ライトスルー、ライトビハインドの選択

1. 概要

Redisをデータベースの前に置くことは、システムのレイテンシーと負荷を削減する最速の方法です。難しいのは別のところです。キャッシュとデータベースがずれないように、データ書き込み戦略をどのように決めるかということです。一般的な戦略は3つあります。キャッシュアサイド、ライトスルー、ライトビハインドです。

この記事ではSpring Boot 3とRedisを使ってこの3つすべてを実装します。その後、一貫性、パフォーマンス、データ損失のリスクに基づいてそれらを比較します。読み終わると、各データタイプに対してどの戦略を選択するべきかがわかります。

2. 準備

2.1. 依存関係

Spring Data Redis、Spring Data JPA with H2をサンプルデータベースとして、テストのためのTestcontainersを使用します。

<dependencies>
    <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>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <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>
</dependencies>

ローカルで実行するときのRedis接続設定:

spring.data.redis.host=localhost
spring.data.redis.port=6379

2.2. Entity、repository、RedisTemplate

@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 interface ProductRepository extends JpaRepository<Product, Long> {
}

Spring BootはStringRedisTemplateを自動的に作成します。ProductをJSON形式で保存するために、専用のRedisTemplateを宣言します。

@Configuration
@EnableScheduling
public class RedisConfig {

    @Bean
    RedisTemplate<String, Product> productRedisTemplate(RedisConnectionFactory factory) {
        var template = new RedisTemplate<String, Product>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new Jackson2JsonRedisSerializer<>(Product.class));
        return template;
    }
}

キーの形式はproduct:{id}で、値はProductのJSONです。@EnableSchedulingアノテーションはライトビハインドセクションで使用されます。

3つの戦略すべてでトランザクションcommit後に実行するコードが必要です。この部分をヘルパーにまとめます。

public final class AfterCommit {

    public static void run(Runnable action) {
        if (!TransactionSynchronizationManager.isSynchronizationActive()) {
            action.run();
            return;
        }
        TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
            @Override
            public void afterCommit() {
                action.run();
            }
        });
    }
}

トランザクション内にいる場合、actionはcommit成功時のみ実行されます。トランザクションがロールバックされた場合、キャッシュには触れません。

3. キャッシュアサイド(遅延読み込み)

キャッシュアサイドではアプリケーション自体がキャッシュを管理します。

  • 読み込み:最初にRedisで探します。キャッシュミスの場合、データベースから読み込み、結果をTTL付きでRedisに書き込みます。
  • 書き込み:データベースを更新した後、Redisのキーを削除します。
@Service
public class ProductCacheAsideService {

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

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductCacheAsideService(ProductRepository repository,
                                    RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    public Optional<Product> findById(Long id) {
        Product cached = redis.opsForValue().get(key(id));
        if (cached != null) {
            return Optional.of(cached);
        }
        Optional<Product> fromDb = repository.findById(id);
        fromDb.ifPresent(p -> redis.opsForValue().set(key(id), p, TTL));
        return fromDb;
    }

    @Transactional
    public Product updatePrice(Long id, BigDecimal newPrice) {
        Product product = repository.findById(id).orElseThrow();
        product.setPrice(newPrice);
        AfterCommit.run(() -> redis.delete(key(id)));
        return product;
    }

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

上記のコードの動作:

  • 最初のfindById(1L)の呼び出しはデータベースを読み込み、TTL 10分でキーproduct:1を作成します。
  • その後10分以内の呼び出しはRedisから返され、データベースにアクセスしません。
  • updatePriceはエンティティを変更し、JPAのダーティチェッキングはcommit時にデータベースに書き込み、その後キーが削除されます。次の読み込みは新しい値を再度読み込みます。

redis-cliで確認:

redis-cli GET product:1
redis-cli TTL product:1
"{\"id\":1,\"name\":\"Bàn phím cơ\",\"price\":1200000.00}"
(integer) 587

注意: 書き込み時に新しい値を設定する代わりにキーを削除してください。2つのリクエストが同時に更新する場合を想定します。両方がsetの場合、Redisへの書き込み順序はデータベースへのcommit順序と逆になる可能性があり、キャッシュは古い値を無期限に保持します。キーを削除すれば、その後の読み込みは常に最新バージョンを取得します。さらに、commitの後に削除する必要があります。削除の前に、読み込みリクエストが介入して古いデータをキャッシュに再度読み込む可能性があります。TTLは最後のセーフティネットです。TTLを設定せずにキャッシュしてはいけません。

キャッシュアサイド自体であっても、まだ小さなraceウィンドウがあります。1つのリクエストがデータベースから古いデータを読み込み、キーが削除された後にキャッシュに書き込みます。TTLが最後の防止策です。TTLを常に設定してください。

シンプルなデータの場合、Spring Cacheの@Cacheableと@CacheEvictを使用して、より少ないコードで同じ効果を達成できます。その本質は依然としてキャッシュアサイドです。

4. ライトスルー

ライトスルーでは、書き込みするたびにデータベースとキャッシュの両方が同期的に更新されます。リクエストは両方が完了した場合にのみ戻ります。読み込み部分は依然としてキャッシュアサイドと同じですが、ヒット率が高いのは、書き込まれたばかりのデータが既にRedisに存在しているためです。

@Service
public class ProductWriteThroughService {

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

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;

    public ProductWriteThroughService(ProductRepository repository,
                                      RedisTemplate<String, Product> redis) {
        this.repository = repository;
        this.redis = redis;
    }

    @Transactional
    public Product save(Product product) {
        Product saved = repository.saveAndFlush(product);
        AfterCommit.run(() -> redis.opsForValue()
                .set(ProductCacheAsideService.key(saved.getId()), saved, TTL));
        return saved;
    }
}

saveAndFlushの行はすぐにデータベースに書き込んでIDを取得し、制約エラーを早期に検出するためのものです。commitの後、レコードがRedisにプッシュされます。新しく作成された製品が再度読み込まれるとき、結果は最初のキャッシュヒットです。

ライトスルーの利点:

  • キャッシュはいつも書き込まれたばかりのデータで温かく、最初のキャッシュミスがありません。
  • 書き込み直後に頻繁に読み込まれるデータに適しています。例えば、顧客プロファイルやショッピングカートなど。

欠点:

  • 書き込みするたびに、Redisへの追加のラウンドトリップがかかり、書き込みレイテンシーが増加します。
  • キャッシュは誰も読まないレコードでいっぱいになります。TTLはクリーンアップを支援しますが、それでもメモリを消費します。
  • 複数のwriterが同時にsetするときは依然としてraceがあります。前述のセクション3を参照してください。データが同時に更新される場合、@Versionを追加してバージョンが新しい場合のみ上書きするか、またはキーの削除方式に戻ります。

注意: データベースがcommitした後にRedisが失敗する場合、キャッシュは最新バージョンを失います。例外をキャッチしてバックアップ計画としてキーを削除します。データベースが正常に書き込んだ後、リクエストが失敗しないようにしてください。

5. ライトビハインド(ライトバック)

ライトビハインドでは、アプリケーションはRedisにのみ書き込んで、すぐに戻ります。バックグラウンドプロセスが変更を集約してバッチでデータベースに書き込みます。データベースはもうリクエストの経路上にないため、書き込みは非常に速いです。その代わり、Redisは一時的にソースデータを保持する場所になります。

@Service
public class ProductWriteBehindService {

    private static final String DIRTY_SET = "products:dirty";
    private static final Logger log = LoggerFactory.getLogger(ProductWriteBehindService.class);

    private final ProductRepository repository;
    private final RedisTemplate<String, Product> redis;
    private final StringRedisTemplate stringRedis;

    public ProductWriteBehindService(ProductRepository repository,
                                     RedisTemplate<String, Product> redis,
                                     StringRedisTemplate stringRedis) {
        this.repository = repository;
        this.redis = redis;
        this.stringRedis = stringRedis;
    }

    public void updatePrice(Long id, BigDecimal newPrice) {
        String key = ProductCacheAsideService.key(id);
        Product product = Optional.ofNullable(redis.opsForValue().get(key))
                .or(() -> repository.findById(id))
                .orElseThrow();
        product.setPrice(newPrice);
        redis.opsForValue().set(key, product);              // TTLなし
        stringRedis.opsForSet().add(DIRTY_SET, id.toString());
    }

    @Scheduled(fixedDelay = 5000)
    public void flush() {
        String id;
        while ((id = stringRedis.opsForSet().pop(DIRTY_SET)) != null) {
            Product product = redis.opsForValue().get(ProductCacheAsideService.key(Long.valueOf(id)));
            if (product == null) {
                continue;
            }
            try {
                repository.save(product);
            } catch (RuntimeException ex) {
                log.warn("Flush失敗(product:{})、後で再試行", id, ex);
                stringRedis.opsForSet().add(DIRTY_SET, id);
                break;
            }
        }
    }
}

処理フローは次のようです。

  • updatePriceはRedisのレコードを更新し、products:dirty セットにIDを追加します。リクエストは数ミリ秒で終了します。
  • 5秒ごと、flush()はSPOPを使用して各IDを取り出し、対応するレコードをデータベースに書き込みます。
  • 1つの製品が5秒間に100回更新される場合、データベースは1回の書き込みのみを受け取ります。これはwrite-behindの最大の利点です。
  • 書き込みエラーの場合、IDは次の試行のためにセットに戻されます。

これは3つの戦略の中で最もリスクの高いモデルです。対処する必要がある重要なポイント:

  • Redisがダウンするとデータが失われます。 AOFをappendfsync everysecまたはalwaysで有効にし、レプリケーション付きRedisを実行します。それでも、約1秒間のデータが失われる可能性があります。
  • エビクション。 ダーティキーにはTTLがありません。maxmemory-policyをnoevictionまたはvolatile-*に設定します。そうしないと、Redisはflushする前にデータを削除する可能性があります。
  • 複数インスタンス。 SPOPがアトミックであるため、2つのインスタンスは同じIDを取得しません。ただし、SPOPの直後にインスタンスが停止してsaveの前に、その変更はRedisに留まります。次の更新まで。より安全にするには、Redis StreamsをコンシューマーグループとXACKと共に使用します。
  • データベースのクエリは古いデータを見ます。 レポート、別のジョブ、または別のサービスがデータベースを直接読み込む場合、実際より数秒遅れます。

注意: アカウント残高、注文、支払い、または1つのレコードを失うと問題になるデータに、write-behindを使用しないでください。 ビュー数、いいね数、読み取り位置、last-seenなど、書き込みが非常に多く、若干のロスを許容できるデータに適しています。

6. キャッシュアサイドの動作をテスト

以下のテストはTestcontainersで実行する実際のRedisを使用します。@ServiceConnectionはSpring Bootのホストとポートを自動的に設定します。

@SpringBootTest
@Testcontainers
class ProductCacheAsideServiceTest {

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

    @Autowired
    ProductCacheAsideService service;

    @Autowired
    ProductRepository repository;

    @Autowired
    RedisTemplate<String, Product> redis;

    @Test
    void shouldCacheOnReadAndEvictAfterUpdate() {
        Product saved = repository.save(new Product("Bàn phím cơ", new BigDecimal("1200000")));
        String key = ProductCacheAsideService.key(saved.getId());

        service.findById(saved.getId());
        assertThat(redis.hasKey(key)).isTrue();

        service.updatePrice(saved.getId(), new BigDecimal("990000"));
        assertThat(redis.hasKey(key)).isFalse();

        Product reloaded = service.findById(saved.getId()).orElseThrow();
        assertThat(reloaded.getPrice()).isEqualByComparingTo("990000");
        assertThat(redis.hasKey(key)).isTrue();
    }
}

このテストはキーのライフサイクル全体を検証します。読み込み時に作成され、更新後に削除され、新しい値で再度読み込まれます。テストクラスは@Transactionalを付けません。付けると、テストのトランザクションはロールバックされ、afterCommitコールバックは実行されず、2番目のアサーションは失敗します。

7. 比較と選択

比較項目 キャッシュアサイド ライトスルー ライトビハインド
初回読み取りレイテンシー 高い(ミス) 低い 低い
書き込みレイテンシー 低い 中程度(2ヶ所に書き込み) 非常に低い
DBとの一貫性 良好(TTLがセーフティネット) 良好 結果整合性、数秒の遅延
データ損失のリスク なし なし あり
複雑さ 低い 中程度 高い
DBの書き込み負荷削減 なし なし あり(バッチ処理)

選択時のヒント:

  • デフォルトではキャッシュアサイドを使用します。 シンプルで、フォールトトレランスが優れており、Redisがダウンしてもデータベースを読み込むことができます。ほとんどの読み込み多、書き込み少のデータ(製品カタログや設定など)に十分です。
  • 書き込み直後に頻繁に読み込まれるデータの場合、write-throughを選択します し、最初のミスを避けたいときです。実際には、多くのシステムが組み合わせます。write-throughでの書き込み、cache-asideでの読み込み。
  • データベースが実際に書き込みボトルネックである場合にのみwrite-behindを選択し、かつデータが損失を許容できる場合のみです。データベースが詰まっていることを証明する計測データがない場合、この複雑さの代償を払わないでください。
  • 全体のシステムに対して1つの戦略を選択する必要はありません。各データタイプは異なる戦略を使用できます。

3つの戦略すべてに共通する一般的なエラー:

  • キャッシュスタンピード。 ホットキーの有効期限が切れると、数千のリクエストが同時にミスしてデータベースに大量の負荷がかかります。ランダムジッターでTTLを設定することで対処できます。またはSET key value NX PXでロックして、1つのリクエストのみが再度読み込まれるようにします。
  • キャッシュペネトレーション。 リクエストが存在しないIDを継続的にクエリするため、いつもミスします。短いTTLで空の結果もキャッシュします。
  • クラス構造を変更してもキャッシュのクリーンアップを忘れた場合。 古いJSONは新しいクラスにデシリアライズできません。キーにバージョンを追加します。例えば、product:v2:{id}。

8. 結論

この記事はSpring Boot 3とRedisを使って3つのキャッシュ戦略を実装しました。キャッシュアサイドは必要に応じてデータを読み込み、commitの後にキーを削除します。ライトスルーは書き込み時にデータベースとキャッシュの両方を同期します。ライトビハインドは高速な書き込みのためにデータの耐久性をトレードオフします。

簡潔なルール:キャッシュアサイドで開始し、常にTTLを設定します。キャッシュが温かい場合は、write-throughにアップグレードします。データ損失を許容できるデータにのみwrite-behindを使用し、AOF、適切なevictionポリシー、そして明確なリトライメカニズムとともに実装します。

rediscachespring-bootjavaパフォーマンス