
- Published on
- ·11 min read
Redis Cache Avalanche, Penetration, and Breakdown — How to Handle Each
- Authors

- Name
- Bert / DOTUNE
- Developer
Caching looks simple until it stops working the way you expect. The happy path is "check Redis, load from DB on a miss, write back." The problems all live in that miss path — specifically, in what happens when it gets hit far more often than it should.
Penetration, breakdown, and avalanche are the same failure in three shapes: a request that should have been served by the cache reaches the database instead. What separates them is the scope of what failed:
| Problem | What fails | Trigger |
|---|---|---|
| Penetration | One key that never existed anywhere | Querying data that isn't in cache or DB |
| Breakdown | One hot key | A heavily requested key expires at peak traffic |
| Avalanche | The whole cache layer | Mass simultaneous expiry, or Redis going down |
Most write-ups stop at "here's the definition and the fix." The part that actually matters in production is the trade-off each fix forces you to accept. That's what the rest of this article is about.
Cache Penetration
Penetration is the most straightforward of the three to understand and the easiest to get wrong in practice. A request comes in for a key that doesn't exist in Redis or the database. Because the database also returns nothing, there's nothing to write back to the cache. So the next identical request misses again, and the one after that. Every request for that key becomes a database query.
A single user doing this by accident is noise. An attacker doing it on purpose — sending requests for id=-1, or a million random UUIDs that will never exist — turns it into a slow database outage. The cache stops being a shield and becomes an irrelevant middleman.
First line: reject obviously invalid input
This is not a caching trick, but it blocks the majority of real penetration attacks before they touch anything expensive. Validate the shape of the input before it reaches the cache layer: positive integers only, ID ranges, permission checks, per-user rate limits, blacklists.
if (id <= 0 || id > MAX_ID) {
return null; // or throw, depending on the API contract
}
It's cheap and it handles the dumb cases. What it can't handle is a request that looks valid but doesn't exist in the data — which is why you need the next two layers.
Null caching: store the miss
The simplest structural fix is to cache the fact that the data is missing. When the database returns null, write a placeholder into Redis with a short TTL:
String key = "product:" + id;
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return NULL_PLACEHOLDER.equals(cached) ? null : deserialize(cached);
}
Product product = productDao.findById(id);
if (product == null) {
redisTemplate.opsForValue().set(key, NULL_PLACEHOLDER, 60, TimeUnit.SECONDS);
return null;
}
redisTemplate.opsForValue().set(key, serialize(product), 30, TimeUnit.MINUTES);
return product;
Two trade-offs to be aware of. First, memory: if an attacker can generate unlimited distinct IDs, you're storing unlimited placeholder keys. The short TTL (30–120 seconds is typical) caps the damage, but it doesn't eliminate it. Second, consistency: if the record gets created while the placeholder is still cached, clients will keep seeing "not found" until the TTL expires. On insert, invalidate the placeholder explicitly.
Null caching is a good default for normal traffic. It is not a defense against a determined attacker.
Bloom filter: know what's absent without storing it
A Bloom filter answers one question cheaply: "does this key definitely not exist?" It's a fixed-size bit array plus a handful of hash functions. You preload it with every valid key. On a request, you ask the filter first:
- "Definitely not in the set" → reject immediately, no cache or DB access.
- "Possibly in the set" → proceed with the normal cache → DB flow.
The asymmetry is the whole point. False negatives are impossible — if the filter says a key is absent, it really is. False positives are allowed within a tunable rate. A filter for one million keys at a 1% false-positive rate takes about 1.2 MB. That's the trade-off: a tiny, bounded error rate buys you near-total protection against querying for things that don't exist.
With RedisBloom (Redis 4.0+ as a module):
BF.RESERVE product:bloom 0.01 1000000
BF.ADD product:bloom product:1001
BF.EXISTS product:bloom product:1001
In Java you can use Redisson's wrapper, which is cleaner to work with and falls back to a plain Redis implementation if the module isn't installed:
RBloomFilter<String> bloom = redisson.getBloomFilter("product:bloom");
bloom.tryInit(1_000_000L, 0.01); // expected insertions, false-positive rate
bloom.add("product:1001");
if (!bloom.contains("product:" + id)) {
return null; // the filter guarantees this key doesn't exist
}
Two caveats that matter in real systems. A Bloom filter has no delete operation — you can't remove a key once it's added, so for datasets where keys get deleted you either rebuild the filter periodically or reach for a counting/Cuckoo filter. And the filter has to be kept in sync with the database; a filter built from stale data will either let bad requests through or block good ones.
The reason people layer these rather than pick one: the Bloom filter is the front gate, null caching catches the false positives that slip through, and rate limiting is the backstop. Which combination you need depends on whether your threat model is "accidental misses" or "someone is attacking you."
Cache Breakdown
Breakdown is penetration's more dangerous cousin. Here the key does exist and is in the cache — it's just a very hot key, and it expires. The instant it does, every request that was being served from cache now misses simultaneously. Tens of thousands of requests hit the database for the same key at once. One expired key behaves like an avalanche.
The defining detail is that this is a timing problem on a single key. That narrows the solution space to two approaches, and they trade consistency against availability in opposite directions.
Mutex lock: let one request rebuild
The goal is to make the miss path single-threaded. Before a miss queries the database, it tries to acquire a lock on that key. Only the winner rebuilds the cache; everyone else waits and retries.
public Product getProduct(long id) {
String key = "product:" + id;
Product product = readFromCache(key);
if (product != null) return product;
RLock lock = redisson.getLock("lock:" + key);
try {
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
// re-check: another thread may have rebuilt while we waited for the lock
product = readFromCache(key);
if (product == null) {
product = productDao.findById(id);
writeToCache(key, product, 30, TimeUnit.MINUTES);
}
} else {
Thread.sleep(50);
return getProduct(id); // retry
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) lock.unlock();
}
return product;
}
The double-check inside the lock is not optional — it's the difference between "one database query" and "N database queries, serialized." The cost of this approach is latency: every request during the rebuild window waits. In the worst case, requests pile up on the lock. It guarantees the database sees exactly one query per expired key, but it makes clients wait.
Logical expiration: never actually expire
The alternative sidesteps the race entirely by not setting a physical TTL. Instead you store an expireAt field inside the cached value. On read, you return the value even if it's past expireAt, and fire off an asynchronous task to refresh it in the background. The key never truly misses, so there's never a thundering herd.
public Product getProduct(long id) {
CachedProduct cached = readFromCache("product:" + id);
if (cached == null) return rebuild(id); // cold start only
if (cached.expireAt.isBefore(Instant.now())) {
executor.submit(() -> rebuild(id)); // refresh in background
}
return cached.product; // serve slightly stale data
}
The trade-off here is the mirror image of the mutex: clients never wait, but they may briefly read stale data. The staleness window is bounded by however long the background refresh takes.
The choice reduces to a business question: does your traffic pattern tolerate a few seconds of stale data (logical expiration), or is correctness non-negotiable and latency is the cheaper sacrifice (mutex)? There's no universally correct answer — it depends on which cost you're willing to pay in a real system.
Cache Avalanche
Avalanche is the failure at the level of the whole cache layer. It has two distinct causes, and they need different responses.
Cause 1: everything expires at once
The classic way to cause this is a batch warm-up where a million keys all get the same TTL. They expire in the same second, and the entire read load lands on the database at once.
The baseline fix is almost embarrassingly simple — randomize the TTL so expirations spread out:
int baseTtl = 3600; // 1 hour
int jitter = ThreadLocalRandom.current().nextInt(300); // 0–5 minutes
redisTemplate.opsForValue().set(key, value, baseTtl + jitter, TimeUnit.SECONDS);
If you take nothing else from this article, add jitter to your cache writes. It's one line and it eliminates the most common avalanche trigger. The reason most teams don't is that a fixed TTL looks deliberate and a random one looks sloppy — until the first time a million keys expire together.
Cache warm-up (preheating) is the other half: load hot data before traffic peaks so the first wave doesn't hit a cold cache. The caveat is that preheated keys also need jitter, or you've just scheduled an avalanche for one TTL later.
Cause 2: Redis itself goes down
No amount of TTL math helps when the cache server is unreachable. This is an availability and resilience problem, not a caching problem:
- High availability first. Sentinel or Cluster so a node failure doesn't take the cache offline. This is table stakes for anything medium-sized and up.
- Multi-level caching. A local in-process cache (Caffeine) in front of Redis absorbs traffic even when Redis is gone. The local layer should have a much shorter TTL than Redis — it's a cushion, not a replacement.
- Circuit breaking and rate limiting as the last gate. When the cache is down and the database is the only thing left, the only thing standing between your app and a database outage is a circuit breaker (Sentinel, Hystrix) that trips and returns a degraded response instead of forwarding every request downstream.
The circuit breaker doesn't fix the root cause — it just decides whether the failure stops at the cache or takes the database with it. That distinction is the entire reason it's worth the configuration overhead.
Hot-Key Detection
Mutex locks and logical expiration respond to a hot key that has already failed. The alternative is to find hot keys before they break: a hot key concentrates traffic on one shard, saturating that node while the rest of the cluster sits idle.
Detection ranges from reactive to proactive: track access counts and QPS and look for outliers, mark the keys you already know will be hot (flash sales), or use a dedicated framework like JD.com's open-source "hotkey," which flags hot keys in near real time so they can be pushed into a local cache.
Most teams don't need a detection framework. Jitter, a mutex or logical-expiration strategy on the keys they already know are hot, and monitoring that surfaces new ones is enough — detection is what you add when those stop being enough.
Choosing by Scale
Each layer has a cost, and most of them are wasted at small scale.
| Scale | Penetration | Breakdown | Avalanche |
|---|---|---|---|
| Small / single service | Input validation + null caching | Mutex lock | Random TTL |
| Medium | Add a Bloom filter | Mutex or logical expiration | Sentinel + multi-level cache |
| Large / promotion-scale | Bloom filter + rate limiting | Logical expiration + hot-key detection | Cluster + circuit breaker + preheating |
Random TTL and null caching are nearly free and solve the majority of real-world incidents; everything below the first row earns its place only when traffic forces it to.
The Bottom Line
The three problems are one problem — a cache miss reaching the database — distinguished by scope: a key that never existed, a hot key that expired, or the whole layer failing at once.
Each fix carries a cost: null caching spends memory, a Bloom filter trades a bounded error rate for near-total protection, a mutex spends latency, logical expiration spends freshness, random TTL spends nothing but discipline. The skill is knowing which cost you're paying for your traffic.