← Back to the section

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:

  1. Need data → check Redis.
  2. Cache hit: the data is there — return it, don't go to the database.
  3. 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).

diagram

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:

StrategyConsistencySpeedComplexity
TTL onlyweakmaximumminimal
TTL + @CacheEvict on writestronggoodmedium
Write-throughstronglowermedium

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.
  • Redis fundamentals: data structures and commands
  • Redis data structures: strings, hashes, sets, lists
  • Redis in Spring Boot: @Cacheable, RedisTemplate, configuration