in

Redis Cache Strategies: Cache-Aside, Write-Through, Eviction

A cache is the fastest performance win in an enterprise application and the most common source of 'stale data' bugs. We compare cache-aside, read/write-through and write-behind, then cover the Spring Cache and Redis setup, per-cache TTLs, invalidation strategies, stampede and consistency pitfalls and Redis operational settings.

Cache-aside read and write flow between the application, the Redis cache and the database

A cache is the fastest performance win in an enterprise application: the price-list query drops from 400 ms to 2 ms, database load halves. The same cache is also the source of the most irritating bug class in production: "we updated the price but the screen shows the old one", "two servers show different stock", "Redis went down and took the application with it". Adding a cache is easy; invalidating it correctly is hard.

This article covers what to cache, the cache-aside, read/write-through and write-behind patterns, the Spring Cache and Redis setup with per-cache TTLs, invalidation strategies, stampede and consistency pitfalls, and Redis's operational settings.

What to cache, and what not to

Something is a cache candidate when three criteria hold together: it is read a lot, expensive to produce (multi-table query, external API, computation) and tolerates being stale for a while. Price lists, product catalogs, parameter tables, exchange rates, permission matrices and session data are typical candidates. Not to cache: data private to one user (it leaks if the user isn't in the key), data changing every second (stock reservations), queries that are already fast (one row by primary key) and balance/limit values where correctness is critical.

The patterns

Pattern

How it works

Consistency

Complexity

Fit

Cache-aside (lazy)

The application checks the cache first; on a miss, reads the DB and writes the cache

Depends on TTL and explicit eviction

Low

The default; any read-heavy scenario

Read-through

The cache layer reads the DB itself on a miss

Same as cache-aside

Low (Spring Cache abstracts it)

Cache-aside implemented through annotations

Write-through

Writes go to the DB and then to the cache, in the same operation

High; the cache is always current

Medium

Data read right after being written

Write-behind

Writes go to the cache; the DB is updated in delayed batches

Low; data loss if the cache goes down

High

Counters, logs, writes where loss is tolerable

Refresh-ahead

Refreshed in the background before the TTL expires

High

Medium

A few hot keys (home page, exchange rate)

For 90% of enterprise applications the right answer is: cache-aside + explicit eviction on write + TTL as a safety net. Write-behind only where data loss is accepted.

Spring Cache with Redis

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    RedisCacheManagerBuilderCustomizer caches() {
        var json = RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer());
        var defaults = RedisCacheConfiguration.defaultCacheConfig()
            .serializeValuesWith(json)
            .disableCachingNullValues()
            .prefixCacheNameWith("app:v3:");                       // version prefix: bulk invalidation when the schema changes
        return b -> b
            .cacheDefaults(defaults.entryTtl(Duration.ofMinutes(10)))
            .withCacheConfiguration("priceList",   defaults.entryTtl(Duration.ofMinutes(30)))
            .withCacheConfiguration("fxRate",      defaults.entryTtl(Duration.ofMinutes(5)))
            .withCacheConfiguration("permissions", defaults.entryTtl(Duration.ofMinutes(2)));
    }
}
@Service
public class PriceService {

    @Cacheable(cacheNames = "priceList", key = "#tenant + ':' + #listId", sync = true)   // sync: one computation per instance
    public PriceList get(String tenant, Long listId) {
        return repo.load(tenant, listId);                         // the expensive query
    }

    @Transactional
    @CacheEvict(cacheNames = "priceList", key = "#tenant + ':' + #list.id()")
    public void update(String tenant, PriceList list) {
        repo.save(tenant, list);
    }
}

Three details decide the outcome. Serialization: JDK serialization breaks when a class changes; use JSON, and make the objects you cache DTOs/records, not entities. Key design: the key includes every parameter that determines the result (tenant, language, user role); a missing parameter means a data leak or a wrong result. Version prefix: when the cache schema changes, bump the prefix v3 → v4 instead of deleting old values one by one; the old ones die by TTL.

Invalidation: four strategies, together

  1. TTL — the safety net. Every key expires; in the worst case, data is at most a TTL old. Choose the TTL by business tolerance: exchange rates 5 min, catalog 30 min, parameters 2 h. Add a small random jitter to the TTL to avoid mass expiry.

  2. Explicit eviction on write. Every path that changes the data deletes the related key (@CacheEvict). The critical detail: evict after the transaction commits; if the key is deleted before commit, another request reads the old data and repopulates the cache. Spring Cache's @CacheEvict runs on method return by default; in transactional services, move it after commit with TransactionAwareCacheManagerProxy or TransactionSynchronization.afterCommit.

  3. Event-driven invalidation. If the data changes in another system (ERP, another module), eviction is triggered by the event coming from there: a ProductUpdated event on Kafka → the related keys are deleted. The outbox guarantees the event isn't lost.

  4. Namespace versioning. For "flush the whole price-list cache", bump a namespace version number instead of deleting thousands of keys with KEYS/SCAN; new reads go to the new namespace.

Consistency and stampede pitfalls

  • The cache-aside race: Request A reads the old value from the DB, request B updates and evicts, A writes the old value into the cache → stale data for a whole TTL. Fix: eviction after commit plus a short TTL; for data with zero tolerance, don't cache.

  • Stampede (dogpile): A hot key expires, a thousand requests hit the DB at once. On one instance, sync = true; across instances, the single instance that acquires a Redis lock (SET key:lock NX EX 5) recomputes, the others wait briefly or serve the old value (stale-while-revalidate).

  • Caching nulls: Going to the DB every time for a non-existent product (cache penetration) wears the DB down; caching "absent" with a short TTL solves it, but conflicts with disableCachingNullValues — choose deliberately.

  • Local + Redis (two tiers): A Caffeine cache removes the Redis round trip too; but to invalidate the other instances' local caches, an evict message must be published over Redis pub/sub. Without it, two servers show different data.

Operating Redis as a cache

  • Memory policy: maxmemory set and maxmemory-policy allkeys-lru (or allkeys-lfu); a cache Redis should evict the least used when full, not throw errors.

  • Persistence: RDB/AOF can be off for a pure cache; but data whose loss hurts, such as sessions, must not live in the same Redis — separate instance.

  • Timeouts and fallback: Short connection and command timeouts to Redis (50–200 ms); if Redis is unreachable, the request must continue from the DB, not fail (swallow errors with a CacheErrorHandler, log, emit a metric). "Redis went down, the app went down" is this setting missing.

  • Key hygiene: app:version:cache:key format; large values (1 MB+) compressed or split; a hot key (one key everyone reads) wears down a single Redis node and needs a local tier.

  • Metrics: Hit ratio via Micrometer cache.gets (hit/miss); a ratio below 80% says either the TTL is too short or the key is wrong. On the Redis side: memory, evicted_keys and latency.

Common mistakes in the field

  1. Caching entities: Lazy associations blow up in serialization or drag the whole graph along; use DTOs.

  2. Keys without a TTL: Stale data forever when an eviction is forgotten; every key needs a TTL.

  3. Tenant/language missing from the key: Tenant A sees tenant B's price.

  4. Eviction before commit: The race puts the old data back.

  5. Bulk deletion with KEYS *: Locks up the production Redis; use versioned namespaces.

  6. Treating the cache as a correctness mechanism: A cache is an optimism; business-rule validation (limit checks, stock deduction) always goes to the DB.

Frequently asked questions

Hibernate second-level cache or Spring Cache?

They work at different levels: the L2 cache is per entity/collection and managed by Hibernate; Spring Cache stores service-method results (DTOs, computations). For most applications Spring Cache is more predictable; L2 only for bottlenecks proven by measurement.

Is cache warm-up necessary?

If a cold cache after deployment puts a sudden load on the DB, yes: the hottest few hundred keys are filled in the background at startup. In most applications a rolling deployment makes it unnecessary.

When is Redis Cluster needed?

When a single instance's memory or throughput isn't enough; Sentinel/replicas first for high availability, Cluster later for data volume. Most enterprise caches run for years on a single managed instance.

Conclusion

The right cache: cache-aside, a versioned key containing every parameter that determines the result, a TTL per cache, eviction after commit, event-driven invalidation for external changes, stampede protection and a fallback to the DB when Redis is down. These six rules keep the cache's gains from turning into "stale data" bugs.

To measure your application's performance bottlenecks and build the right caching layer, see our maintenance and support and web and e-commerce development services.