Spring Boot Did Not Leak Memory. Redis Had No Exit

Spring Boot Redis cache memory growth incident dashboard

Redis is memory.

That sounds obvious, but it is easy to forget when Redis enters a Spring Boot system as “just a cache.”

In this incident, the application looked like it had a JVM memory leak. Memory graphs kept moving in the wrong direction. GC became noisier. Latency started to wobble. A few pods restarted under pressure.

The first theory was Spring Boot. Maybe an object graph stayed reachable. Maybe a static map kept growing. Maybe a thread-local value was not cleared. Maybe serialization was creating hidden heap pressure.

Those were reasonable guesses.

But the heap did not behave like a classic leak. The real growth was happening outside the JVM. Redis memory kept increasing because the cache had no exit strategy.

The Problem Looked Like a Spring Boot Memory Leak

The symptom started slowly.

At first, the service was faster after adding Redis. Database calls dropped. Common lookups became cheaper. Response time improved enough that the cache looked like an easy win.

Then the slow failure began.

Key count kept increasing. Redis used memory kept increasing. Many keys had no TTL. There was no meaningful memory boundary. There was no clear eviction policy. There was no namespace-level dashboard showing which cache group was responsible.

Each cached value was small, so each individual write looked harmless. That is exactly why the failure was easy to miss.

Small values become expensive when key count grows without limit.

What the Service Was Caching

The application cached simple string values in Redis. Nothing dramatic. No large files, no binary payloads, no complex object graph.

One common example looked like this:

@Cacheable(value = "product-name", key = "#productId")
public String getProductName(Long productId) {
    return productRepository.findNameById(productId);
}

This tells Spring Cache to store the result in the product-name cache. If the same productId is requested again, the application can read from Redis instead of querying the database.

That part was fine.

The missing part was ownership of the cache lifecycle.

The Real Root Cause

Redis was not broken. Spring Boot was not broken either.

Redis was doing exactly what the application asked it to do:

  • The application stored keys.
  • The application did not give those keys an expiration.
  • Redis kept them.

The implementation behaved like permanent storage, even though the team thought of it as a temporary cache. That mismatch was the bug.

A cache needs a lifecycle. It needs to know when to forget.

Why a Cache Needs an Exit Strategy

Before adding Redis cache to a production service, the team should be able to answer these questions:

  • How long can this data live?
  • How stale can it be?
  • How many keys can this cache create?
  • Is the key based on user input or other high-cardinality parameters?
  • What happens when Redis memory is full?
  • Which keys should be removed first?
  • How do we know the cache is still worth its memory cost?
  • Who owns cache cleanup?

If those questions do not have answers, the cache policy is incomplete.

A cache without TTL or eviction is not really a cache. It is a growing storage system.

Example Spring Boot Setup

A typical setup uses Spring Cache with 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>

And a basic Redis connection may look like this:

spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.cache.type=redis

This connects Spring Boot to Redis. It does not define a safe cache policy.

Fix 1: Add TTL Based on Data Type

The safest first fix was to define TTLs per cache group instead of treating all cached data the same.

@Bean
RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
    RedisCacheConfiguration defaultConfig =
        RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30));

    Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();

    cacheConfigs.put(
        "product-name",
        defaultConfig.entryTtl(Duration.ofHours(1))
    );

    cacheConfigs.put(
        "search-result",
        defaultConfig.entryTtl(Duration.ofMinutes(5))
    );

    return RedisCacheManager.builder(connectionFactory)
        .cacheDefaults(defaultConfig)
        .withInitialCacheConfigurations(cacheConfigs)
        .build();
}

Product names can usually live longer than search results. Search results often depend on filters, user context, stock, permissions, or ranking. They need shorter TTLs and stricter key design.

Fix 2: Set a Redis Memory Boundary

TTL alone is not enough. Redis also needs a memory boundary.

maxmemory 2gb
maxmemory-policy allkeys-lru

maxmemory turns unlimited growth into a managed failure mode. Without it, Redis can quietly expand until the platform, node, or managed Redis plan becomes the real limit.

The eviction policy matters. allkeys-lru allows Redis to evict less recently used keys across the keyspace. volatile-lru only evicts keys with TTLs, which is dangerous when many old keys have no expiration.

Fix 3: Split Cache Namespaces

The team also needed clearer cache namespaces. A single Redis instance can hold many unrelated key families, but each group needs ownership and visibility.

product-name::123
search-result::tenant-42::user-99::redis
pricing-rule::region-us::sku-123
feature-flag::tenant-42

Once keys were grouped by purpose, it became easier to see which namespace was growing and whether the memory cost was justified.

Fix 4: Monitor Key Growth Against Hit Rate

One metric became more useful than the usual cache hit rate alone:

Key growth vs cache hit rate

If keys are growing quickly but hit rate is barely improving, the cache is probably a memory tax.

Day 1:
Keys: 100,000
Hit rate: 75%
Memory: 500 MB

Day 2:
Keys: 800,000
Hit rate: 76%
Memory: 3 GB

The cache is using much more memory, but the hit rate barely moved. That is a warning sign. The team should remove, shorten, or redesign that cache group.

Redis Inspection Commands

During the investigation, these Redis checks helped separate JVM symptoms from Redis growth:

redis-cli INFO memory
redis-cli INFO keyspace
redis-cli --scan --pattern "product-name::*" | head
redis-cli TTL "product-name::123"

In production, avoid KEYS * on large datasets because it can block Redis. Prefer SCAN when inspecting key patterns.

Watch Out for RedisTemplate

Not all Redis writes go through Spring Cache.

A service may also contain direct Redis writes:

redisTemplate.opsForValue().set(key, value);

That writes a key without TTL unless the developer provides one.

redisTemplate.opsForValue().set(
    key,
    value,
    Duration.ofMinutes(10)
);

This detail matters. A team can configure Spring Cache TTL correctly and still create permanent Redis keys from custom RedisTemplate code.

Example Alert Conditions

The incident became easier to prevent once the alerts focused on growth behavior, not only outages.

Redis used memory > 80% of maxmemory
Redis key count grows more than 30% in 1 hour
Evictions increase quickly
Expired keys stay near zero
Cache hit rate drops below the expected threshold
Many sampled keys return TTL = -1

The exact thresholds depend on the system. The important part is to detect memory growth before Redis is completely full.

Security and Production Notes

Cache key design is not only a performance concern.

User permissions, tokens, personal data, tenant-specific results, and customer-specific responses need clear TTL and invalidation rules. A stale permission cache can become a security issue. A user-specific response with a weak key can leak data across users.

This key is risky if the result depends on user, tenant, role, or locale:

@Cacheable(value = "search-result", key = "#query")

A safer key includes the dimensions that affect the result:

@Cacheable(
    value = "search-result",
    key = "#tenantId + ':' + #userId + ':' + #query"
)

But that also increases cardinality. Security and memory cost both matter.

What Changed After the Fix

After adding cache boundaries, the behavior changed:

  • Redis memory stopped growing without limit.
  • Cache keys expired based on clear rules.
  • Redis had a max memory safety boundary.
  • Old or less useful keys could be evicted.
  • Teams could see which cache namespace was growing.
  • Cache hit rate could be compared with memory cost.
  • Spring Boot became more stable because Redis was no longer silently expanding.

The biggest improvement was not only memory control. The bigger improvement was that the cache became understandable.

Internal Reading

If you are debugging Redis behavior in Spring Boot, these related Bytz Echo articles extend the same theme:

Conclusion

A cache without TTL or eviction is not really a cache. It is a growing storage system.

Spring Boot was blamed first because the service was unstable, GC was busier, and pods restarted. But the real issue was Redis remembering everything.

The fix was to treat Redis cache as a bounded system: add TTLs, set maxmemory, choose an eviction policy, monitor key growth, and compare cache value against memory cost.

A good cache should make the system lighter. It should not become invisible production debt.

Before adding a cache, ask one simple question:

How does this data leave the cache?

If there is no answer, the cache is not ready yet.