Caching Patterns
A cache is a faster, smaller, possibly stale copy of something expensive. This page is about everything that goes wrong after you add one: which pattern to pick, how to invalidate, the read-miss race, stampedes, missing keys, mass expiry, hot keys, HTTP and CDN caching, how to size and measure it, and how to survive it being down. It ends with a small plain-Java cache you can run to watch a stampede happen and be stopped.
What to Cache, and What Never To
A cache is a faster, smaller, possibly stale copy of data that is expensive to produce or fetch. It earns its place in two ways: it lowers latency (the answer is nearby) and it lowers load on the source of truth (the database sees only misses). It costs you three things: staleness, memory, and a new failure mode (the cache itself, plus the code that keeps it correct). Phil Karlton's joke about cache invalidation being one of the two hard problems in computer science is the most quoted line in the field because it is true.
| Good candidates | Why | Bad candidates | Why |
|---|---|---|---|
| Read-heavy data that changes rarely: catalogues, configuration, reference data | High reuse, cheap to be a little stale | Data that must be exactly current: account balances, inventory used to accept an order, payment state | A stale read causes a wrong business decision. Read the source of truth |
| Expensive computations: aggregates, rendered fragments, ML scores, permission sets | Producing it costs far more than storing it | Write-heavy data with little re-reading | Every write invalidates; you pay maintenance and get no hits |
| Responses from slow or rate-limited external APIs | Saves latency, money and quota | Per-request unique results (search with free text, one-off reports) | Hit ratio near zero; memory wasted |
| Hot keys with skewed access (few items get most reads) | A small cache captures most traffic | Very large objects that rarely repeat | Evict useful small items, cost bandwidth |
| Idempotent, deterministic lookups | Same input, same output | Anything personalised where the key omits the user or tenant | You serve one user's data to another (a security incident, not a bug) |
- The key must contain every input that changes the answer: tenant, user or role, locale, currency, API version, feature flags. A cached
GET /dashboardkeyed only by URL shows the first user's data to everyone. Symptom: "customer A saw customer B's name". Include the dimension in the key, or do not cache that response.
- Fix the slow query (an index, a missing join condition, an N+1) and measure again. A cache over a slow query hides the problem and moves it to the first request after every expiry. See Spring Data JPA and the SQL pages for query tuning.
Cache-Aside, Read-Through, Write-Through, Write-Behind, Refresh-Ahead
Five names cover almost every design. They differ in who loads on a miss and when writes reach the database. The Spring annotation side (@Cacheable, @CachePut, @CacheEvict) is in Spring: Caching and Resilience; this page is about the behaviour behind the annotations.
| Pattern | Read path | Write path | Staleness window | Main risk |
|---|---|---|---|---|
| Cache-aside (lazy loading) | App: get; on miss load from DB, put in cache | Write DB, then delete (or update) the cache entry | Until the next write invalidates, or TTL | Read/write races leave stale values (chapter 4); stampede on a cold hot key (chapter 5) |
| Read-through | Cache loads from DB on a miss through a configured loader | Same as cache-aside unless combined with write-through | Same | Loader failure mode is hidden inside the cache; one place to get it right |
| Write-through | Reads hit a cache that is always current | Write goes to the cache, which writes to the DB synchronously before acknowledging | Near zero for keys written through it | Slower writes; cache holds data that may never be read; DB failure fails the write |
| Write-behind (write-back) | Reads hit the cache | Write goes to the cache and is acknowledged; the DB write happens later, batched | The DB is stale until flush | Data loss if the cache node dies before flush; ordering and failure handling are your job |
| Refresh-ahead | Hot entries are reloaded in the background before they expire | n/a (a read-side technique) | Bounded by the refresh interval | Wasted loads for keys nobody asks for; refresh storms |
| Pattern | What breaks | What you see | Mitigation |
|---|---|---|---|
| Cache-aside | Cache down | Latency up, database load up by 1/(1-hit ratio) | Timeouts, circuit breaker, load shedding (chapter 10) |
| Cache-aside | Update the DB but the cache delete fails | Stale value until TTL | Retry the delete, TTL as a safety net, event-driven invalidation (chapter 3) |
| Read-through | Loader throws or hangs | Every caller on that key waits or fails | Do not cache failures, timeout the loader, serve stale-on-error |
| Write-through | DB write fails after the cache updated (or the reverse) | Cache and DB disagree | Write the DB first, update the cache on success; or invalidate instead of update |
| Write-behind | Cache node crashes with unflushed writes | Acknowledged writes are gone | Persist the queue (a log or stream), replicate, limit batch size; use only for data you can afford to lose |
| Write-behind | DB is down for a while | Queue grows without bound | Bounded queue, backpressure, alert on queue age |
| Refresh-ahead | Predicts the wrong keys | Load on the DB for entries nobody reads | Refresh only keys read recently (Caffeine refreshAfterWrite refreshes on access) |
- It is attractive because writes are fast. It is dangerous because you have acknowledged a write that exists only in memory. If you need it for throughput (counters, metrics, view counts), say out loud what you accept to lose and put a durable log (a Kafka topic or a Redis stream with persistence, see Redis) between the cache and the database.
TTL, Explicit Deletes, Events and Versioned Keys
Invalidation is the act of making sure a cache stops serving a value that is no longer true. There are four tools. Real systems use a TTL as a safety net plus one precise method on top.
| Strategy | How | Staleness bound | Cost / catch |
|---|---|---|---|
| TTL only | SET key value EX 300 | At most the TTL | Simplest. Chooses a staleness number for the whole data class. A short TTL means more misses; a long one means longer stale reads |
| Explicit delete on write | After the DB commit: DEL key (or cache.invalidate(key)) | Near zero, if the delete runs and is not raced (chapter 4) | Every writer must know every cache key derived from its data. Derived and aggregate keys are easy to forget |
| Event-driven | The write publishes a change event (outbox or CDC); consumers delete or refresh keys | Event latency plus processing | Decouples writers from caches, scales to many caches and services. Needs at-least-once handling, idempotent invalidation, and ordering care. See Event-driven patterns |
| Versioned keys | Put a version in the key: product:42:v17, or a namespace version shop:v3:...; bump the version instead of deleting | Immediate for readers that read the new version | Old versions linger until TTL or eviction (memory). Needs a cheap place to read the current version |
Event-driven invalidation is how you keep many caches (several services, local L1 caches on every instance) correct without the writer knowing about them. The reliable form writes the change and an outbox row in the same database transaction, a relay publishes the event, and each cache owner consumes it and invalidates. Because delivery is at least once, invalidating twice must be harmless (deleting a key twice is). See Messaging fundamentals for the delivery guarantees and Kafka Architecture for a log that can carry the events.
Versioned keys are the cheapest way to invalidate a whole class at once. If the value format changes in a deployment, or a bulk import rewrites a table, bump shop:v3 to shop:v4: every old key becomes unreachable instead of being found and deleted one by one, and the TTL or the eviction policy reclaims the memory. The same idea gives you safe rolling deployments: instances running the old and the new code use different prefixes and never read each other's incompatible values.
- Delete is safer: the next reader loads the truth. Update (write the new value into the cache) saves a miss but is vulnerable to two writers finishing in a different order than they started (chapter 4), and to writing a value that was valid only inside a transaction that later rolled back. Delete after commit by default.
Races Between Readers, Writers and the Cache
A cache and a database are two stores updated by separate calls, so there is no moment where both change atomically. The worst symptom is stale data that stays stale until the TTL. The diagram shows the most common sequence in cache-aside with delete-on-write.
Why it happens: step 2 reads the old value, the whole write (steps 3 and 4) completes, and only then does step 5 write the old value into the cache. It needs a slow reader and a fast writer, which is rare per request but certain at enough traffic. What you can do, in order of cost:
| Technique | How it helps | Limits |
|---|---|---|
| Short TTL as a safety net | Bounds how long a stale value survives | The inconsistency window is the TTL; some data cannot accept it |
| Delete after commit, not before | Deleting before the DB commit lets a reader reload the old row and cache it | Does not fix the diagram above |
| Delayed double delete | Delete after the write, then delete again after a short delay longer than a typical read-and-store (hundreds of milliseconds) | A heuristic: the delay is a guess, and the second delete needs a retry-safe scheduler |
| Versioned values with compare-and-set | Store the row version with the value; the loader writes only if its version is newer than what is in the cache (a script in Redis, a conditional put locally) | Needs a version column and an atomic conditional write in the cache |
| Event-driven invalidation from the database log | A CDC stream emits changes in commit order; the consumer deletes the key after the change is durable, and retries | Eventually consistent by design; more moving parts (chapter 3) |
| Short leases on loading | A reader takes a lease token on a miss; a delete invalidates the token so the late SET is rejected (the approach used in large Memcached deployments) | Needs cache support for leases or a Lua script |
| Do not cache this | Read from the source of truth | Costs the latency you wanted to save |
A second race: two writers, update-on-write. Writer 1 sets the DB to 1, writer 2 sets it to 2, writer 2 writes the cache (2), then writer 1 writes the cache (1): cache says 1, DB says 2. This is why delete-on-write is the default advice. If you do update the cache, include a version and make the cache write conditional.
- A lock held by the application process does not cover another instance, or a replica read that is lagging. If staleness matters that much, prefer a version check or the database as the only reader for that path.
Thundering Herds and How to Stop Them
A cache stampede (thundering herd, dog-piling) happens when a popular entry expires or is evicted and many requests miss at the same instant. Each one runs the expensive loader, so the database receives a burst of identical queries at exactly the moment the cache stopped protecting it. The burst makes the database slower, which makes the loaders slower, which keeps more requests in flight: a feedback loop that can take the database down.
| Defence | Idea | Cost / catch |
|---|---|---|
| Single-flight (request coalescing) | Per key, the first miss becomes the loader; concurrent misses wait for its future. Caffeine's LoadingCache.get does this per key inside one JVM. Real result in chapter 11: 100 callers, 1 load | Per process only. With 50 app instances you still get 50 loads, which is usually fine |
| Distributed lock / lease | Cross-process single-flight: on a miss, SET lock:key token NX PX 5000; the winner loads, the losers briefly retry or serve stale. See Redis, distributed lock | Extra round trips, a TTL to tune, and a lock-service failure mode. An efficiency lock is fine here |
| Jittered TTL | Add a random amount to each TTL (3600 + random(0..360)) so keys loaded together do not expire together | Spreads expiries; does not help one hot key |
| Probabilistic early expiration | Each reader may decide to refresh a little before expiry, with a probability that rises as expiry nears. The XFetch rule from the VLDB 2015 paper "Optimal Probabilistic Cache Stampede Prevention": recompute when now - delta * beta * ln(rand()) >= expiry, where delta is how long the last recomputation took and beta (default 1) tunes eagerness | You must store delta and the expiry with the value. Wastes a few recomputations to avoid a herd |
| Stale-while-revalidate | Keep serving the expired value while one request refreshes it in the background. Caffeine: refreshAfterWrite. HTTP: stale-while-revalidate | Readers see slightly old data for one refresh time |
| Background refresh / pre-warming | A scheduled job reloads known-hot keys before they expire | Only works for a known hot set |
| Protect the source | Rate-limit or bulkhead the loader so the database never sees an unbounded burst | Callers beyond the limit get errors or stale data |
Fifty threads asked for the same cold key and the database was called once. The second line shows negative caching in action (chapter 6): the missing id was loaded once and the empty answer was cached for the other two lookups. The statistics line is Caffeine's own recordStats() data.
- Suppose a warm-up job loads 1,000,000 keys in about 5 minutes, each with a 1-hour TTL. Without jitter, they expire over 5 minutes in the same order: a wave of reloads the same size as the warm-up. With 0-10 % jitter (0-6 minutes) the wave is spread over about 11 minutes, roughly halving the peak rate. The numbers are an illustration of the spread, not a measurement.
Missing Keys, Mass Expiry and Cache Collapse
A cache stores what exists. A request for an id that is not in the database gets a miss every single time, so it always reaches the database. A scanner, a bug or an attacker iterating random ids can therefore bypass the cache completely. The defences: negative caching and a Bloom filter, plus ordinary input validation and rate limiting.
| Technique | How | Catch |
|---|---|---|
| Negative caching | Store a marker for "not found" (Optional.empty(), a null sentinel) with a short TTL | A row created right after the miss is invisible until the negative entry expires or is deleted: delete the key on create |
| Bloom filter in front | Keep the set of valid ids in a Bloom filter; if it says "definitely not", reject without touching the cache or database | Standard Bloom filters cannot delete; rebuild periodically or use a counting/cuckoo variant. Must be updated when new rows are created |
| Validate the key space | Ids have a known format and range; reject others at the edge | Does not stop valid-looking nonexistent ids |
| Rate limit by client | Limits what one caller can scan | Distributed attackers |
An avalanche is the same problem at scale: a large share of the cache disappears at once and the database gets the full load. Three common causes: (1) lots of keys written together with the same TTL, (2) a cache restart or failover with no persistence, (3) a deployment that changes the key prefix for everyone. All three are about the cold cache, and the database must be able to survive it, or the traffic must be limited until the cache is warm again.
| Cause | Prevention | During the event |
|---|---|---|
| Same TTL on a batch of keys | Jitter the TTL (chapter 5) | Single-flight and background refresh |
| Cache node restart | Persistence (RDB) so it restarts warm, replicas, several nodes so one failure loses a fraction | Load shedding, stale-if-error, warm hot keys first |
| Key prefix / version bump | Bump gradually, or warm the new version before switching readers | Same |
| Cache eviction under memory pressure | Size for the working set (chapter 9) and watch evicted_keys | Add memory or shrink values |
L1 in the Process, L2 Shared
A hot key is one that receives a disproportionate share of traffic: the home page, a trending product, a feature flag read on every request. In a sharded cache all requests for one key land on the node that owns it, so adding nodes does not help. In Redis Cluster, for example, a key lives in exactly one slot (see Redis, chapter 5).
| Technique | How | Trade-off |
|---|---|---|
| Local L1 in front of L2 | A small in-process cache (Caffeine) per instance with a short TTL, then Redis, then the database | Hot reads never leave the process. Staleness up to the L1 TTL unless invalidated by event |
| Key replication / suffixing | Store the hot value under key#0 ... key#N-1; each reader picks one at random, so the load spreads over several shards | Writes must update or delete all copies. Use only for a known handful of keys |
| Client-side caching with server-assisted invalidation | Redis can track which keys a client has cached (CLIENT TRACKING ON) and push an invalidation message when one changes; needs a client that implements it (RESP3 or a second connection) | Memory on the server for tracking; support depends on client library. Described on the Redis client-side caching docs page |
| Pub/Sub invalidation | Writers publish the key name; every instance drops its L1 copy | Pub/Sub is at-most-once, so keep a short L1 TTL as the backstop; the Redis docs call broadcast invalidation by Pub/Sub tricky and bandwidth-costly at scale |
| Request coalescing at L1 | Single-flight in the L1 loader | Already covered in chapter 5 |
- Give L1 a hard size bound and a TTL measured in seconds or a few minutes. Instrument L1 and L2 separately: an L1 hit ratio of 60 % with an L2 hit ratio of 95 % tells you a different story than one blended number. The Spring side of configuring two cache layers is covered in Spring: Caching and Resilience.
- Every new instance starts with an empty L1, so the first requests after a deployment all go to L2 and the database at once. Roll slowly, warm L1 from L2 for the hot keys, or let L2 absorb the first wave (it is the reason L2 exists).
Cache-Control, ETag and the Edge
HTTP has caching built in, and it is the cheapest cache you can use because the request never reaches your servers. Two mechanisms work together: freshness (the response says how long it may be reused without asking) and validation (once stale, the cache asks the origin "has it changed?" with a conditional request). The directives below were checked against MDN's Cache-Control reference (2026-10-31).
| Directive (response) | Meaning | Typical use |
|---|---|---|
max-age=N | Fresh for N seconds after it was generated, in any cache | Static content, rarely changing API data |
s-maxage=N | Like max-age but for shared caches (CDN, proxy); overrides max-age there | Let the CDN keep it longer than the browser |
no-cache | The response may be stored, but must be revalidated with the origin before each reuse. Despite the name it does not mean "do not store" | Pages that must always be fresh but can use a 304 |
no-store | Do not store the response in any cache | Sensitive data: account pages, tokens |
private | Only the user's own (browser) cache may store it, not shared caches | Personalised responses |
public | Shared caches may store it even if it would normally not be stored (for example an authorised request) | Explicitly cacheable shared content |
must-revalidate | Once stale, do not serve it without successful revalidation (no stale on error) | When serving stale is unacceptable |
immutable | The response will not change during its freshness lifetime, so do not revalidate on reload | Fingerprinted assets: public, max-age=31536000, immutable |
stale-while-revalidate=N | Serve a stale response for up to N seconds while revalidating in the background | Latency-friendly freshness |
stale-if-error=N | Serve a stale response for up to N seconds if the origin errors | Resilience: the origin can be down briefly |
| Header | Role |
|---|---|
ETag: "v7" and request If-None-Match: "v7" | Validator for the content. If it still matches the origin answers 304 Not Modified with no body. A weak ETag starts with W/ and means "semantically equivalent" |
Last-Modified and If-Modified-Since | A time-based validator; coarser than an ETag (one-second resolution) |
Vary: Accept-Encoding, Accept-Language | Extends the cache key: the cache keeps a separate copy per value of the named request headers. Vary: * effectively disables sharing. A missing Vary serves the wrong language or encoding to somebody |
Age | Seconds the response has been in shared caches, set by the cache |
- No
Cache-Controlat all: a cache is allowed to store the response and choose a freshness time heuristically, so always state your intent. - Putting personalised data behind a CDN without
private/no-storeor aVaryon what identifies the user: users see each other's pages. - Long
max-ageon a URL whose content changes in place. Version the URL (fingerprint) instead of shortening the TTL. - Confusing
no-cachewithno-store.
- Use HTTP caching for what a client may reuse, and the server-side patterns above for what your servers may reuse. A common stack: CDN with
s-maxageplusstale-while-revalidatein front, Caffeine plus Redis behind the app, and event-driven invalidation (and a CDN purge for the few paths that need it) when content changes.
Hit Ratio Math, Working Sets and What to Watch
The hit ratio is hits / (hits + misses). Two formulas make it concrete. Database load is requests x (1 - hit ratio). Average latency is h x t_hit + (1 - h) x (t_hit + t_load), because a miss still pays the cache lookup. The example below uses made-up round numbers: a 1 ms cache lookup and a 20 ms database load.
| Hit ratio (h) | Database load at 10,000 req/s | Average latency (1 ms lookup, 20 ms load) | Reading |
|---|---|---|---|
| 0.90 | 1,000 req/s | 0.90 x 1 + 0.10 x 21 = 3.0 ms | A good start |
| 0.95 | 500 req/s | 0.95 x 1 + 0.05 x 21 = 2.0 ms | Half the database load of 0.90 |
| 0.99 | 100 req/s | 0.99 x 1 + 0.01 x 21 = 1.2 ms | Tail latency is now dominated by the misses |
Two consequences. First, the database must survive the miss traffic, and a cold cache means h near 0: at 10,000 req/s you need either capacity for 10,000 req/s of loads or a plan to shed load (chapter 10). Second, average latency hides the tail: with h = 0.99 the 99th percentile request is a miss, so p99 is the database latency, not the cache latency. Watch percentiles, not the mean.
Caches work because access is skewed: a small part of the data takes most of the traffic. The cache needs to hold that working set, not the whole dataset. Measure it: log key accesses for a day, sort by frequency, and plot the cumulative share of requests against the number of keys. If, say, 5 % of keys served 80 % of requests (a made-up skew for illustration), a cache holding about 5 % of the data reaches roughly an 80 % hit ratio, and each extra percent of capacity buys less.
| Sizing step | Example (labelled example numbers) | Notes |
|---|---|---|
| Entries to hold | 5,000,000 entries | From the working-set measurement, not the table row count |
| Bytes per entry | 1 KB value + about 100 B key and bookkeeping overhead (assumption; measure with MEMORY USAGE in Redis) | Overhead is per entry, so many small entries cost more than their payload |
| Total | 5,000,000 x 1,100 B = about 5.5 GB | Add headroom for fragmentation, replication buffers and fork copy-on-write |
| Set limits | maxmemory below the container limit; Caffeine maximumSize or maximumWeight | An unbounded cache is a memory leak with a nice name |
| Compress or shrink | Cache the DTO the page needs, not the entity graph | Smaller values mean more entries per GB |
| Metric | Where to read it | What it tells you |
|---|---|---|
| Hit ratio (per cache and per endpoint) | Redis INFO stats: keyspace_hits, keyspace_misses; Caffeine stats().hitRate() | Is the cache earning its keep? Alert on a sudden drop |
| Evictions | Redis evicted_keys; Caffeine evictionCount() | Rising evictions with a falling hit ratio means the cache is too small or keys are too big |
| Expired keys | Redis expired_keys | Normal churn; spikes suggest synchronised TTLs |
| Load time and failures | Caffeine averageLoadPenalty(), loadFailureCount(); your own timers | The cost of a miss; the first sign a source is degrading |
| Memory | Redis used_memory against maxmemory; JVM heap for L1 | Headroom before eviction or OOM |
| Latency percentiles of cache calls | Client metrics (Micrometer) | A slow cache is worse than no cache |
| In-flight loads / stampede indicator | Loader concurrency per key | Confirms single-flight works |
The output of INFO stats on a lightly used test server looked like keyspace_hits:34, keyspace_misses:7, evicted_keys:0, expired_keys:5 (real values from the session used for the Redis page). The ratio of hits to misses, not the raw counts, is what you alert on.
Cache Down, Cold Start and Degradation
A cache is an optimisation, so the system must still work, within limits, without it. Most outages involving caches share one pattern: the cache stops absorbing 90-99 % of reads, the database receives all of them, and the database falls over.
| Failure | What you see | Response |
|---|---|---|
| Cache unreachable or slow | Timeouts, thread pools filling with waiting requests, latency spike everywhere | Short client timeouts (tens of milliseconds, not seconds), circuit breaker that stops calling the cache while it is unhealthy, then fail open to the database under a concurrency limit. Breaker details in Spring: Caching and Resilience |
| Database overloaded after cache loss | Database CPU and connections saturate, errors cascade | Load shedding: cap concurrent loads, return stale or a degraded response for the excess. Rate-limit expensive endpoints |
| Cold start after restart or failover | Hit ratio near 0 for minutes | Persist the cache to disk and restart warm (Redis RDB), pre-warm hot keys from a list or from L2, ramp traffic |
| Cache returns wrong or stale data | Users report old data, or another user's data | Check key construction first (missing tenant/user), then invalidation, then races; add versioned keys to flush a class quickly |
| Serialization incompatibility after deploy | Deserialization errors on old entries | Version the key prefix; treat a deserialization error as a miss, not a failure |
| Memory exhaustion | Evictions climbing, hit ratio falling, or OOM errors | Eviction policy, bounded sizes, find big keys (see Redis) |
- If
cache.getthrows and the exception is not caught, a cache outage becomes a full outage. Wrap cache reads and writes: on error, log, count a metric, treat as a miss. Make writes to the cache best effort as well: failing to populate a cache should not roll back the business operation.
- "If the cache were empty right now, would the database survive the traffic?" If not, you do not have a cache, you have a dependency, and the protection (shedding, breakers, stale serving) is part of the design, not an extra.
A TTL + LRU Cache with Single-Flight Loading, in Plain Java
Libraries hide the mechanics, so here is the whole thing in about a hundred lines of plain Java 17+: a bounded (LRU) cache, expiring entries (TTL with optional jitter), and single-flight loading so that concurrent misses share one loader call. It is for understanding; in production use Caffeine, which adds much better eviction (W-TinyLFU), asynchronous refresh and statistics.
How it works. lookup takes the lock, returns a fresh entry or removes an expired one (lazy expiry), and a LinkedHashMap in access order makes the eldest entry the least recently used one (LRU: store evicts it when the size exceeds the capacity). getNaive is the classic cache-aside code, so concurrent misses all call the loader. get is single-flight: the first miss puts a CompletableFuture into the inFlight map and becomes the leader; later misses for the same key find the future and wait on it. The leader re-checks the cache once it has won, because a previous leader may have just finished (without that re-check a thread can slip in between a leader's store and its inFlight.remove and load again). A loader failure completes the future exceptionally, is not cached, and the next call retries.
| Test | What it shows |
|---|---|
| 1. 100 threads, cold key, loader 100 ms | Naive cache-aside called the loader 100 times; single-flight called it once. All 100 callers still got the value |
| 2. Manual clock | Within the TTL a herd of 50 triggers 0 loads; one millisecond past expiry it triggers exactly 1 |
| 3. LRU, capacity 3 | After touching a and inserting d, b (least recently used) was evicted: keys are [c, a, d] |
| 4. Jitter 0-20 % | Eight keys loaded at the same instant expire at different times between 60 s and 72 s |
| 5. Loader failure | The failure was not cached; the second call loaded again and succeeded |
- It holds one global lock for lookups, has no background refresh, no size-by-weight, no statistics beyond three counters, no negative-entry TTL, and no protection against a loader that hangs (a hung leader blocks its followers forever; give the loader a timeout). It is a teaching model, not a library.
Caching Questions with Crisp Answers
- Q: Name the main caching patterns.
Cache-aside (app loads and fills), read-through (cache loads), write-through (synchronous write to cache and DB), write-behind (asynchronous DB write), refresh-ahead (reload before expiry). They differ in who loads on a miss and when the database is written. - Q: Why delete the cache entry on write instead of updating it?
Delete lets the next reader load the truth and avoids two writers leaving the cache in a different order than the database. Update saves a miss but needs versioning or a conditional write. - Q: Describe the read/write race in cache-aside.
A reader misses and reads the old row, a writer updates the DB and deletes the cache entry, then the reader writes its old value into the cache. It stays stale until TTL. Mitigate with TTL, versioned values with conditional set, delayed double delete, or event-driven invalidation from the database log. - Q: What is a cache stampede and how do you prevent it?
Many concurrent misses on an expired hot key all hit the database. Use single-flight, a distributed lock or lease, jittered TTLs, probabilistic early refresh (XFetch), stale-while-revalidate, and protect the source with limits. - Q: How does probabilistic early expiration work?
Readers recompute a bit before expiry with rising probability: recompute ifnow - delta * beta * ln(rand()) >= expiry, wheredeltais the last recompute time. One reader usually refreshes early, so there is no herd at the deadline. - Q: Cache penetration versus avalanche versus stampede?
Penetration: requests for nonexistent keys always miss (negative caching, Bloom filter). Avalanche: many keys expire or the cache dies together (jitter, persistence, replicas, shedding). Stampede: concurrent misses on one hot key (single-flight). - Q: How does a Bloom filter help and what are its limits?
It answers "definitely not present" or "possibly present" in small memory, so nonexistent keys are rejected before the cache or database. False positives are tunable (about 9.6 bits per element for 1 %); standard filters cannot delete and must be updated when data is added. - Q: What is a hot key and how do you handle it?
One key getting disproportionate traffic, which overloads the shard that owns it. Add a local L1 cache, replicate the value under suffixed keys, or use client-side caching with server-assisted invalidation. - Q: Design a two-level cache. What about invalidation?
L1 in-process (small, seconds-minutes TTL), L2 shared (Redis), database behind. On write: update DB, delete L2, publish an event so every instance drops its L1 entry. A short L1 TTL bounds staleness if an event is lost. - Q: Explain
no-cacheversusno-store.no-cacheallows storing but requires revalidation before each reuse;no-storeforbids storing. Neither means "not cached" by itself in the colloquial sense. - Q: What is an ETag?
A validator for a response version. The client sendsIf-None-Match; if it matches, the server returns304 Not Modifiedwith no body. - Q: How do you cache static assets safely?
Fingerprint the file name and serveCache-Control: public, max-age=31536000, immutable; a new build produces a new URL. - Q: How do you size a cache?
Measure the working set (key frequency distribution), multiply entries by value size plus overhead, add headroom, setmaxmemoryormaximumSize, and watch hit ratio and evictions. Do not size by table row count. - Q: Why is the hit ratio alone not enough?
It hides tail latency (p99 is a miss) and cost per miss. Track per-endpoint hit ratio, load time percentiles, evictions and memory. - Q: What if the cache goes down?
Short timeouts, circuit breaker on the cache client, fail open to the database under a concurrency limit, serve stale or degrade, then warm up before restoring full traffic. Ask: could the database survive an empty cache? - Q: Write-through or write-behind?
Write-through is consistent and slower; write-behind is fast but acknowledged writes can be lost if the cache dies before the flush. Use write-behind only for loss-tolerant data, with a durable queue. - Q: What goes wrong if the cache key omits the tenant or user?
Responses are shared across users: a data leak. The key must include every input that changes the answer. - Q: Scenario: the home page API is slow every day at 09:00 exactly.
Likely synchronised TTLs on keys warmed at the same time, or a daily deploy emptying L1. Check expiry metrics and load times at that hour; add jitter, stale-while-revalidate and pre-warming.
Cheat Sheet and Recap
| Problem | Symptom | Fix |
|---|---|---|
| Stale after write (race) | Old value lives until TTL | Delete after commit, short TTL, version + conditional set, event-driven invalidation |
| Stampede | DB burst when a hot key expires | Single-flight, lock/lease, jitter, XFetch, stale-while-revalidate |
| Penetration | DB load from nonexistent ids | Negative cache (short TTL), Bloom filter, validation |
| Avalanche | DB overload after mass expiry or cache loss | Jitter, persistence, replicas, load shedding, warm-up |
| Hot key | One shard saturated | L1 cache, key replication, client-side caching |
| Data leak | User sees another user's data | Put every input in the key; private/no-store over HTTP |
| Cache outage | Latency and DB load explode | Timeout, breaker, fail open under limits, stale-if-error |
| Unbounded growth | OOM or evictions everywhere | maxmemory/maximumSize, TTL, watch evictions |
- Cache what is read often, expensive, and tolerant of staleness; never what must be exactly current.
- Cache-aside is the default; write-through trades write latency for freshness; write-behind trades durability for speed.
- TTL is the safety net; add delete-after-commit or event-driven invalidation for precision, versioned keys for bulk flushes.
- The read-miss/update interleaving is real: only versions, leases or event ordering truly fix it.
- Single-flight, jitter and early refresh stop stampedes; negative caching and Bloom filters stop penetration.
- L1 plus L2 absorbs hot keys; invalidate by event with a short L1 TTL as backstop.
- HTTP caching is free speed:
max-age,s-maxage,no-cache(revalidate),no-store(do not keep), ETag and 304. - Size by working set; measure hit ratio, evictions, load time and memory; design for the cache being down.
Checked on 2026-10-31: the Java demos were compiled with JDK 21 and run (MiniCache and the Caffeine 3.x program, Caffeine on the classpath of a Spring Boot 3.5.16 project); Redis behaviour and Bloom commands by running redis:7 and redis:8 images; the XFetch rule against the VLDB 2015 paper; Cache-Control directive meanings against MDN; client-side caching against redis.io docs. Hit ratio and sizing numbers are labelled worked examples, not benchmarks. Not verified: behaviour of managed cache services and of specific CDNs, which differ in what they honour.
- 02 Redis - the store most of these patterns run on: expiry, eviction, persistence, locks and Cluster.
- 07 Polyglot Persistence - where caches sit among the other stores.
- Spring: Caching and Resilience -
@Cacheable, Caffeine, Redis and Resilience4j in Spring. - Event-driven patterns - outbox and events for invalidation.