Redis is used first and foremost as a cache: it keeps hot data in memory so the application doesn't hit the database on every request. But "install Redis and cache everything" is not a strategy. Different data calls for different approaches to reading, writing and expiration.
Why you need a cache at all
Without a cache, every request to a popular page or a frequently read record means a request to the database. A database can handle thousands of requests per second — but not tens of thousands of identical ones. A cache takes on the repeated reads: the database rests, and the response comes back faster.
Short rule: a cache is effective where the same data is read often and changed rarely.
Examples: a user profile, a product catalog, the result of an expensive SQL query, application configuration.
Cache-aside (lazy caching)
Cache-aside is the most common pattern. The application manages the cache itself:
- Need data → check Redis.
- Cache hit: the data is there — return it, don't go to the database.
- Cache miss: the data is missing → go to the database, put the result in Redis, return it to the client.
Client → App → Redis: GET user:42
← (nil) — miss
App → PostgreSQL: SELECT * FROM users WHERE id = 42
← data
App → Redis: SET user:42 <data> EX 300
App → Client: response
The upside is simplicity: Redis doesn't know about the database, the database doesn't know about Redis. Data enters the cache only on real demand.
The downside is the cold start: after a Redis restart or the first load, all requests go to the database until the cache warms up.
Cache-aside in Spring Boot
The most convenient way is the @Cacheable annotation from Spring Cache:
@Cacheable(value = "users", key = "#id")
public UserDto getUser(long id) {
return userRepository.findById(id)
.map(userMapper::toDto)
.orElseThrow();
}
On a miss, Spring calls the method itself and puts the result in Redis. On a repeat call with the same id, the method is not executed — the cached value is returned.
To evict a single record, use @CacheEvict:
@CacheEvict(value = "users", key = "#id")
public void updateUser(long id, UserUpdateRequest req) {
// update in the database; the cache entry will be removed
}
Write-through
With write-through, the write goes first to Redis and then synchronously to the database (or the other way around — but both stores are always updated in one logical transaction).
Client → App → Redis: SET user:42 <new data>
App → PostgreSQL: UPDATE users ...
App → Client: OK
The upside is that the cache is always fresh, with no misses after a write.
The downside is that writes are slower: you have to wait for both stores. This fits cases where consistency matters and the write volume is low.
Write-behind (write-back)
With write-behind, the write goes to Redis first and to the database asynchronously, with a delay. The application gets a fast response; a background process flushes the accumulated changes to the database in batches.
The upside is maximum write speed.
The downsides are a more complex implementation and the risk of data loss if Redis crashes before the flush to the database. It's used rarely, mostly for counters, event queues and metrics, where a small loss is acceptable.
TTL and expiration
TTL (Time To Live) is a key's lifetime. Once it elapses, Redis removes the key automatically.
# set a key with a 5-minute TTL
SET user:42 "..." EX 300
# check how much time is left
TTL user:42
# → 247
TTL solves two problems: not keeping stale data forever and freeing memory from unneeded keys.
How to choose a TTL
- Data changes rarely (configuration, reference data) — TTL from 10 minutes to several hours.
- Data changes often (cart, session) — TTL of 1-5 minutes or an explicit eviction via
@CacheEvict. - Real-time data (balance, order status) — be careful with caching, or use a very short TTL.
Invalidation
Invalidation is the explicit removal of a key before its TTL elapses, when the data has changed. It's more reliable than waiting for expiration.
# delete a specific key
DEL user:42
# delete by pattern (careful on large databases — it blocks Redis)
# better to use SCAN + DEL in a loop
SCAN 0 MATCH user:* COUNT 100
In Spring: @CacheEvict after an entity changes.
Cache Stampede — an avalanche of misses
Imagine: the cache holds the result of a heavy query (1 second against the database). A million users read it every minute. The TTL expires — and a thousand requests arrive at the application at once, see a miss and rush to the database. The database collapses under the load.
That is a cache stampede (an avalanche of misses on a hot key).
Protection: a lock on a miss
On a miss, one thread takes a lock (SETNX or Redisson RLock) and recomputes the value. The rest wait or return a slightly stale value.
// Redisson — a simple option with a lock
RLock lock = redissonClient.getLock("lock:popular:result");
if (lock.tryLock(100, 5000, TimeUnit.MILLISECONDS)) {
try {
// re-check the cache under the lock — maybe it's already filled
String cached = redisTemplate.opsForValue().get("popular:result");
if (cached != null) return cached;
String result = expensiveQuery();
redisTemplate.opsForValue().set("popular:result", result, 5, TimeUnit.MINUTES);
return result;
} finally {
lock.unlock();
}
}
// if the lock wasn't acquired — return a stale value or wait
Protection: jitter in the TTL
If a thousand keys with the same TTL expire simultaneously, you get a thousand simultaneous misses. Jitter (a random spread) scatters them across time:
// base TTL of 5 minutes + a random shift of up to 60 seconds
long ttl = 300 + ThreadLocalRandom.current().nextLong(60);
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS);
A simple and effective defense against a "simultaneous explosion" of keys.
Consistency between the cache and the database
A cache is a copy of data from the database. Copies drift apart. The question isn't "do they diverge" — it's "how long a divergence is acceptable".
Three levels:
| Strategy | Consistency | Speed | Complexity |
|---|---|---|---|
| TTL only | weak | maximum | minimal |
TTL + @CacheEvict on write | strong | good | medium |
| Write-through | strong | lower | medium |
For most tasks, cache-aside + @CacheEvict is enough: data is fresh right after a change, and the cache is read quickly.
Full strict consistency (without any divergence window) requires transactions between Redis and the database — that's complex and rarely justified.
What to cache and what not to
Worth caching:
- Rarely changing reference data (categories, settings).
- Results of expensive queries (aggregations, JOINs across several tables).
- User profiles that are read thousands of times between updates.
- HTML fragments or whole JSON responses, if the content is the same for everyone.
Not worth caching:
- Data critical to real-time accuracy (a financial balance, medical readings).
- Data unique to each user when there are many users — the cache won't fill up in time and will consume a lot of memory.
- Small queries the database already returns in fractions of a millisecond — the overhead of Redis will exceed the gain.
In short
- Cache-aside is the core pattern: check Redis, and on a miss go to the database and put the result in the cache.
- Write-through writes to both stores at once; freshness is guaranteed, but writes are slower.
- Write-behind writes asynchronously; it's fast, but risks loss on a failure.
- TTL sets a key's lifetime; invalidation (
DEL/@CacheEvict) clears it explicitly when the data changes. - Cache stampede is an avalanche of misses on an expired hot key; it's cured with a lock during recomputation and jitter in the TTL.
- A cache doesn't replace transactions — data in Redis and the database always diverge for at least a moment; choose a strategy to match the acceptable divergence window.
What to read next
- Redis fundamentals: data structures and commands
- Redis data structures: strings, hashes, sets, lists
- Redis in Spring Boot:
@Cacheable,RedisTemplate, configuration