Redis Caching: Cache-Aside, TTLs and Invalidation in Practice
System Design & ArchitectureLesson 2 of 45 min read
What you'll learn
- Implement cache-aside reads and delete-on-write invalidation with redis-py
- Pick TTLs, add jitter and understand how long stale data can survive
- Stop a cache stampede with a short rebuild lock
- Design keys that cannot serve one user's or one version's data to another
On this page
A cinema chain's website shows showtimes for each location. They change a few times a day, but the page is requested thousands of times an hour, and every request runs the same expensive query across screens, films and seat availability. This is the textbook case for a cache. The design choices (what to cache, how long, and how to throw it away) are covered in Caching: Strategies, Eviction and Pitfalls. This lesson is the Redis side: the exact commands and the code.
Cache-aside in twenty lines
With cache-aside the application owns the logic: look in Redis; on a miss, load from the database, write the result to Redis with an expiry, and return it. Writes go to the database, and the cached copy is deleted so the next read rebuilds it. Three details matter more than the shape: the key includes a version, the TTL has jitter, and the write path deletes rather than updates.
import json
import random
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
BASE_TTL = 300 # seconds
def showtimes_key(cinema_id):
# ":v1" lets you invalidate every showtimes entry at once by bumping the version.
return f"cinema:{cinema_id}:showtimes:v1"
def fetch_showtimes_from_db(cinema_id):
... # your expensive SQL query; returns a list of dicts
def save_showtimes_to_db(cinema_id, data):
... # your UPDATE/INSERT statements
def get_showtimes(cinema_id):
key = showtimes_key(cinema_id)
cached = r.get(key)
if cached is not None:
return json.loads(cached)
data = fetch_showtimes_from_db(cinema_id)
ttl = BASE_TTL + random.randint(0, 60) # jitter: keys do not all expire at once
r.set(key, json.dumps(data), ex=ttl)
return data
def save_showtimes(cinema_id, data):
save_showtimes_to_db(cinema_id, data) # 1) write the source of truth first
r.delete(showtimes_key(cinema_id)) # 2) then drop the cached copyThe equivalent in redis-cli makes the TTL mechanics visible. SET ... EX 300 stores the value and expiry together in one atomic command. A second SET on the same key without an expiry clears the TTL, so always pass EX again when you overwrite.
127.0.0.1:6379> SET cinema:12:showtimes:v1 "[...]" EX 300
OK
127.0.0.1:6379> EXPIRE cinema:12:showtimes:v1 600
(integer) 1
127.0.0.1:6379> PERSIST cinema:12:showtimes:v1
(integer) 1
127.0.0.1:6379> TTL cinema:12:showtimes:v1
(integer) -1
127.0.0.1:6379> DEL cinema:12:showtimes:v1
(integer) 1How stale can the cache be?
Delete-on-write keeps the cache close to the database, but not perfectly. There is a classic race, shown below as a timeline. A reader misses, reads the old row from the database, and is slow. Meanwhile a writer updates the database and deletes the key. The slow reader then writes its old data into the cache. Now the cache disagrees with the database until the TTL ends.
time Reader A Writer B
1 GET key -> miss
2 read DB (old showtimes)
3 UPDATE DB (new)
4 DEL key
5 SET key (old data) <- stale value cached
6 ...stale until TTL expiresThis is why the TTL is not just a memory-saving device: it is the backstop that bounds how long a mistake can live. Choose it from the business tolerance ("showtimes may be five minutes stale") rather than from a round number. If the data must never be stale, such as a seat that was just sold, do not cache it, or cache only the parts that can lag and check the authoritative store for the rest.
Stampedes: when a hot key expires
When a popular key expires, every request that arrives before the first rebuild finishes misses at the same moment, and they all hit the database together. Jitter helps spread expiry across many keys, but one very hot key needs more. A small lock around the rebuild lets one caller refresh while the others wait briefly and then read the fresh value.
import json
import random
import time
# continues from showtimes_cache.py: r, BASE_TTL, showtimes_key, fetch_showtimes_from_db
def get_showtimes_guarded(cinema_id):
key = showtimes_key(cinema_id)
for _ in range(3):
cached = r.get(key)
if cached is not None:
return json.loads(cached)
# SET NX EX: only one caller wins the right to rebuild; the lock expires on its own.
if r.set(key + ":rebuild", "1", nx=True, ex=10):
try:
data = fetch_showtimes_from_db(cinema_id)
r.set(key, json.dumps(data), ex=BASE_TTL + random.randint(0, 60))
return data
finally:
r.delete(key + ":rebuild")
time.sleep(0.05) # someone else is rebuilding; wait a moment and re-check
return fetch_showtimes_from_db(cinema_id) # fall back rather than fail the requestRelated trick: negative caching. If a request for a cinema id that does not exist hits the database every time, an attacker or a buggy client can bypass your cache entirely. Store a short-lived marker (a few seconds to a minute) for "not found" results so repeated misses are absorbed too.
| Technique | Protects against | Cost |
|---|---|---|
| TTL on every cached key | Memory growth and permanent staleness | Occasional misses |
| TTL jitter | Many keys expiring in the same second | Slightly uneven freshness |
| Delete on write | Serving data that was just changed | Extra work on the write path; a small race remains |
| Rebuild lock | Stampede on one hot key | Short waits and extra code |
| Negative caching | Repeated misses for missing ids | Brief stale "not found" |
Versioned keys (:v2) | Format changes and bulk invalidation | Old keys linger until they expire |
Common mistakes and the symptoms they cause
- No TTL. Symptom: memory grows without bound, and an outdated value survives forever after one missed invalidation.
- Updating the cache instead of deleting it. Symptom: two writers racing leave the cache holding the older of the two values. Deleting and letting the next read rebuild is simpler and safer.
- Caching an empty or error result as if it were real. Symptom: one database hiccup returns an empty list that is then served for the full TTL. Do not cache failures, or cache them for a few seconds only.
- Treating Redis as always available. Symptom: when the cache goes down, every request falls through and the database collapses. Set short client timeouts, catch connection errors and degrade to the database with protection (see the rate limiting course).
- Serialising huge objects. Symptom: slow reads and network saturation from megabyte values. Cache the smallest useful unit.
How this shows up in real projects and interviews
"How do you keep the cache consistent with the database?" is among the most common design questions, and an honest answer admits that you cannot make it perfect: you pick delete-on-write for freshness, a TTL as a safety net, and you say how stale is acceptable for each data type. Real projects spend more time on invalidation and key design than on the Redis calls themselves. Be ready to discuss cache warming after a deploy, what happens on a cold cache, and how you would observe the hit rate, because without that number nobody knows whether the cache helps.
Try it yourself
Add caching to a function that returns a customer's order summary. Decide the key, the TTL (justify it in one sentence) and the invalidation rule for when a new order arrives. Then write down the single stale-data scenario your design still allows and why you accept it.
Show a hint
Include the customer id in the key and delete the key in the same code path that inserts an order. The accepted scenario is usually the reader/writer race from the timeline, bounded by your TTL.
Key takeaways
- Cache-aside: read Redis, fall back to the database on a miss, write back with
SET key value EX seconds; on writes, update the database then delete the key. - Always set a TTL; it is the backstop that bounds staleness when invalidation races or fails, and jitter keeps expiries from lining up.
- A short
SET ... NX EXlock around the rebuild prevents a hot-key stampede; negative caching absorbs repeated lookups of missing ids. - Put every input that changes the answer into the cache key, and version keys so you can invalidate in bulk.
Test yourself
A trick from this lesson's ride: On a write, delete the cache entry instead of patching it, and do it after saving to the database. Four more tricks like it wait along the route, each followed by a question.
Frequently asked questions
How do I set an expiry on a key in Redis?
Use SET key value EX seconds to store a value and its TTL atomically, or EXPIRE key seconds on an existing key. TTL key shows the remaining seconds, -1 means no expiry, and -2 means the key does not exist. Overwriting with a plain SET clears any TTL.
What is the cache-aside pattern with Redis?
The application checks Redis first; on a miss it reads from the database, stores the result in Redis with an expiry and returns it. On updates, the application writes to the database and deletes the cached key so the next read repopulates it.
How do I prevent a cache stampede in Redis?
Add random jitter to TTLs so keys do not expire together, and use a short-lived lock (SET lockkey 1 NX EX 10) so only one request rebuilds a hot key while others wait or serve slightly stale data. Falling back to the database after a bounded wait avoids deadlock.