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ポリシー、そして明確なリトライメカニズムとともに実装します。