Database Caching at Scale · Cache Failure Modes

Cache Penetration: Requests for Data That Doesn't Exist

Bots and bugs asking for missing keys bypass the cache every time — negative caching and Bloom filters stop them.

Scenario

Example app: a public product API, GET /products/{id}, cached in Redis. A scraper starts enumerating ids 1 to 50,000,000; 98% of those ids don't exist.

What you see in production

The cache hit ratio drops from 97% to 40%, database QPS triples, and every one of those database queries returns nothing. Rate limiting per IP doesn't help — the scraper rotates through thousands of IPs.

The naive code

Only found values are cached, so every request for a missing id goes to the database:

def get_product(pid):
    if (hit := r.get(f"product:{pid}")) is not None:
        return json.loads(hit)
    product = db.fetch_product(pid)             # None for missing ids
    if product is not None:
        r.set(f"product:{pid}", json.dumps(product), ex=600)
    return product                              # missing → database every time

Reproduce it

1,000 requests for random ids where only 2% exist; compare database queries with and without negative caching and a Bloom filter:

Negative caching and a Bloom filter vs penetrationTry it yourself
import random
random.seed(3)
existing = set(range(1, 20_001))                       # ids that exist
requests = [random.randint(1, 1_000_000) for _ in range(1000)] * 2   # scraper repeats ids

def bloom_contains(i):                                 # stand-in for a Bloom filter (~1% false positives)
    return i in existing or i % 100 == 0

def run(negative_cache, bloom):
    cache, db_queries = {}, 0
    for i in requests:
        if bloom and not bloom_contains(i):
            continue                                   # definitely missing: answer 404 without the database
        if i in cache:
            continue
        db_queries += 1
        found = i in existing
        if found or negative_cache:
            cache[i] = found                           # cache "not found" too (short TTL)
    return db_queries

for label, neg, bloom in (("no protection", False, False), ("negative caching", True, False), ("Bloom filter + negative", True, True)):
    print(f"{label:24} database queries: {run(neg, bloom)}")

Output

no protection            database queries: 1988
negative caching         database queries: 1000
Bloom filter + negative  database queries: 26

Root cause

Cache-aside only stores results that exist. Requests for non-existent data — enumeration, bugs that generate bad ids, deleted content still linked from search engines — always miss and always reach the database. The cache gives no protection exactly when traffic is adversarial.

The fix

  1. Negative caching: cache "not found" with a short TTL (e.g. 30–60 s).
  2. Bloom filter of existing ids in front of the database: "definitely not present" answers skip the lookup entirely.
  3. Validate input cheaply (id ranges, checksums in public ids, signed ids) and rate-limit by behaviour, not just IP.
NOT_FOUND = "__nf__"

def get_product(pid):
    hit = r.get(f"product:{pid}")
    if hit == NOT_FOUND:
        return None
    if hit is not None:
        return json.loads(hit)
    if not r.bf().exists("products:ids", pid):          # RedisBloom; add ids on create
        return None
    product = db.fetch_product(pid)
    if product is None:
        r.set(f"product:{pid}", NOT_FOUND, ex=60)       # negative entry, short TTL
        return None
    r.set(f"product:{pid}", json.dumps(product), ex=600)
    return product

Trade-offs

  • Negative entries must be invalidated on create, or a newly created item stays "not found" until the TTL.
  • Bloom filters have false positives (some missing ids still reach the database) but never false negatives; they can't delete entries (use counting/cuckoo filters or rebuild periodically).
  • Negative entries for random ids can fill memory — keep their TTL short and the eviction policy on.

Detect it in production

  • Track the share of cache misses that return "not found" from the database.
  • Unusual id distributions (sequential scans, out-of-range ids) in access logs.

Key points

  • Requests for missing data bypass naive caches and hit the database every time.
  • Cache "not found" briefly; use a Bloom filter for existence checks; validate ids cheaply.
  • Invalidate negative entries when items are created.

Practice questions

What is cache penetration?

Answer

Requests for data that doesn't exist always miss the cache (only found results are cached) and always hit the database — often from enumeration attacks or bad links.

How does negative caching work and what must you remember on create?

Answer

Cache a 'not found' marker with a short TTL so repeated lookups skip the database. When the item is later created, delete the negative entry or it stays 'not found' until the TTL.

What guarantee does a Bloom filter give for existence checks?

Answer

No false negatives: if it says an id is absent, it's definitely absent. It can give false positives, so some missing ids still reach the database.

Open in the app to mark lessons complete, take notes and track your progress.