Cache Eviction, Invalidation and Stampedes
A cache is small, so something must leave when it fills up, and stale entries must go when data changes. Learn LRU, LFU and TTL eviction, invalidation approaches, and how to stop a cache stampede from flattening your database.
In a nutshell
- Eviction decides what to drop when the cache is full: LRU (least recently used), LFU (least frequently used), or TTL expiry.
- Invalidation removes entries when the source changes: delete on write, short TTLs, events from the database, or versioned keys.
- A stampede happens when a hot key expires and thousands of requests rebuild it at once.
- Stop stampedes with request coalescing (one rebuild, others wait), early refresh and jittered TTLs.
Eviction policies
| Policy | Drops | Good for | Weak spot |
|---|---|---|---|
| LRU | The entry not used for the longest time | Most workloads: recent items are likely to be used again | A one-off scan of many keys pushes out the hot ones |
| LFU | The entry used the fewest times | Stable popularity (top products) | Old favourites linger after they stop being popular |
| TTL | Entries past their expiry time | Bounding staleness | Not a size limit by itself |
| FIFO / random | Oldest inserted / any entry | Simplicity | Ignores how entries are used |
An LRU cache with O(1) operations combines a hash map (key to entry) with a doubly linked list ordered by recency: every access moves the entry to the front, and eviction removes the entry at the back. Both Java and Python have this built in.
LRU cache
import java.util.*;
class LRUCache<K, V> {
private final int capacity;
// accessOrder = true: iteration order is least to most recently used
private final LinkedHashMap<K, V> map;
LRUCache(int capacity) {
this.capacity = capacity;
this.map = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > LRUCache.this.capacity;
}
};
}
synchronized V get(K key) {
return map.get(key); // also marks it most recently used
}
synchronized void put(K key, V value) {
map.put(key, value);
}
synchronized Set<K> keys() {
return new LinkedHashSet<>(map.keySet());
}
}from collections import OrderedDict
class LRUCache:
def __init__(self, capacity):
self.capacity = capacity
self.data = OrderedDict() # least to most recently used
def get(self, key):
if key not in self.data:
return None
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False) # evict the least recently used
def keys(self):
return list(self.data)LFU in O(1) keeps, for each use count, a list of keys in recency order, plus the current minimum count. Eviction removes the least recently used key among those with the lowest count.
LFU cache (ties broken by least recent use)
import java.util.*;
class LFUCache<K, V> {
private final int capacity;
private final Map<K, V> values = new HashMap<>();
private final Map<K, Integer> counts = new HashMap<>();
private final Map<Integer, LinkedHashSet<K>> byCount = new HashMap<>();
private int minCount = 0;
LFUCache(int capacity) {
this.capacity = capacity;
}
private void touch(K key) {
int c = counts.get(key);
counts.put(key, c + 1);
byCount.get(c).remove(key);
if (c == minCount && byCount.get(c).isEmpty()) {
minCount++;
}
byCount.computeIfAbsent(c + 1, x -> new LinkedHashSet<>()).add(key);
}
synchronized V get(K key) {
if (!values.containsKey(key)) {
return null;
}
touch(key);
return values.get(key);
}
synchronized void put(K key, V value) {
if (capacity == 0) {
return;
}
if (values.containsKey(key)) {
values.put(key, value);
touch(key);
return;
}
if (values.size() == capacity) {
K victim = byCount.get(minCount).iterator().next();
byCount.get(minCount).remove(victim);
values.remove(victim);
counts.remove(victim);
}
values.put(key, value);
counts.put(key, 1);
byCount.computeIfAbsent(1, x -> new LinkedHashSet<>()).add(key);
minCount = 1;
}
}from collections import OrderedDict, defaultdict
class LFUCache:
def __init__(self, capacity):
self.capacity = capacity
self.values = {}
self.counts = {}
self.by_count = defaultdict(OrderedDict) # count -> keys, least recent first
self.min_count = 0
def _touch(self, key):
c = self.counts[key]
self.counts[key] = c + 1
del self.by_count[c][key]
if c == self.min_count and not self.by_count[c]:
self.min_count += 1
self.by_count[c + 1][key] = None
def get(self, key):
if key not in self.values:
return None
self._touch(key)
return self.values[key]
def put(self, key, value):
if self.capacity == 0:
return
if key in self.values:
self.values[key] = value
self._touch(key)
return
if len(self.values) == self.capacity:
victim, _ = self.by_count[self.min_count].popitem(last=False)
del self.values[victim]
del self.counts[victim]
self.values[key] = value
self.counts[key] = 1
self.by_count[1][key] = None
self.min_count = 1Invalidation
Eviction handles space; invalidation handles correctness. The options, from simplest to strongest:
- TTL only: data is at most N seconds stale. Simple and self-healing; fine when some staleness is acceptable.
- Delete on write: the code path that writes the database deletes the key. Fast, but every write path must remember to do it.
- Change data capture: a process reads the database's change log and deletes affected keys, so no write path can forget.
- Versioned keys: include a version in the key (
product:31:v7); bump the version on change and old entries simply stop being read and expire.
Cache stampedes
A hot key (the home page feed, served 10,000 times a second) expires. In the next instant thousands of requests miss together, all run the same expensive query, and the database falls over, which makes rebuilding the cache even slower. This is a stampede (or thundering herd, or dog-pile).
Request coalescing stops a stampede
All steps Only one request rebuilds the expired key; the others wait for its result instead of all hitting the database.
- Three requests miss at once. The first takes a short lock for the key; the others wait (or are served the stale value).
- Only one query reaches the database. When it returns, the cache is filled and every waiting request gets the result.
Diagram as text
- Three requests miss at once. The first takes a short lock for the key; the others wait (or are served the stale value).
- Only one query reaches the database. When it returns, the cache is filled and every waiting request gets the result.
- Request coalescing / locking: the first miss takes a lock (for example Redis
SET key:lock 1 NX EX 10) and rebuilds; others wait briefly or get the old value. - Serve stale while revalidating: keep the expired value and return it while one background refresh runs.
- Early, probabilistic refresh: as expiry approaches, each request has a small and growing chance of refreshing early, so one request usually refreshes before the key actually expires.
- Jittered TTLs: add randomness (10 minutes ± 1) so keys written together do not all expire together.
Common mistakes
- Giving thousands of keys the same TTL after a cache warm-up, so they all expire in the same second.
- Relying on delete-on-write alone, with no TTL as a safety net.
- Letting every miss on a hot key query the database.
- Using LRU for a workload with large one-off scans, which flush out the hot set.
Interview questions
Try answering out loud before you open each one.
A popular key expires and the database spikes. What do you do?
Prevent concurrent rebuilds: a lock or single-flight so one request recomputes while others wait or get the stale value. Serve stale-while-revalidate for hot keys, refresh them early, and add jitter to TTLs so expiries spread out. Also make sure the database has protection (connection limits, load shedding).
Implement an LRU cache.
A hash map from key to a node in a doubly linked list ordered by recency. get moves the node to the front; put inserts at the front and, if over capacity, removes the tail node and its map entry. Both are O(1). In Java, LinkedHashMap with access order and removeEldestEntry does this; in Python, OrderedDict with move_to_end and popitem(last=False).
Check yourself
Related topics
Further reading
- Key eviction Redis documentation
- Optimal Probabilistic Cache Stampede Prevention Vattani et al., VLDB 2015
Your notes
Saved in this browser as you type.
Finished this topic?
Mark it read to track your progress, then carry on with the next one.