skip to content

A service is flooded with lookups for identifiers that do not exist in the database, and every one of them falls through Redis to the origin. How would you handle this with cache entries and TTLs, and what risks does that introduce?

level: seniorimportance: should knowfreq 42%

answer

  1. Cache the miss with an explicit sentinel
  2. Negative TTL ≪ positive TTL (seconds)
  3. Three states: value / known-absent / nothing cached
  4. Delete the marker when the entity is created
  5. Unbounded junk IDs → memory + eviction pressure; consider a Bloom filter

basics

~20 s

Cache the miss: store an explicit not-found marker under the same key with a much shorter TTL than positive entries. Risks: the marker must be distinguishable from 'nothing cached', it must be deleted when the entity is created, and attacker-generated keys can flood memory.

solid answer

~1 min

Cache the absence. On a miss that the origin confirms as not-found, write a **sentinel value** under the same cache key with a short TTL — typically seconds to a minute, far shorter than positive entries — so repeated lookups for that identifier are answered from Redis instead of the database. Three things must be right: 1. **Three-state reads.** The lookup must distinguish *cache hit with a value*, *cache hit meaning known-absent*, and *no cache entry*. A `nil` reply from `GET` means the third; the sentinel must be an unambiguous marker, not an empty string. 2. **Invalidate on create.** If the entity is created a second later, the negative entry would hide it for the rest of its TTL. The create path must `DEL`/`UNLINK` the key, and the TTL must be short enough to bound the damage if that delete is missed. 3. **Bound the key space.** Random or hostile identifiers create one negative key each, so the negative entries themselves can consume memory and push out useful data. Keep the TTL small, validate the identifier format before caching anything, and consider a probabilistic filter for the truly unbounded case. Then measure: negative-hit ratio and memory used by that prefix.

code

text · 14 lines
text
# origin confirmed 'not found' -> cache the absence briefly,
# NX so we never clobber a value someone else just cached
> SET user:9999999 "__NF__" EX 30 NX
OK

# read path distinguishes three cases
> GET user:9999999
"__NF__"      # known-absent -> return 404, skip the database
> GET user:12345
(nil)          # nothing cached -> go to the origin

# create path must remove the marker immediately
> UNLINK user:9999999
(integer) 1

go deeper

for a junior

Say that you cache the fact that the item does not exist, using a special marker value and a short TTL, so repeated lookups stop hitting the database.

for a middle

Add the three-state read path, the sentinel-versus-empty-string distinction, and deleting the marker when the entity is created.

for a senior

Discuss the TTL asymmetry, memory and eviction pressure from unbounded identifiers, NX to avoid clobbering a real value, and the metrics you would watch.

for a principal

Frame it as a penetration-defence layer with input validation and rate limiting in front, a probabilistic filter for genuinely unbounded spaces, and a runtime-tunable negative TTL usable as an incident lever.

## The problem: cache penetration A cache only protects the origin for keys it holds. Lookups for identifiers that do not exist never populate anything, so every one of them passes straight through — the pattern is often called cache penetration. It appears innocently (a crawler walking numeric IDs, a client retrying a deleted resource, a broken deep link) and maliciously (someone generating random IDs specifically to bypass the cache). Either way the origin, not Redis, absorbs the traffic, and the expensive part is usually a database index probe that returns nothing. ## The mechanism: cache the absence Write an explicit marker when the origin confirms non-existence: ``` SET user:9999999 "\x00NF" EX 30 NX ``` Subsequent reads hit Redis. The read path becomes three-valued: - `GET` returns `nil` → nothing is cached; go to the origin. - `GET` returns the sentinel → the origin already said not-found; return 404 without touching the database. - `GET` returns anything else → a normal cached value. Use a sentinel that cannot collide with a legitimate value: a byte sequence that is not valid in your serialisation, or a typed envelope such as `{"__nf":true}` if everything is JSON. An empty string is a poor choice precisely because it is a plausible real value in many domains. ## Risk 1: hiding a newly created entity This is the correctness risk. The sequence *read (miss) → origin says no → someone creates the entity → we write the negative entry* leaves a marker that contradicts reality for its whole TTL. Two defences, used together: - **Delete on create.** The write path removes the cache key for the identifier it just created, exactly like any other invalidation. - **Short TTL.** Keep the negative TTL to seconds or tens of seconds so a missed delete self-heals quickly. This asymmetry — negatives much shorter than positives — is the whole point. Writing the sentinel with `NX` also helps: it will not overwrite a positive value that another request cached in the meantime. ## Risk 2: memory and eviction pressure Each distinct bad identifier creates a key. With an unbounded or attacker-controlled key space that is unbounded key growth, and worse, those keys compete with useful data: under `maxmemory` with `allkeys-lru` they can evict real cached values, degrading the hit ratio for legitimate traffic. Controls: - Very short TTLs so the population is bounded by *arrival rate × TTL* rather than by total distinct IDs seen. - Validate the identifier shape (length, format, checksum, range) before the origin call and reject nonsense without caching anything. - Keep negative entries tiny — a few bytes plus key overhead. - Rate-limit per client for the pathological case; negative caching protects the database, it does not stop abuse. ## Risk 3: an unbounded space that repeats rarely If each bad identifier is seen only once, negative caching buys nothing — you cache a miss no one asks about again. The tool for "definitely absent" over a huge space is a probabilistic membership filter (a Bloom or cuckoo filter, available in Redis via the Bloom module): it answers "definitely not present" cheaply for the whole ID space, at the cost of some false positives — meaning some existing IDs are reported as maybe-present and simply fall through, which is safe, whereas a false "absent" is not possible by construction. The filter needs a rebuild or an additive update path as real entities are created. ## Operating it - Track how many reads are answered by sentinels versus real values; if negative hits dominate, something upstream is broken or hostile. - Track memory by key prefix (`MEMORY USAGE` sampling, or a per-prefix accounting job) to see negative entries growing. - Track `evicted_keys`: if it rises when the negative population rises, negatives are pushing out real data and the TTL is too long. - Make the negative TTL a runtime-tunable value. During an incident, raising it from 10 s to 60 s is a cheap way to shed origin load, and lowering it is how you recover freshness once the create-path bug is fixed. ## Summary of the contract A negative entry is a promise of the form "the origin said this did not exist, as of at most N seconds ago". Keep N small, delete the entry when reality changes, make it distinguishable from an empty cache, and watch what it does to memory.

  • What goes wrong if the not-found marker is stored as an empty string?
    The read path can no longer tell a cached absence from a legitimately empty value, and in some clients an empty string is easy to confuse with a nil reply, so a cached absence silently becomes 'nothing cached' or vice versa. Use a sentinel that cannot occur in real data — a reserved byte prefix or a typed envelope field — so the three states stay distinguishable.
  • When is a Bloom filter a better answer than negative cache entries?
    When the space of bad identifiers is huge and each one is requested only once or twice, so individual negative entries never pay for themselves and would balloon memory. A membership filter answers 'definitely not present' for the entire key space in a fixed, small amount of memory. The cost is false positives — some existing IDs are reported as maybe-present and simply fall through to the origin — plus the need to add every newly created entity to the filter.

saying these in an interview costs you the question

  • Storing the not-found marker as an empty string or a nil-equivalent that reads back as 'no cache entry'
  • Giving negative entries the same TTL as positive ones
  • Forgetting to delete the negative entry when the entity is created, so new records appear invisible
  • Assuming negative caching stops abusive traffic rather than merely shielding the database
  • Ignoring that a flood of negative keys can evict genuinely useful cached values under maxmemory

context