Skip to content

Instantly share code, notes, and snippets.

@rajeshpv
Created March 18, 2026 16:00
Show Gist options
  • Select an option

  • Save rajeshpv/d6c55dd014dd59a21545cfe4a68e0ec9 to your computer and use it in GitHub Desktop.

Select an option

Save rajeshpv/d6c55dd014dd59a21545cfe4a68e0ec9 to your computer and use it in GitHub Desktop.
sping redis aka valkey cache

Out-of-Process Caching with Redis/Valkey + Spring Boot 3.x

1. Dependencies

<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>

2. Valkey vs Redis

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.

Option A: Use spring-boot-starter-data-redis (recommended today)

Works unchanged with Valkey. Simply point your config to a Valkey server.

Option B: Dedicated Valkey starter (still maturing)

<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.

3. Configuration (application.yml)

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

4. Cache Annotations Best Practices

@Cacheable — cache the return value

@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 — always execute, then update cache

@CachePut(value = "devices", key = "#device.flexNetId")
public Device updateDevice(Device device) {
    return deviceRepository.save(device);
}

@CacheEvict — remove from cache

@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 — compose multiple operations

@Caching(evict = {
    @CacheEvict(value = "devices", key = "#device.flexNetId"),
    @CacheEvict(value = "deviceReads", key = "#device.customerId + ':' + #device.flexNetId")
})
public void removeDeviceAndReads(Device device) {
    // ...
}

Best practices

  • Always set unless = "#result == null" to avoid caching nulls.
  • Use @CacheEvict on write operations to prevent stale data.
  • Avoid caching large collections; cache individual entities by ID when possible.
  • Use condition to skip caching for certain inputs (e.g., condition = "#id.length() > 3").

5. Cache-Aside Pattern Implementation

Spring's @Cacheable is cache-aside by default:

  1. Check cache for key
  2. If hit → return cached value (skip method)
  3. If miss → execute method, store result in cache, return

Manual/programmatic cache-aside (when you need more control)

@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.

6. TTL Configuration

Global default TTL

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(Duration.ofMinutes(10))
        .disableCachingNullValues();

    return RedisCacheManager.builder(connectionFactory)
        .cacheDefaults(defaults)
        .build();
}

Per-cache TTL (production pattern)

@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();
}

Dynamic per-entry TTL (Spring Data Redis 3.2+)

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);

Time-To-Idle (requires Redis/Valkey 6.2+)

RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
    .entryTtl(Duration.ofMinutes(30))
    .enableTimeToIdle();  // resets TTL on every read (uses GETEX command)

7. Serialization Approaches

JSON serialization (recommended for production)

@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();
}

For RedisTemplate (direct operations)

@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;
}

Comparison

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.

8. Connection Pooling: Lettuce vs Jedis

Lettuce (recommended, Spring Boot default)

  • 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);
}

Jedis (alternative, synchronous)

  • 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: 2000ms

Recommendation: Use Lettuce for production.

9. Complete Production Configuration

@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();
    }
}

Key Recommendations Summary

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

Sources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment