Database Caching at Scale · Cache-Aside Done Right
Versioned Keys and Generation Counters
Invalidate a whole family of cache entries — category pages, search results, per-user lists — with one increment.
Scenario
Example app: category pages are cached per category, sort order and page (cat:42:price_asc:p3). When a product changes, every cached page of its categories may be wrong — potentially thousands of keys.
What you see in production
The team tried KEYS cat:42:* + DEL on each product update. During a bulk price import it blocked Redis for seconds (KEYS scans the whole keyspace), causing timeouts across the site. Switching to SCAN made invalidation slow and incomplete.
The naive code
def on_product_changed(product):
for cat in product.category_ids:
for key in r.keys(f"cat:{cat}:*"): # O(N) over ALL keys, blocks Redis
r.delete(key)
foreach ($product->categories as $cat) {
foreach (Redis::keys("cat:{$cat->id}:*") as $key) { Redis::del($key); }
}
Reproduce it
Use a generation number per category inside every key. Bumping it makes all old keys unreachable at once; they expire by TTL:
store = {"gen:cat:42": 7}
cache = {}
def page_key(cat, sort, page):
gen = store.get(f"gen:cat:{cat}", 0) # GET gen:cat:42 (or keep it in the page request)
return f"cat:{cat}:g{gen}:{sort}:p{page}"
def get_page(cat, sort, page):
key = page_key(cat, sort, page)
if key not in cache:
cache[key] = f"rendered {sort} page {page}"
return key, "miss"
return key, "hit"
print(get_page(42, "price_asc", 1))
print(get_page(42, "price_asc", 1))
store["gen:cat:42"] += 1 # INCR gen:cat:42 — the whole category is invalidated
print(get_page(42, "price_asc", 1))
print("old keys left to expire by TTL:", [k for k in cache if ":g7:" in k])
Output
('cat:42:g7:price_asc:p1', 'miss')
('cat:42:g7:price_asc:p1', 'hit')
('cat:42:g8:price_asc:p1', 'miss')
old keys left to expire by TTL: ['cat:42:g7:price_asc:p1']Root cause
Deleting a family of keys requires knowing them all. Pattern scans (KEYS, SCAN) are slow, block or miss keys created concurrently, and get worse as the keyspace grows. The relationship "these keys depend on category 42" is better encoded in the key itself.
The fix
def cat_gen(cat):
return int(r.get(f"gen:cat:{cat}") or 0)
def get_category_page(cat, sort, page):
key = f"cat:{cat}:g{cat_gen(cat)}:{sort}:p{page}"
if (hit := r.get(key)) is not None:
return json.loads(hit)
data = render_category(cat, sort, page)
r.set(key, json.dumps(data), ex=900)
return data
def on_product_changed(product):
for cat in product.category_ids:
r.incr(f"gen:cat:{cat}") # O(1) per category
long gen = Optional.ofNullable(redis.opsForValue().get("gen:cat:" + cat)).map(Long::parseLong).orElse(0L);
String key = "cat:%d:g%d:%s:p%d".formatted(cat, gen, sort, page);
// on change: redis.opsForValue().increment("gen:cat:" + cat);
$gen = (int) Cache::get("gen:cat:$cat", 0);
$page = Cache::remember("cat:$cat:g$gen:$sort:p$n", 900, fn () => $this->render($cat, $sort, $n));
// on change: Cache::increment("gen:cat:$cat");
const gen = Number((await redis.get(`gen:cat:${cat}`)) ?? 0);
const key = `cat:${cat}:g${gen}:${sort}:p${page}`;
// on change: await redis.incr(`gen:cat:${cat}`);
The same idea works at any scope: a global schema_version in every key (deploy-safe), a per-user generation for "everything about this user", or a per-tenant generation for a cache flush without FLUSHALL.
Trade-offs
- Each read needs the current generation (an extra
GET, orMGETtogether with the data key, or a short in-process cache of generations). - Old entries linger until their TTL — memory is used until then; keep TTLs reasonable and use an eviction policy.
- Coarse generations over-invalidate (one product change resets every page of a large category); balance granularity against hit ratio.
Detect it in production
- Never allow
KEYSin production (rename/disable it via ACLs); watch Redis slowlog for blocking commands. - Track generation bump rates — a bump storm (bulk import) causes a miss storm; batch bumps per import.
Key points
- Put a generation number in keys of related entries;
INCRinvalidates the family instantly. - Avoid
KEYS/pattern deletes in request paths. - Old entries expire via TTL; size TTLs and eviction accordingly.
Practice questions
Why is `KEYS cat:42:*` followed by DEL dangerous in production?
Answer
KEYS scans the entire keyspace in one blocking operation; on a large Redis it stalls all other commands for its duration. SCAN doesn't block but is slow and can miss keys created concurrently.
How does a generation counter invalidate many cache keys at once?
Answer
Every key in the family embeds the current generation (cat:42:g7:…). Incrementing the generation makes readers build new keys, so all old entries become unreachable immediately and expire by TTL.
What's the cost of generation-based invalidation?
Answer
Readers must fetch the generation (an extra lookup), and unreachable old entries occupy memory until their TTL expires.
Open in the app to mark lessons complete, take notes and track your progress.