Redis — Patterns: Caching, Rate Limits, Locks, Messaging¶
Examples use a redis-py client created with decode_responses=True (04):
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
Cache-Aside¶
The application checks Redis first; on a miss it reads the database and writes the result back with a TTL. Redis never talks to the database itself.
sequenceDiagram
participant App
participant Redis
participant DB
App->>Redis: GET myapp:user:42
alt hit
Redis-->>App: JSON
else miss
Redis-->>App: nil
App->>DB: SELECT ... WHERE id = 42
DB-->>App: row
App->>Redis: SET myapp:user:42 JSON EX 300
end
# app/cache.py
import json
from collections.abc import Callable
import redis
USER_TTL = 300 # seconds
def user_key(user_id: int) -> str:
return f"myapp:user:{user_id}"
def get_user(r: redis.Redis, user_id: int, load: Callable[[int], dict]) -> dict:
key = user_key(user_id)
cached = r.get(key)
if cached is not None: # hit
return json.loads(cached)
user = load(user_id) # miss: read the source of truth
r.set(key, json.dumps(user), ex=USER_TTL)
return user
def update_user(r: redis.Redis, user_id: int, data: dict, save: Callable[[int, dict], None]) -> None:
save(user_id, data) # 1. write the database
r.delete(user_key(user_id)) # 2. drop the cached copy
This module is the code under test in 07 — Testing Recipes.
Invalidation Strategies¶
| Strategy | How | Risk |
|---|---|---|
| TTL only | Entry expires after N seconds | Stale data for up to N seconds |
| Delete on write | DEL after the DB commit (above) |
A concurrent reader can put the old value back right after the delete; a short TTL limits the damage |
| Write-through | Update the DB and SET the new value |
Two writers can leave Redis and DB with different values |
| Versioned keys | myapp:v7:user:42; bump the version to drop everything |
Old keys stay until their TTL ends |
| Event-driven | Consume DB change events (outbox, CDC) and delete keys | More moving parts, but covers writes from other services |
Rules that prevent most cache bugs:
- Delete, do not update, on write — the next read rebuilds the value from the database.
- Delete after the DB transaction commits, not before: deleting first lets a reader cache the old row again.
- Always keep a TTL, even with explicit invalidation — it caps the life of any value you forgot to invalidate.
- Cache
Noneresults carefully — without a short negative-cache entry, requests for a missing ID always hit the DB (cache penetration); with a long one, a newly created record stays "not found". - Put everything that changes the result in the key: tenant, user permissions, locale, API version.
Stampedes and Mass Expiry¶
When a hot key expires, many requests miss at once and all hit the database.
import random
def cache_set(r: redis.Redis, key: str, value: str, ttl: int = 300) -> None:
r.set(key, value, ex=ttl + random.randint(0, ttl // 10)) # jitter: keys written together do not expire together
Other options: let only one caller rebuild (SET myapp:rebuild:<key> 1 NX EX 10, others serve the stale value or wait briefly), or refresh hot keys in the background before they expire.
Rate Limiting¶
Fixed Window¶
One counter per subject per window. INCR creates the key; EXPIRE ... NX sets the TTL only on the first hit.
# app/ratelimit.py
import time
from collections.abc import Callable
import redis
def allow(
r: redis.Redis,
subject: str,
limit: int,
window_s: int,
now: Callable[[], float] = time.time, # injectable clock for tests
) -> bool:
window = int(now() // window_s)
key = f"myapp:rl:{subject}:{window}"
pipe = r.pipeline() # MULTI/EXEC by default
pipe.incr(key)
pipe.expire(key, window_s, nx=True) # set TTL only on the first hit (Redis 7.0+)
count, _ = pipe.execute()
return count <= limit
Simple and cheap, but a client can send 2 × limit requests around a window boundary (end of one window + start of the next).
Sliding Window¶
A sorted set of request timestamps; a Lua script makes "clean up, count, add" one atomic step.
import time
import uuid
SLIDING_WINDOW = r.register_script("""
local key, now_ms, window_ms, limit = KEYS[1], tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', key, 0, now_ms - window_ms)
if redis.call('ZCARD', key) >= limit then
return 0
end
redis.call('ZADD', key, now_ms, ARGV[4])
redis.call('PEXPIRE', key, window_ms)
return 1
""")
def allow_sliding(r: redis.Redis, subject: str, limit: int, window_s: int) -> bool:
now_ms = int(time.time() * 1000)
member = uuid.uuid4().hex # unique member: two requests in the same ms both count
return SLIDING_WINDOW(keys=[f"myapp:rl:sliding:{subject}"], args=[now_ms, window_s * 1000, limit, member]) == 1
[allow_sliding(r, "user:1", 3, 10) for _ in range(5)] # [True, True, True, False, False]
Exact, but memory grows with the limit (one member per request). Token bucket and GCRA are other common algorithms; libraries such as limits implement them on Redis.
Distributed Locks¶
A Lock by Hand¶
import uuid
RELEASE = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
""")
def acquire(r: redis.Redis, name: str, ttl_ms: int) -> str | None:
token = uuid.uuid4().hex
return token if r.set(f"myapp:lock:{name}", token, nx=True, px=ttl_ms) else None
def release(r: redis.Redis, name: str, token: str) -> bool:
return RELEASE(keys=[f"myapp:lock:{name}"], args=[token]) == 1 # delete only our own lock
NX— only one client gets the key;PX— the lock disappears if the owner crashes.- The random token plus compare-and-delete in Lua prevents deleting a lock that already expired and was taken by someone else. A plain
DELinfinallydoes exactly that.
redis-py Lock¶
from redis.exceptions import LockError
with r.lock("myapp:lock:import", timeout=30, blocking_timeout=5) as lock: # LockError if not acquired in 5 s
run_import()
lock.extend(30) # add 30 s to the remaining TTL for a long step
finish_import()
lock = r.lock("myapp:lock:nightly-report", timeout=60, blocking=False)
if lock.acquire(): # False at once if someone else holds it
try:
build_report()
finally:
lock.release() # LockNotOwnedError if the lock already expired
timeout is the lock TTL (seconds); without it the lock never expires. blocking_timeout is how long to wait for it.
Caveats¶
| Problem | What happens | Mitigation |
|---|---|---|
| Work takes longer than the TTL | Lock expires, a second worker starts, both run | Generous TTL, extend() from the worker, measure job duration |
| Process pause (GC, VM freeze, debugger) | Same as above, without any error in the first worker | Fencing token: a counter (INCR) that the protected resource checks and rejects if older |
| Failover to a replica | Replication is async: a new primary may not know about the lock | Accept it, or use a system with consensus (etcd, ZooKeeper, DB row locks) |
| Clock jumps on the Redis server | TTL counts wrong | NTP with slewing, not stepping |
| Redlock (lock on N independent servers) | Reduces the failover issue, but its safety under pauses and clock drift is disputed | Use Redis locks for efficiency (avoid duplicate work), not as the only guarantee of correctness |
For correctness put a guard in the system of record too: a unique constraint, a WHERE version = ? update, or an idempotency key.
Pub/Sub vs Lists vs Streams¶
# Pub/Sub: fire-and-forget
sub = r.pubsub()
sub.subscribe("myapp:events:orders")
r.publish("myapp:events:orders", '{"id": 1}') # returns the number of receivers: 1
r.publish("myapp:events:orders", "lost") # 0 receivers if nobody is subscribed: the message is dropped
# Stream with a consumer group: at-least-once
r.xgroup_create("myapp:orders", "billing", id="0", mkstream=True)
r.xadd("myapp:orders", {"order_id": 1}, maxlen=100_000, approximate=True)
for stream, entries in r.xreadgroup("billing", "worker-1", {"myapp:orders": ">"}, count=10, block=5000):
for entry_id, fields in entries:
handle(fields)
r.xack("myapp:orders", "billing", entry_id) # not acked → stays in XPENDING, can be reclaimed
| Pub/Sub | List (RPUSH / BLPOP) |
Stream + consumer group | |
|---|---|---|---|
| Delivery | To everyone subscribed now | To one consumer | To one consumer per group; every group gets all entries |
| Offline consumer | Misses messages | Messages wait | Entries wait |
| Consumer crash | Message lost | Popped job lost (unless LMOVE) |
Entry stays pending; XAUTOCLAIM gives it to another consumer |
| Replay / history | No | No | Yes, by ID range, until trimmed |
| Acknowledgement | No | No | XACK |
| Good for | Cache invalidation broadcasts, WebSocket fan-out | Simple job queues | Event processing, work queues that must not lose jobs |
Consumers of a stream must be idempotent: after a crash between "handle" and XACK, the entry is delivered again. For long retention, partitions and many independent consumers at high volume, a log such as Kafka fits better.
Transactions: MULTI / EXEC / WATCH¶
MULTI queues commands; EXEC runs them one after another with no other client's command in between.
MULTI
INCR myapp:orders:count
EXPIRE myapp:orders:count 3600
EXEC -- 1) (integer) 1 2) (integer) 1
It is not a rollback transaction: if one command fails at runtime (for example INCR on a text value), the others still run.
with r.pipeline() as pipe: # transaction=True by default
pipe.set("s", "text")
pipe.incr("s") # will fail on the server
pipe.set("after", 1)
pipe.execute() # raises ResponseError — but "s" and "after" were written
# pipe.execute(raise_on_error=False) → [True, ResponseError(...), True]
WATCH adds optimistic locking: EXEC does nothing and redis-py raises WatchError if a watched key changed.
from redis.exceptions import WatchError
def withdraw(r: redis.Redis, account: int, amount: int) -> bool:
key = f"myapp:balance:{account}"
with r.pipeline() as pipe:
while True:
try:
pipe.watch(key) # from here commands run immediately
balance = int(pipe.get(key) or 0)
if balance < amount:
pipe.unwatch()
return False
pipe.multi() # from here commands are queued
pipe.decrby(key, amount)
pipe.execute() # WatchError if key changed since WATCH
return True
except WatchError:
continue # another client wrote first: retry
r.transaction(func, *watched_keys) wraps the same retry loop. For logic like this a Lua script is usually simpler.
Pipelines¶
A pipeline sends many commands in one network round trip. It is about latency, not atomicity.
with r.pipeline(transaction=False) as pipe: # no MULTI/EXEC, just batching
for i in range(1000):
pipe.set(f"myapp:item:{i}", i, ex=3600)
results = pipe.execute() # list of 1000 replies
On a local server 1000 single SET calls took about 64 ms, the pipeline about 8 ms; over a real network the difference is much larger. Keep batches to thousands of commands, not millions — the server buffers all replies in memory.
Lua Scripts¶
A script runs atomically on the server: no other command runs in the middle. Use it for "read, decide, write" logic (locks, rate limits, compare-and-set).
# EVAL: keys first, then arguments
r.eval("return redis.call('INCRBY', KEYS[1], ARGV[1])", 1, "myapp:counter", 5) # 5
# register_script: sends EVALSHA, falls back to EVAL on NOSCRIPT (after a restart or SCRIPT FLUSH)
cas = r.register_script("""
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('SET', KEYS[1], ARGV[2])
return 1
end
return 0
""")
cas(keys=["myapp:config:mode"], args=["v1", "v2"])
- Pass every key through
KEYS— in Redis Cluster all keys of one script must be in the same slot. - A long script blocks the whole server (
busy-reply-threshold, 5 s by default, then onlySCRIPT KILL/SHUTDOWN NOSAVEwork). Keep scripts short, no loops over large collections. - Redis 7+ also has functions (
FUNCTION LOAD,FCALL): named, persisted with the data set, better for shared server-side logic. - fakeredis runs Lua only with the
luaextra (fakeredis[lua]); redis-pyLockneeds it too.
Other Common Patterns¶
| Pattern | Commands |
|---|---|
| Idempotency key for API requests / webhooks | SET myapp:idem:<key> <response-id> NX EX 86400 — nil means "already processed" |
| Session store | HSET myapp:session:<id> ... + EXPIRE, or JSON string + GETEX ... EX for sliding expiry |
| Leaderboard | ZINCRBY, ZRANGE ... REV WITHSCORES, ZREVRANK |
| Unique visitors | SADD per day, or PFADD / PFCOUNT when approximate is enough |
| Feature flags | HGET myapp:flags <name> with a local in-process cache |
| Delayed jobs | Sorted set with run time as score, a poller moves due jobs to a queue |
See also¶
- Redis — Overview
- Redis — Data Types & Commands
- Redis — Testing Recipes
- REST: Caching, Concurrency and Idempotency
- Queues vs Streams: Message Delivery, Ordering & Reliability