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.
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
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.
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@CacheEvictruns on method return by default; in transactional services, move it after commit withTransactionAwareCacheManagerProxyorTransactionSynchronization.afterCommit.Event-driven invalidation. If the data changes in another system (ERP, another module), eviction is triggered by the event coming from there: a
ProductUpdatedevent on Kafka → the related keys are deleted. The outbox guarantees the event isn't lost.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:
maxmemoryset andmaxmemory-policy allkeys-lru(orallkeys-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:keyformat; 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
Caching entities: Lazy associations blow up in serialization or drag the whole graph along; use DTOs.
Keys without a TTL: Stale data forever when an eviction is forgotten; every key needs a TTL.
Tenant/language missing from the key: Tenant A sees tenant B's price.
Eviction before commit: The race puts the old data back.
Bulk deletion with
KEYS *: Locks up the production Redis; use versioned namespaces.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.