The problem

A product page reads the same catalogue row thousands of times for each time it changes. Every view runs a query, and the database becomes the bottleneck at peak traffic. A cache stores the result of expensive work closer to the caller so repeated reads skip that work. It is a trade: you gain speed and lower load, and you accept that some readers may see data that is slightly out of date, plus a new component that can fail.

Before adding one, check the cheaper options. Is the query using a proper index? Is an ORM issuing N+1 selects? A cache over a bad query hides a defect and adds complexity.

Cache-aside

The application owns the logic. On a read it looks in the cache; on a miss it loads from the database, stores the value with a TTL and returns it. On a write it updates the database and removes (or updates) the cache entry.

public Product get(long id) {    String key = "product:v1:" + id;    Product cached = redis.get(key, Product.class);    if (cached != null) return cached;    Product p = repository.findById(id).orElseThrow();    redis.set(key, p, Duration.ofMinutes(10));    return p;}@Transactionalpublic void update(Product p) {    repository.save(p);    redis.delete("product:v1:" + p.getId());}

Cache-aside is the most common pattern because it is simple and the cache can be empty or down without the system losing correctness. Spring's @Cacheable and @CacheEvict implement the same idea declaratively.

Read-through and write-through

In read-through, the application talks only to the cache layer, and the cache itself loads missing values from the source. The calling code is simpler, but you need a cache component that supports loaders.

In write-through, every write goes to the cache, which synchronously writes to the database. Reads of recently written data hit the cache, and the two stay consistent, but each write pays for both stores and you cache data that may never be read. Write-behind (write-back) acknowledges after the cache write and persists later; it is fast but risks losing data if the cache fails before flushing, so it needs careful thought.

TTL and invalidation

A TTL (time to live) is a safety net: even if an invalidation is missed, stale data eventually disappears. Choose it from the business tolerance for staleness, not from habit. A price shown at checkout needs a strict policy; a "popular articles" list can be minutes old. Add a small random jitter to TTLs so that many keys created together do not expire together.

Invalidation is hard because there are two writes (database and cache) with no shared transaction. Deleting the entry after the commit is the usual choice. A failure between the commit and the delete leaves a stale entry until the TTL expires, which is why a TTL stays mandatory. Two further subtleties: delete rather than set on update, because concurrent updates can write an older value last; and do the delete after the transaction commits, not inside it, otherwise a concurrent reader can repopulate the old value.

Versioned keys (product:v1:...) let you invalidate everything on a format change by bumping the version.

Stampedes

When a hot key expires, hundreds of requests miss simultaneously and all hit the database to rebuild it. This is a cache stampede. Mitigations:

  1. Single flight: only one caller rebuilds the value while others wait or reuse the result. In one process this is a lock per key; across instances a short Redis lock (SET key value NX PX ...) works, with a timeout so a crashed rebuilder does not block others.
  2. Serve stale while revalidating: keep a logical expiry inside the value, return the old value and refresh in the background.
  3. TTL jitter, as above, and pre-warming known hot keys.
  4. Negative caching: cache "not found" briefly, so requests for missing ids do not reach the database every time.

What not to cache

  • Data that must be exactly current: account balances used for authorisation, stock counts at the moment of purchase.
  • Per-user data under a shared key, which leaks one user's data to another. Include the user or tenant in the key when the value differs by user.
  • Cheap values. If the database answers from an indexed lookup in a fraction of the cost of a network hop to Redis, the cache gains nothing.
  • Large objects that evict many small hot ones.
  • Anything whose loss would hurt: a cache is not a database. Treat its contents as disposable.

Redis structures in brief

  • String: a serialized object or a counter (INCR is atomic).
  • Hash: a record with fields you update individually.
  • Set / Sorted set: membership, leaderboards, time-ordered indexes (ZADD, ZRANGE).
  • List: simple queues.
  • Key expiry: EXPIRE/PX on any key. Set a memory limit and an eviction policy (for example allkeys-lru) so the cache degrades by evicting, not by failing writes.

Common mistakes

  • Treating the cache as the source of truth.
  • No TTL, so wrong data lives until someone notices.
  • Caching entities with lazy associations and getting serialization surprises. Cache DTOs.
  • Not monitoring hit ratio. A cache with a low hit ratio costs more than it saves.
  • Letting a cache outage take the service down; use timeouts and fall back to the database, protecting it with limits.

Trade-offs

Every layer you add (browser, CDN, application, Redis) multiplies the places where stale data can hide. Keep the number of layers small and define for each data type the maximum staleness you accept. Local in-process caches are the fastest but differ between instances; a shared Redis cache is consistent across instances but adds a network hop and an operational dependency.

Measuring and controlling

The value of a cache only shows once you measure it. Track at least three indicators: the hit ratio, the latency of responses served from the cache and from the source, and the eviction count. A low hit ratio means the keys are too unique or the TTL is too short; many evictions mean the cache does not have enough memory. Justify every cache decision with these numbers, not with intuition.

After a change, write a simple test: write, read, write again, read again, and assert that the second read returns the new value. This test catches forgotten invalidation, which is the most common kind of cache bug.

A small rule

Before adding a cache, write one sentence: "This data may be at most N minutes stale, because ...". If you cannot complete the sentence, you are not ready to cache it. If you can, that sentence is your TTL and your invalidation policy.

Checklist

  • The slow query was already examined and indexed.
  • Each cached value has a TTL and a stated staleness tolerance.
  • Writes invalidate after commit.
  • Hot keys have stampede protection.
  • User-specific data has the user in the key.
  • Hit ratio and latency are observed.

Where to go next

Start with SQL indexes and the N+1 problem before caching. If cached responses protect an operation against repeats, compare with idempotency patterns. To explore system design more widely, see the senior backend roadmap.