Caching with Redis: TTL, Eviction, and Cache Stampede

1. Overview

Redis is a popular choice for caching data, reducing database load and speeding up response times. But using Redis isn’t as simple as just set and get — you need to understand how TTL (Time To Live) works, how Redis deletes keys when memory runs out (eviction), and most importantly: how to avoid cache stampede — the situation where hundreds of requests hit the database simultaneously when a hot key expires.

This article covers basic TTL configuration, Redis eviction policies, and battle-tested techniques to prevent stampede: mutex lock, logical expiration, and request coalescing. After reading, you’ll know how to design a cache layer that can handle high load without overwhelming the database behind it.

2. Setting up Redis with Spring Boot

First, add the dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Configure the connection:

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8

The RedisTemplate bean is used to interact with key/value pairs with custom data types:

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    template.setKeySerializer(new StringRedisSerializer());
    template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
    return template;
}

GenericJackson2JsonRedisSerializer stores objects as JSON with class information, making it easy to deserialize to the correct type when reading back — more convenient than JdkSerializationRedisSerializer, which is hard to read with redis-cli.

3. TTL — Time To Live

3.1. Setting TTL when writing a key

@Service
public class ProductCacheService {

    private final RedisTemplate<String, Object> redisTemplate;

    public ProductCacheService(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    public void cacheProduct(Long productId, Product product) {
        String key = "product:" + productId;
        redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    }
}

The line set(key, product, Duration.ofMinutes(10)) writes the key with a 10-minute TTL. After this time, Redis automatically deletes the key — called passive expiration combined with active expiration (Redis randomly scans keys with TTL every 100ms to clean up early, avoiding expired keys sitting in RAM until they’re accessed).

Check the remaining TTL:

redis-cli TTL product:123
(integer) 542

A value of -1 means the key exists but has no TTL (exists indefinitely). A value of -2 means the key doesn’t exist.

3.2. Fixed TTL vs Random TTL (jitter)

If you cache thousands of keys at once with the same TTL, they’ll all expire at the same time — causing a burst of “cache misses”. The solution is to add jitter: randomly add a small amount to the TTL.

public void cacheProductWithJitter(Long productId, Product product) {
    long baseSeconds = 600;
    long jitter = ThreadLocalRandom.current().nextLong(0, 60);
    redisTemplate.opsForValue()
        .set("product:" + productId, product, Duration.ofSeconds(baseSeconds + jitter));
}

Each key will expire at slightly different times (600–660 seconds), avoiding the “thundering herd” effect when many keys expire at once.

Note: Jitter doesn’t prevent cache stampede for a single hot key (e.g., homepage, bestselling product). That issue requires techniques covered in section 5.

4. Redis Eviction Policy When Running Out of Memory

TTL handles keys expiring over time. But when Redis reaches the maxmemory limit, it needs to evict keys according to an eviction policy, regardless of whether they have TTL or not.

Configuration in redis.conf:

maxmemory 512mb
maxmemory-policy allkeys-lru

Common policies:

Policy Meaning
noeviction Don’t evict anything, return OOM error when writing. Redis default.
allkeys-lru Evict the least recently used key (Least Recently Used), applied to all keys.
volatile-lru Same as above but only evicts keys with TTL.
allkeys-lfu Evict the key with the lowest access frequency (Least Frequently Used).
volatile-ttl Prioritize evicting keys about to expire soonest (shortest TTL).
volatile-random Randomly evict from keys with TTL.

For pure caching use cases, allkeys-lru or allkeys-lfu is the safe choice because Redis will automatically free memory instead of returning errors. If you use Redis both for caching and storing important data that shouldn’t be lost (like session tokens), separate instances or use volatile-lru to only evict keys marked with TTL.

Note: noeviction (the default) will cause your application to receive an OOM command not allowed error when Redis is full. If using Redis for caching, always set an appropriate maxmemory-policy; don’t leave it at default.

Check current configuration:

redis-cli CONFIG GET maxmemory-policy
1) "maxmemory-policy"
2) "allkeys-lru"

5. What is Cache Stampede

Cache stampede (also called the dogpile effect) happens when a hot key — accessed by hundreds or thousands of requests per second — suddenly expires. All requests miss the cache almost simultaneously, rushing into the database to rebuild the data, overwhelming the database for hundreds of milliseconds.

public Product getProduct(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }
    // Key just expired -> hundreds of threads rush in here
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
    return product;
}

With bestselling product keys, the code above is perfectly reasonable under normal circumstances, but at the exact moment TTL expires, productRepository.findById might be called hundreds of times simultaneously for the same id.

5.1. Solution: Distributed Lock (Mutex)

Allow only one request to rebuild the cache; other requests wait or use the old value.

public Product getProductSafely(Long id) {
    String key = "product:" + id;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }

    String lockKey = "lock:product:" + id;
    Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));

    if (Boolean.TRUE.equals(acquired)) {
        try {
            Product product = productRepository.findById(id)
                .orElseThrow(() -> new ProductNotFoundException(id));
            redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    } else {
        // Failed to acquire lock -> wait briefly then retry reading cache
        return waitAndRetry(key, id);
    }
}

private Product waitAndRetry(String key, Long id) {
    for (int i = 0; i < 10; i++) {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        Product cached = (Product) redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }
    }
    // Timeout expired, fallback to calling DB directly
    return productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
}

setIfAbsent is equivalent to the SET lockKey 1 NX EX 5 command in Redis — atomic, only one thread can successfully create the lock. Any thread that doesn’t acquire the lock will poll the cache every 100ms instead of calling the database itself.

5.2. Solution: Logical Expiration (No Physical TTL)

Instead of letting Redis auto-delete the key, store the logical expiration time in the value and never let the key physically disappear. When data is detected as logically expired, one thread rebuilds it in the background while other threads still return the old data immediately (stale-while-revalidate).

public record CachedValue<T>(T data, Instant logicalExpireAt) {}

public Product getProductWithLogicalTtl(Long id) {
    String key = "product:" + id;
    CachedValue<Product> cached =
        (CachedValue<Product>) redisTemplate.opsForValue().get(key);

    if (cached == null) {
        return rebuildAndCache(id, key);
    }

    if (Instant.now().isAfter(cached.logicalExpireAt())) {
        String lockKey = "lock:product:" + id;
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
        if (Boolean.TRUE.equals(acquired)) {
            // Rebuild in background, return old data immediately to current request
            CompletableFuture.runAsync(() -> {
                try {
                    rebuildAndCache(id, key);
                } finally {
                    redisTemplate.delete(lockKey);
                }
            });
        }
    }
    return cached.data(); // always return data, even if slightly stale
}

private Product rebuildAndCache(Long id, String key) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ProductNotFoundException(id));
    CachedValue<Product> value = new CachedValue<>(product, Instant.now().plusSeconds(600));
    // Physical TTL longer than logical TTL to prevent key disappearing during rebuild
    redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(15));
    return product;
}

Key insight: The physical TTL on Redis (15 minutes) is always longer than the logical TTL (10 minutes). The key never gets deleted by Redis unexpectedly — it’s only marked “stale” and rebuilt in the background. Requests never have to wait for the database, even right after data reaches logical expiration.

6. Testing Anti-Stampede Behavior

Write a test confirming only one database call when multiple threads miss the cache:

<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
class ProductCacheServiceTest {

    @Autowired
    private ProductCacheService cacheService;

    @MockBean
    private ProductRepository productRepository;

    @Test
    void onlyOneThreadAllowedToCallDatabaseWhenCacheMisses() throws InterruptedException {
        Long productId = 42L;
        Product product = new Product(productId, "Mechanical Keyboard");
        given(productRepository.findById(productId)).willReturn(Optional.of(product));

        int threadCount = 50;
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);
        CountDownLatch latch = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                cacheService.getProductSafely(productId);
                latch.countDown();
            });
        }
        latch.await(5, TimeUnit.SECONDS);
        executor.shutdown();

        verify(productRepository, times(1)).findById(productId);
    }
}
@Test
void withoutLockDatabaseIsCalledMultipleTimes() {
    Long productId = 99L;
    Product product = new Product(productId, "Mechanical Keyboard");
    given(productRepository.findById(productId)).willReturn(Optional.of(product));

    List<Product> results = IntStream.range(0, 50)
        .parallel()
        .mapToObj(i -> cacheService.getProductUnsafe(productId))
        .toList();

    assertThat(results).hasSize(50);
    verify(productRepository, atLeast(2)).findById(productId); // proof that stampede occurs
}

The first test uses getProductSafely (with lock) and expects findById to be called exactly once even though 50 threads run concurrently. The second test uses the non-lock version to illustrate the opposite — you’ll see findById called multiple times, proving that stampede actually occurs without handling.

7. Common Mistakes

  • Forgetting to set TTL: keys exist forever, Redis gradually fills up RAM, forcing you to rely entirely on eviction policy — easy to accidentally delete needed keys.
  • TTL too short for hot keys: increases cache miss frequency, makes stampede more likely. For high-traffic keys, prefer logical expiration over short physical TTL.
  • Lock without TTL: if the instance holding the lock crashes mid-way, the lock exists forever and other threads wait indefinitely. Always set TTL for locks (setIfAbsent with Duration).
  • Using noeviction for Redis cache: your application throws errors when Redis is full instead of auto-cleaning. Only reasonable if Redis stores data that shouldn’t be lost.
  • Inconsistent object serialization: changing fields in class Product without handling versioning can cause deserialization errors from old cache. You should add cache-busting (change key prefix) when changing schema.

8. Conclusion

TTL helps cache data refresh over time, but you need jitter to prevent many keys from expiring simultaneously. Eviction policy determines how Redis handles running out of memory — choose allkeys-lru/allkeys-lfu for pure caching, avoid the default noeviction. For hot keys, plain TTL isn’t enough: you need mutex lock or logical expiration to ensure only one thread rebuilds the cache, while others read stale data instead of rushing the database together. Applying these three techniques correctly, your cache layer will handle load much better than simple set/get operations.

rediscachespring-bootperformancejava