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.
Cache-aside: the application asks Redis first and reads the database only on a miss, then puts the answer back with a limited lifetime. While the key lives, the database does no work at all; once the lifetime is over Redis drops the key and the next reader pays for the database trip again. Hence the two settings that decide everything: what goes into the cache, and for how long.
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.
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.
The mechanics are visible without Redis at all: a map instead of the cache, a timestamp instead of the key's lifetime.
live example
import java.util.HashMap;
import java.util.Map;
public class CacheAside {
record Entry(String value, long expiresAt) {}
static final Map<Long, Entry> cache = new HashMap<>();
static int dbCalls = 0;
static String getUser(long id, long now) {
Entry hit = cache.get(id);
if (hit != null && hit.expiresAt() > now) {
System.out.println(now + " ms: hit — " + hit.value());
return hit.value();
}
dbCalls++;
String value = "Smith";
cache.put(id, new Entry(value, now + 300));
System.out.println(now + " ms: miss — read the database, store the key for 300 ms");
return value;
}
public static void main(String[] args) {
for (long now : new long[] {0, 100, 250, 400, 500}) {
getUser(42, now);
}
System.out.println("database trips: " + dbCalls);
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
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).
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 stops serving the key immediately, but frees the memory later: either when the key is touched again, or through a background cycle that samples keys with a lifetime.
live example
# set a key with a 5-minute TTL
SET user:42 "..." EX 300
# check how much time is left
TTL user:42
# → 247
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
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.
live example
# delete a specific key
DEL user:42
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
It gets harder when a whole batch of keys has to go by pattern. This is where the server is easy to take down:
live example
# don't do this: KEYS walks the entire keyspace in one go
# and Redis answers nobody while it does
KEYS user:*
# do this: SCAN returns keys in portions, and the server works in between
SCAN 0 MATCH user:* COUNT 100
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
In Spring: @CacheEvict after an entity changes.
Cache stampede: a crowd after one key
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: one popular key expired, and the whole crowd of readers charges into the database after it. A close relative is the avalanche, when many different keys expire at once — for instance because they were loaded into the cache in one batch with the same lifetime. The symptom is the same, a spike of database load, but the cures differ: a stampede is stopped by a lock on the recomputation, an avalanche by a random addition to the lifetime so that keys expire out of step.
The crowd is easy to see with eight readers arriving for one expired key.
live example
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
public class Stampede {
static final AtomicInteger dbCalls = new AtomicInteger();
static String heavyQuery() {
dbCalls.incrementAndGet();
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "top products";
}
static int crowd(boolean underLock) throws InterruptedException {
Map<String, String> cache = new ConcurrentHashMap<>();
dbCalls.set(0);
Thread[] readers = new Thread[8];
for (int i = 0; i < readers.length; i++) {
readers[i] = new Thread(() -> {
if (underLock) {
cache.computeIfAbsent("popular", key -> heavyQuery());
} else if (cache.get("popular") == null) {
cache.put("popular", heavyQuery());
}
});
readers[i].start();
}
for (Thread reader : readers) {
reader.join();
}
return dbCalls.get();
}
public static void main(String[] args) throws InterruptedException {
System.out.println("eight readers arrive for one expired key");
System.out.println("everyone goes to the database: queries " + crowd(false));
System.out.println("recompute under a lock: queries " + crowd(true));
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Eight queries instead of one; with a thousand readers it will be a thousand. computeIfAbsent played the part of a lock, but an internal one: it holds back threads of a single process only. There are many processes in production, so the lock has to be shared — and Redis is the one holding it.
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
Jitter is a random spread of the lifetime: keys loaded in one batch expire out of step instead of all at once.
// 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);
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 | almost immediate, with a window | good | medium |
| Write-through | almost immediate, with a window | lower | medium |
For most tasks, cache-aside + @CacheEvict is enough: data is fresh almost right after a change, and the cache is read quickly.
The word "almost" matters here. Between the moment the key is removed from the cache and the moment the change is committed in the database there is a short window. A read that slips into it takes the old value from the database and puts it into the cache — for the whole lifetime. That is why clearing the cache is safer after the transaction commits rather than inside it: Spring can do that through the TransactionAwareCacheManagerProxy wrapper.
Full strict consistency (without any divergence window) requires transactions between Redis and the database — that's complex and rarely justified.
Cache penetration: requests for data that doesn't exist
A third problem is often confused with the stampede and the avalanche — penetration: requests arrive for data that does not exist. The cache will never fill up for such a key, so every request is guaranteed to pass straight through it into the database. The typical scenario is a bot walking product or user identifiers: a million requests for records that aren't there.
There are two defenses. The first is to cache the negative result: a "no such thing" marker is stored under the key with a short TTL, and repeat requests are turned away by the cache. The second, for larger scale, is a Bloom filter in front of the cache: a compact probabilistic structure that says "this key is definitely absent" for certain (and only sometimes wrongly says "possibly present"); requests that are known to be hopeless are turned back before the cache.
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.
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. A batch of keys is swept withSCAN, neverKEYS. - A cache stampede is a crowd after one expired hot key; it's cured with a lock during recomputation. An avalanche is many keys expiring at once; it's cured with a random addition to the lifetime.
- Penetration is a request for something the database doesn't have at all; it's cured by caching the negative answer or by a Bloom filter.
- 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 — where Redis starts.
- Redis data structures: strings, hashes, sets, lists — what to cache besides strings.
- Redis beyond caching: locks, queues, Pub/Sub — that very shared lock.
- Redis in Spring Boot:
@Cacheable,RedisTemplate— how to wire it into the application.