Redis as a Cache: Invalidation, TTL, and Eviction
Caching is among the most effective performance tools available, and Redis is the near-universal choice for distributed caching in production systems. The basics are simple: store a value with a key, retrieve it later, skip the expensive computation or database query. The parts that determine whether a cache actually improves your system - invalidation, TTL strategy, eviction behavior - are less obvious and more consequential.
The Basic Pattern
import redis
import json
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
def get_user_profile(user_id: int) -> dict:
cache_key = f"user:{user_id}:profile"
# Try cache first
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# Cache miss - fetch from database
profile = db.query("SELECT * FROM users WHERE id = %s", user_id)
# Store in cache with 15 minute TTL
r.setex(cache_key, 900, json.dumps(profile))
return profile
Cache hit: one Redis GET (sub-millisecond). Cache miss: one Redis GET + database query + one Redis SETEX. The speedup comes from the ratio of hits to misses - the cache hit rate.
TTL: How Long to Cache
TTL (Time To Live) is the expiry duration for a cached value. After the TTL expires, Redis deletes the key and the next request is a cache miss.
Setting the right TTL is a tradeoff between staleness and hit rate:
Short TTL (seconds to minutes): data is fresh but many requests hit the database. Good for data that changes frequently or where stale data causes problems.
Long TTL (hours to days): high hit rate, but data can be significantly stale. Good for data that rarely changes or where some staleness is acceptable.
No TTL: data stays in cache until explicitly deleted or evicted by memory pressure. Dangerous unless you have reliable invalidation on writes - forgotten cache entries serve stale data indefinitely.
The default should be a TTL on everything. Explicit invalidation handles the known cases; TTL handles the unknown ones.
# TTL strategy examples
CACHE_TTLS = {
'user_profile': 900, # 15 min - changes occasionally
'product_details': 3600, # 1 hour - changes rarely
'homepage_stats': 60, # 1 min - changes frequently
'currency_rates': 300, # 5 min - external API, rate limited
'user_permissions': 180, # 3 min - security-sensitive, keep short
}
Cache Invalidation
TTL handles expiry by time. Invalidation handles expiry by event: when the underlying data changes, delete the cache entry so the next request fetches the fresh version.
def update_user_profile(user_id: int, data: dict):
# Update the database
db.query("UPDATE users SET ... WHERE id = %s", user_id)
# Invalidate the cache immediately
r.delete(f"user:{user_id}:profile")
The window between the database write and the cache invalidation is a consistency gap - requests in that window see stale data. For most applications this is acceptable (milliseconds). If you need stronger guarantees, you can update the cache with the new value on write rather than deleting it:
def update_user_profile(user_id: int, data: dict):
db.query("UPDATE users SET ... WHERE id = %s", user_id)
# Write-through: update cache with the new value
r.setex(f"user:{user_id}:profile", 900, json.dumps(data))
Write-through eliminates the consistency gap but requires the cached value to be derivable from the write operation. If the database computes additional fields on write (timestamps, triggers, computed columns), the cache update must account for them.
Invalidating related keys. One database write often affects multiple cache keys:
def update_product(product_id: int, data: dict):
db.update_product(product_id, data)
# Invalidate all affected cache keys
r.delete(f"product:{product_id}:details")
r.delete(f"product:{product_id}:summary")
r.delete(f"category:{data['category_id']}:products") # category listing
r.delete("homepage:featured_products") # if this product is featured
This is where cache invalidation earns its reputation as hard. The full set of affected cache keys is often larger than it appears and grows as features are added. Missing an invalidation produces stale data that is sometimes very visible (a product update that doesn’t appear in the category listing) and sometimes invisible (a permissions change that doesn’t take effect for minutes).
Tag-based invalidation. One pattern to manage this: when writing to the cache, tag the entry with all entities it depends on. When an entity changes, delete all entries with that tag.
Redis doesn’t have native tag support, but you can implement it with sets:
def cache_set_with_tags(key: str, value: str, ttl: int, tags: list[str]):
pipe = r.pipeline()
pipe.setex(key, ttl, value)
for tag in tags:
pipe.sadd(f"tag:{tag}", key)
pipe.expire(f"tag:{tag}", ttl)
pipe.execute()
def invalidate_tag(tag: str):
keys = r.smembers(f"tag:{tag}")
if keys:
r.delete(*keys)
r.delete(f"tag:{tag}")
# Usage
cache_set_with_tags(
"product:42:details",
json.dumps(product_data),
ttl=3600,
tags=["product:42", "category:5"]
)
# When product 42 changes:
invalidate_tag("product:42") # deletes all keys tagged with this product
Eviction Policies
Redis operates within a memory limit. When memory fills up, it needs to evict keys. The eviction policy determines which keys get removed.
Configure the memory limit and policy in redis.conf:
maxmemory 2gb
maxmemory-policy allkeys-lru
Common policies:
noeviction (default): return an error when memory is full. Your application starts failing. Good only if you’re sure you’ll never exceed the limit.
allkeys-lru: evict the least recently used key from all keys. The most common choice for a cache - old data is more likely to be stale anyway.
volatile-lru: evict the least recently used key from keys with a TTL set. Preserves keys with no TTL (be careful - these will never be evicted).
allkeys-lfu: evict the least frequently used key. Better than LRU for access patterns where some data is accessed occasionally but must not be evicted (LRU would evict it after a quiet period).
volatile-ttl: evict the key with the shortest remaining TTL. Predictable, biased toward evicting keys that were about to expire anyway.
For a pure cache where all keys have TTLs, allkeys-lru is the standard choice. If Redis serves dual purpose (cache and persistent storage) and you need to protect certain keys, use volatile-lru and set TTLs only on cache keys.
The Cache Stampede
When a popular cache entry expires, many simultaneous requests may all find a cache miss and simultaneously query the database - the cache stampede or thundering herd.
# Vulnerable to stampede: many concurrent requests
# will all execute the expensive_query simultaneously
def get_popular_data():
cached = r.get("popular:data")
if cached:
return json.loads(cached)
result = expensive_query() # all misses run this at the same time
r.setex("popular:data", 300, json.dumps(result))
return result
The fix: distributed locking to let only one request repopulate the cache.
def get_popular_data():
cached = r.get("popular:data")
if cached:
return json.loads(cached)
lock_key = "popular:data:lock"
lock_acquired = r.set(lock_key, "1", nx=True, ex=10) # nx = only if not exists
if lock_acquired:
try:
result = expensive_query()
r.setex("popular:data", 300, json.dumps(result))
return result
finally:
r.delete(lock_key)
else:
# Another request is repopulating - wait briefly and retry
time.sleep(0.1)
return get_popular_data()
A simpler approach where stale data is acceptable: set a longer TTL but also store a “soft expiry” timestamp. On soft expiry, one request asynchronously refreshes the cache while all requests continue serving the stale (but present) cached value.
Monitoring Cache Effectiveness
The metrics that tell you if your cache is working:
Hit rate: keyspace_hits / (keyspace_hits + keyspace_misses). Below 80-90% usually means TTLs are too short or the access pattern is too random for caching to help.
Eviction rate: high eviction means memory pressure. Either increase memory or reduce TTLs to keep the working set smaller.
Memory usage: track used_memory against maxmemory. Approaching the limit means you’re about to start evicting.
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys'
redis-cli INFO memory | grep used_memory_human
A cache with a 95% hit rate is doing its job. A cache with a 40% hit rate is adding latency to 60% of requests (the misses) without proportional benefit.