spring-data-redis について
Java 開発者向けの Spring Data Redis リファレンススキルです。キャッシュ設定、利用パターン、レート制限、実装時の注意点を扱います。
主な機能
- 01Redis と Spring のキャッシュ設定
- 02一貫した Redis キー命名
- 03宣言型とキャッシュアサイドのパターン
- 04原子的な Redis レート制限
- 05TTL とキャッシュスタンピード対策
活用シーン
- 01Spring アプリの Redis キャッシュ設定
- 02注文や製品のキャッシュアサイド読み取り
- 03有効期限のないキーを残さないレート制限
- 04キャッシュのシリアライズやトランザクションの確認
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ rrezartprebreza/ spring-boot-skills --skill spring-data-redisインストール後、Claude Code で/spring-data-redisを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
agents/openai.yamlexamples/bad-cache-service.javagood-cache-service.javatemplates/RedisConfig.java詳細
Spring Data Redis
依存関係
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
設定
@Configuration
@EnableCaching
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(jsonSerializer()); // JSON, not Java serialization
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(jsonSerializer());
return template;
}
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer()))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.transactionAware() // put/evict only after the surrounding transaction commits
.withCacheConfiguration("orders", config.entryTtl(Duration.ofMinutes(5)))
.withCacheConfiguration("products", config.entryTtl(Duration.ofHours(1)))
.build();
}
// Records are final, so DefaultTyping.NON_FINAL writes no type id for them and every
// cache read fails ("missing type id property '@class'"). The builder's defaultTyping(true)
// types user classes too; the validator limits type ids to an allow-list instead of
// LaissezFaire's "any class". BigDecimal is non-final, so java.math must be allowed.
private GenericJackson2JsonRedisSerializer jsonSerializer() {
ObjectMapper mapper = JsonMapper.builder()
.addModule(new JavaTimeModule())
.polymorphicTypeValidator(BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.example.") // your DTO packages
.allowIfSubType("java.util.")
.allowIfSubType("java.math.")
.allowIfSubType("java.time.")
.build())
.build();
return GenericJackson2JsonRedisSerializer.builder()
.objectMapper(mapper)
.defaultTyping(true)
.build();
}
}
キーの命名規則
{app}:{domain}:{id} → orders:order:uuid-here
{app}:{domain}:list:{filter} → orders:order:list:status:PENDING
{app}:session:{userId} → orders:session:uuid-here
{app}:ratelimit:{ip} → orders:ratelimit:192.168.1.1
@Cacheable — 宣言的キャッシュ
@Service
@RequiredArgsConstructor
public class ProductService {
@Cacheable(value = "products", key = "#id")
public ProductResponse findById(UUID id) {
return productRepository.findById(id)
.map(ProductResponse::from)
.orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
}
@CachePut(value = "products", key = "#result.id") // update cache after write
@Transactional
public ProductResponse update(UUID id, UpdateProductRequest request) {
Product product = productRepository.findById(id).orElseThrow();
product.update(request);
return ProductResponse.from(productRepository.save(product));
}
@CacheEvict(value = "products", key = "#id") // invalidate on delete
@Transactional
public void delete(UUID id) {
productRepository.deleteById(id);
}
@CacheEvict(value = "products", allEntries = true) // clear all
public void clearCache() {}
}
手動のキャッシュ・アサイドパターン
@Service
@RequiredArgsConstructor
public class OrderCacheService {
private final RedisTemplate<String, Object> redisTemplate;
private final ObjectMapper objectMapper;
private static final Duration TTL = Duration.ofMinutes(5);
public Optional<OrderResponse> get(UUID orderId) {
String key = "orders:order:" + orderId;
Object cached = redisTemplate.opsForValue().get(key);
if (cached == null) return Optional.empty();
return Optional.of(objectMapper.convertValue(cached, OrderResponse.class));
}
public void put(OrderResponse order) {
String key = "orders:order:" + order.id();
redisTemplate.opsForValue().set(key, order, TTL);
}
public void evict(UUID orderId) {
redisTemplate.delete("orders:order:" + orderId);
}
}
Redis によるレート制限
@Component
@RequiredArgsConstructor
public class RateLimiter {
private final RedisTemplate<String, String> redisTemplate;
// INCR and PEXPIRE in ONE atomic script: as two separate commands, a crash or
// timeout between them leaves a key with no TTL and locks the caller out forever.
private static final RedisScript<Long> INCR_WITH_TTL = RedisScript.of(
"local c = redis.call('INCR', KEYS[1]) " +
"if c == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end " +
"return c", Long.class);
public boolean isAllowed(String identifier, int maxRequests, Duration window) {
String key = "ratelimit:" + identifier;
Long count = redisTemplate.execute(INCR_WITH_TTL, List.of(key), String.valueOf(window.toMillis()));
return count != null && count <= maxRequests;
}
}
application.yml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
max-active: 10
max-idle: 5
min-idle: 2
cache:
type: redis
キャッシュスタンピード
ホットキーの有効期限が切れると、同時に発生したすべてのリクエストが一斉にキャッシュミスとなり、同じ値を再計算するためにデータベースへ集中します(「サンダリングハード」)。負荷の高い処理やトラフィックの多い環境では、1 つの呼び出し元が計算し、残りは待機するようにします。
// sync = true — only one thread computes the value; others block on it
@Cacheable(value = "products", key = "#id", sync = true)
public ProductResponse findById(UUID id) { ... }
sync = true は、単一のインスタンス内でキーごとの再計算を直列化します。フリート全体での保証が必要な場合は、再計算の前後に短時間の Redis ロック(TTL 付きの SETNX)を追加します。同時に書き込まれたキーがすべて同じ秒に有効期限切れにならないよう、ジッターを加えた TTL を組み合わせてください。
注意点
- Agent が、トランザクション対応ではないキャッシュマネージャーで
@Transactionalと@CachePut/@CacheEvictを組み合わせると、コミット前にキャッシュが更新されることがあります(または、キャッシュが削除された後に古いデータで再登録されることがあります)。.transactionAware()を使って構築してください - Agent がレートリミッターを
INCRとEXPIREの別々の呼び出しで実装すると、その間に障害が発生した場合、TTL のないキーが残り、呼び出し元が永久にブロックされます。単一の Lua スクリプトを使用してください - Agent がデフォルトの型付けで
java.util.だけを許可リストに入れると、BigDecimalは final ではなく型 ID を持つため、キャッシュされた価格や合計値のデシリアライズに失敗します。java.math.(およびjava.time.)も許可してください - Agent が値のシリアライズに Java シリアライゼーションを使う場合は、常に JSON(
GenericJackson2JsonRedisSerializer)を使用してください - Agent が
new GenericJackson2JsonRedisSerializer()を使うと、そのプレーンなマッパーには JSR-310 モジュールがないため、Instant/LocalDateフィールドのシリアライズに失敗します。JavaTimeModuleを使って構築してください - Agent が JPA の遅延フィールドを持つエンティティをキャッシュする場合は、エンティティではなく DTO/レスポンスオブジェクトをキャッシュしてください
- Agent が TTL を設定しない場合、必ず有効期限を設定してください。メモリは無限ではありません
- Agent が
@EnableCachingを忘れると、@Cacheableは何もせずに動作します - Agent が
null値をキャッシュする場合は、キャッシュミスの保存を避けるために.disableCachingNullValues()を使用してください - Agent がホットキーを保護しない場合は、有効期限切れ時のスタンピードを防ぐために
@Cacheable(sync = true)を使用してください - Agent がすべてのエントリに同じ TTL を設定する場合は、キーが同期した波のように有効期限切れにならないよう、ジッターを加えてください
コメント
Markdown 対応