<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Required for Lettuce connection pooling -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>Valkey is a BSD-3 licensed fork of Redis 7.2.4, backed by AWS, Google, Oracle, and the Linux Foundation. It is wire-compatible with Redis — just point spring.data.redis.host to a Valkey server. No code changes needed.
Works unchanged with Valkey. Simply point your config to a Valkey server.
<dependency>
<groupId>io.valkey.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-valkey</artifactId>
<version>0.2.0</version>
</dependency>Provides ValkeyTemplate / StringValkeyTemplate and supports three drivers: Valkey GLIDE, Lettuce, and Jedis.
@Configuration
class ValkeyConfig {
@Bean
public ValkeyConnectionFactory valkeyConnectionFactory() {
return new ValkeyGlideConnectionFactory();
}
@Bean
public StringValkeyTemplate valkeyTemplate(ValkeyConnectionFactory factory) {
return new StringValkeyTemplate(factory);
}
}Recommendation: Use spring-boot-starter-data-redis pointed at Valkey for production today. Migrate to the dedicated Valkey starter when it reaches 1.0.
spring:
data:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD:}
timeout: 2000ms
lettuce:
pool:
enabled: true
max-active: 16
max-idle: 8
min-idle: 4
max-wait: 2000ms # NEVER use -1 in production
shutdown-timeout: 200ms@Service
public class DeviceService {
@Cacheable(value = "devices", key = "#flexNetId", unless = "#result == null")
public Device findByFlexNetId(String flexNetId) {
return deviceRepository.findById(flexNetId).orElse(null);
}
// Composite key using SpEL
@Cacheable(value = "deviceReads", key = "#customerId + ':' + #flexNetId")
public List<Reading> getReads(String customerId, String flexNetId) {
return readsStore.findReads(customerId, flexNetId);
}
}@CachePut(value = "devices", key = "#device.flexNetId")
public Device updateDevice(Device device) {
return deviceRepository.save(device);
}@CacheEvict(value = "devices", key = "#flexNetId")
public void deleteDevice(String flexNetId) {
deviceRepository.deleteById(flexNetId);
}
// Evict all entries in a cache on a schedule
@CacheEvict(value = "devices", allEntries = true)
@Scheduled(fixedRateString = "${cache.evict.interval:3600000}")
public void evictAllDevices() {
log.info("Evicting all device cache entries");
}@Caching(evict = {
@CacheEvict(value = "devices", key = "#device.flexNetId"),
@CacheEvict(value = "deviceReads", key = "#device.customerId + ':' + #device.flexNetId")
})
public void removeDeviceAndReads(Device device) {
// ...
}- Always set
unless = "#result == null"to avoid caching nulls. - Use
@CacheEvicton write operations to prevent stale data. - Avoid caching large collections; cache individual entities by ID when possible.
- Use
conditionto skip caching for certain inputs (e.g.,condition = "#id.length() > 3").
Spring's @Cacheable is cache-aside by default:
- Check cache for key
- If hit → return cached value (skip method)
- If miss → execute method, store result in cache, return
@Service
public class ManualCacheAsideService {
private final RedisTemplate<String, Object> redisTemplate;
private final DeviceRepository repository;
public ManualCacheAsideService(RedisTemplate<String, Object> redisTemplate,
DeviceRepository repository) {
this.redisTemplate = redisTemplate;
this.repository = repository;
}
public Device getDevice(String id) {
String cacheKey = "device:" + id;
// 1. Check cache
Device cached = (Device) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
// 2. Cache miss — load from DB
Device device = repository.findById(id).orElse(null);
// 3. Populate cache with TTL
if (device != null) {
redisTemplate.opsForValue().set(cacheKey, device, Duration.ofMinutes(30));
}
return device;
}
public Device updateDevice(Device device) {
Device saved = repository.save(device);
// Invalidate cache on write (not update — avoids race conditions)
redisTemplate.delete("device:" + device.getId());
return saved;
}
}Key principle: On writes, delete the cache entry rather than updating it. This avoids race conditions where two concurrent writes could leave stale data.
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues();
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaults)
.build();
}@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues();
Map<String, RedisCacheConfiguration> perCacheConfig = Map.of(
"devices", defaults.entryTtl(Duration.ofHours(1)),
"deviceReads", defaults.entryTtl(Duration.ofMinutes(5)),
"referenceData", defaults.entryTtl(Duration.ofHours(24))
);
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaults)
.withInitialCacheConfigurations(perCacheConfig)
.enableStatistics()
.build();
}enum AdaptiveTtlFunction implements TtlFunction {
INSTANCE;
@Override
public Duration getTimeToLive(Object key, @Nullable Object value) {
if (value instanceof Device d && d.isFrequentlyAccessed()) {
return Duration.ofHours(2);
}
return Duration.ofMinutes(15);
}
}
// Usage:
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(AdaptiveTtlFunction.INSTANCE);RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.enableTimeToIdle(); // resets TTL on every read (uses GETEX command)@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper) {
// Clone the Spring-managed ObjectMapper and add type info
ObjectMapper cacheMapper = objectMapper.copy()
.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY);
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer(cacheMapper);
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(jsonSerializer));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(config)
.build();
}@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
template.afterPropertiesSet();
return template;
}| Aspect | JdkSerializationRedisSerializer | GenericJackson2JsonRedisSerializer |
|---|---|---|
| Size | ~5x larger than JSON | Compact JSON |
| Readability | Binary, opaque in redis-cli | Human-readable JSON |
| Type info | Embedded via Java serialization | Requires @class property or type hints |
| Cross-language | Java only | Any language can read |
| Class evolution | Fragile (serialVersionUID) | More tolerant of field additions |
| Speed | Slightly faster serialization | Slightly slower, but smaller payloads |
Recommendation: Use GenericJackson2JsonRedisSerializer for production.
- Non-blocking, Netty-based, reactive-capable.
- Single connection shared across threads by default — efficient for most workloads.
- Enable pooling for high-throughput scenarios (requires
commons-pool2).
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.useSsl()
.and()
.commandTimeout(Duration.ofSeconds(2))
.shutdownTimeout(Duration.ofMillis(200))
.build();
RedisStandaloneConfiguration serverConfig =
new RedisStandaloneConfiguration("redis.example.com", 6380);
serverConfig.setPassword("secret");
return new LettuceConnectionFactory(serverConfig, clientConfig);
}- Blocking, one-thread-per-connection model.
- Requires connection pooling for any concurrent use.
spring:
data:
redis:
client-type: jedis
jedis:
pool:
max-active: 50
max-idle: 25
min-idle: 10
max-wait: 2000msRecommendation: Use Lettuce for production.
@Configuration
@EnableCaching
public class RedisCacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper) {
ObjectMapper cacheMapper = objectMapper.copy()
.activateDefaultTyping(
objectMapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY);
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer(cacheMapper);
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(jsonSerializer));
Map<String, RedisCacheConfiguration> perCacheConfig = Map.of(
"devices", defaults.entryTtl(Duration.ofHours(1)),
"deviceReads", defaults.entryTtl(Duration.ofMinutes(5)),
"referenceData", defaults.entryTtl(Duration.ofHours(24))
);
return RedisCacheManager
.builder(RedisCacheWriter.nonLockingRedisCacheWriter(
connectionFactory, BatchStrategies.scan(1000)))
.cacheDefaults(defaults)
.withInitialCacheConfigurations(perCacheConfig)
.enableStatistics()
.transactionAware()
.build();
}
}| Area | Recommendation |
|---|---|
| Client | Lettuce (default, non-blocking, Netty-based) over Jedis |
| Serialization | JSON (GenericJackson2JsonRedisSerializer) — readable, compact, cross-language |
| Cache-aside | Spring's @Cacheable IS cache-aside; on writes delete cache (don't update) to avoid races |
| TTL | Per-cache TTLs via withInitialCacheConfigurations; dynamic TTL via TtlFunction (Spring Data Redis 3.2+) |
| Metrics | enableStatistics() feeds hit/miss rates into Micrometer → Datadog |
| Evict all | Use BatchStrategies.scan(1000) to avoid blocking KEYS * |
| Valkey | Wire-compatible with Redis; use spring-boot-starter-data-redis pointed at Valkey |