Skip to content

I Almost Deleted My Cache One Key at a Time

Adityo Guni Waluyo

Mass-deleting Redis keys felt right until I read the docs. Versioned keys with INCR bumps make stale chat answers unreachable instead.

TL;DR

Editing an article left the Redis-cached AI answers stale, and the author's first fix—scanning and deleting every ask: key—turned out to solve the wrong problem. SCAN is non-blocking; the real pain is tracking key families across workers forever. Versioned keys fix it: bake versions into key names, bump with INCR on edits, and let TTL clean up.

My blog has an AI chat endpoint. You ask a question about an article, it streams the answer back over SSE, and I cache the full answer in Redis so the same question doesn't burn tokens twice. The cache key looks like this: ask:v1:kn:7:art:42:e3a9c1d, TTL of 86400 seconds, one day.

Then I edited an article. And the cache kept serving the answer built from the old version of it. Stale answer, streamed confidently, word by word.

My first instinct: write a job that scans Redis for every key starting with ask:, deletes them all, done. I even sketched the SCAN loop with a cursor and a batch DEL before I stopped to check what SCAN actually does. Good thing I did, because the thing I was worried about wasn't the problem, and the real problem was something I hadn't even thought about.

My wrong guess

I assumed mass deletion would be dangerous because SCAN blocks Redis. That's the folklore: "don't iterate keys in production." So I half-planned to do it at 3am, off-peak, like some kind of database burglar.

The docs say otherwise. SCAN is incremental and non-blocking by design. Each call is O(1), a full iteration is O(N) spread across calls, and it exists precisely to replace blocking full-key iteration [1]. Redis itself ships it as the polite way to walk a keyspace while the server keeps serving.

So SCAN wasn't my enemy. But reading further, I found the actual trap, and it's not performance.

The real problem is fan-out

To delete everything related to the chat cache, I have to know every key that belongs to that feature. Today that's one prefix. Tomorrow I add a per-persona cache, a rate-limit counter, a history window (I keep the last 8 asked questions per article via MGET). Now one article edit means tracking four key families across every worker, forever. Miss one family and stale data survives. That's the classic invalidation problem: removing cache when data is no longer valid is easy, coordinating it across layers is the hard part [3].

And even if I track everything perfectly, deletion costs round trips. Hundreds of DELs, one per key, from every worker that notices a change. Fan-out grows with my key count.

There's a sneakier detail. Old keys don't vanish the moment they become stale. They sit in memory, valid-looking, until TTL expires or something deletes them. Invalidation doesn't delete, it makes keys unreachable. Which raises the obvious question: if unreachable keys are fine, why am I paying to delete them at all?

Versioned keys: delete by never touching

The answer that stuck: don't delete anything. Bake the data version into the key itself, the cache-busting trick of versioned keys [5].

The cache key becomes ask:v1:kn:{kn_ver}:art:{art_ver}:{sha1}. When an article changes, I bump art_ver. When knowledge base content changes, I bump kn_ver. New requests build keys with the new numbers, old keys sit there until TTL quietly reclaims them. Zero deletes, zero scans, zero tracking of key families.

Bumping is trivially cheap. Redis INCR is atomic and it's literally the official counter pattern [2]. One command per namespace.

My bump happens after the DB commit, fire-and-forget through asyncio.create_task, because cache versioning should never block or roll back a content save. When one edit touches both namespaces, the two INCRs go through MULTI/EXEC so they're serialized and never interleaved with other commands [6]. Old keys linger for up to a day. I'm fine with that. Nobody needs the old answer; they just need new requests to miss.

Why I'd pick this over pub/sub

The fashionable alternative is pub/sub invalidation: publish a "article 42 changed" event, every worker listens and evicts locally. I've read it recommended often. I still think versioned keys win.

Pub/sub is an extra moving part that has to be up when the publish fires, or a worker silently keeps stale data. Versioning has no listener to miss. The correctness lives in the key string itself, which means it works across workers, deploys, and even a Redis flush-and-restart. It's the same trick that makes lockfiles and content hashes work: name things after their content and staleness becomes impossible to construct.

One honest cost: memory. Stale keys occupy space until TTL. With a 24h TTL and modest traffic, that's noise. If it ever isn't, the upgrade path is a lazy cleanup job, not a redesign.

The failure mode I now watch for

Versioned keys fix staleness at the key level. They do nothing for a subtler problem: the LLM answer itself. If a model once generated a subtly wrong answer and I cached it, every visitor asking the same question gets the same confident wrong answer, streamed nicely, for a full day. This is the cached-hallucination risk, and streaming makes it feel more authoritative, not less [4].

So the cache stores the answer, but the persona fallback path (assembled from the knowledge base when the LLM is down) exists precisely because cached text is a convenience, not a source of truth. And any answer flagged as wrong gets its key invalidated by hand: bump the version, everything regenerates. One INCR, no scan loop, no 3am key hunt.

That's the trade I took. Slightly dumber memory usage, in exchange for never having to enumerate my own cache again.

Sumber / Sources:

[1] https://redis.io/docs/latest/commands/scan [2] https://redis.io/docs/latest/commands/incr [3] https://redis.io/glossary/cache-invalidation [4] https://upstash.com/blog/sse-streaming-llm-responses [5] https://medium.com/@sonal.sadafal/cache-busting-with-versioned-keys-keep-your-cache-fresh-fast-and-reliable-0e96a7194a27 [6] https://redis.io/docs/latest/develop/using-commands/transactions

Related articles