Caching with redis.asyncio Clients¶
A process-local cache is free and per-worker; Redis is shared, survives a deploy, and costs a network round trip. That round trip is the entire design problem. Measured on a loopback Redis, reading 1,000 keys one at a time took 61 ms, issuing the same reads concurrently took 35 ms, and a single MGET took 1.3 ms — a 48x difference decided purely by how many times you cross the network. This guide covers the client-side decisions that make a Redis cache fast and predictable: pooling and its surprising failure mode, batching, serialisation, and the TTL rule that quietly caches things forever.
Prerequisites¶
- Python 3.11+ with
redis(pip install redis); measurements use redis-py 5.3 against Redis 8. - Cache semantics from preventing cache stampedes in asyncio.
- Connection pooling from Connection Pooling & Keepalive.
1. Create one client, with a pool you sized¶
redis_client = redis.from_url(
REDIS_URL,
max_connections=32, # concurrent commands, not requests
decode_responses=False, # bytes: you handle serialisation
socket_timeout=1.0, # never wait forever on a cache
socket_connect_timeout=1.0,
health_check_interval=30,
)
One client per process, created in the lifespan and closed at shutdown. The client is a pool: concurrent commands take separate connections and are multiplexed by the library, so 1,000 concurrent GETs over a 32-connection pool completed in 35 ms.
socket_timeout is the important one. A cache is an optimisation, and an optimisation that can hang is worse than no cache — bound it and treat a timeout as a miss.
Verify: the connection count to Redis is stable and well below max_connections in normal operation.
2. Know what the pool does when it runs out¶
This is the behaviour that surprises people coming from other clients:
default pool (max_connections=2) 4 blocking ops -> ConnectionError: Too many connections
BlockingConnectionPool(max=2) 4 blocking ops -> completed in 2.04 s
The default redis.asyncio pool raises rather than queues. For ordinary fast commands that is almost impossible to hit; for blocking commands (BLPOP, XREAD with BLOCK) or a slow Redis, it turns a capacity problem into a stream of errors.
from redis.asyncio.connection import BlockingConnectionPool
client = redis.Redis(connection_pool=BlockingConnectionPool.from_url(
REDIS_URL, max_connections=32, timeout=1.0)) # queue, with a bounded wait
Choose deliberately: fail fast when the cache is strictly optional, queue with a timeout when commands are expensive and errors are noisier than waiting. Never use blocking commands on the same client as your cache reads — give them their own client, as a bulkhead.
Verify: under a burst larger than the pool, you get the failure mode you chose.
3. Batch everything you can¶
The round trip dominates everything else, so the cache API should be plural:
async def get_many(keys: list[str]) -> dict[str, bytes | None]:
values = await redis_client.mget(keys) # one round trip
return dict(zip(keys, values))
async def set_many(items: dict[str, bytes], ttl: int) -> None:
async with redis_client.pipeline(transaction=False) as pipe:
for key, value in items.items():
pipe.set(key, value, ex=jittered(ttl))
await pipe.execute() # one round trip
Measured: 1,000 writes took 69 ms one at a time and 12 ms pipelined; 1,000 reads took 61 ms individually and 1.3 ms via MGET. transaction=False sends the commands without wrapping them in MULTI/EXEC, which is what you want for cache writes — they are independent, and the transaction adds round trips and semantics you do not need.
A request handler that looks up ten things should issue one MGET, not ten GETs. Designing the cache interface around lists rather than single keys is what makes that natural instead of an optimisation someone has to remember.
Verify: the Redis command count per request is closer to one than to the number of cached values.
4. Get the TTL rules right¶
Two Redis behaviours cause most "why is this stale" incidents:
SET key value EX 5 -> ttl 5
SET key value -> ttl -1 (the TTL is cleared)
SET key value KEEPTTL -> ttl unchanged
A plain SET to refresh a value removes its expiry, and the key is then cached forever. Verified on Redis 8. Every cache write must carry ex=, or keepttl=True when deliberately refreshing a value in place.
Add jitter so keys written together do not expire together:
def jittered(ttl: int) -> int:
return int(ttl * random.uniform(0.9, 1.1))
And remember TTL returns -1 for a key with no expiry and -2 for a key that does not exist — a distinction worth handling explicitly when auditing a cache.
Verify: a scan of your cache namespace finds no keys with a TTL of -1.
5. Choose a serialisation and version the keys¶
Values go over the wire as bytes, so every read and write pays serialisation. A JSON round trip of a small object measured 3.3 µs — negligible against a 1.3 ms batched read, and not negligible if you serialise a thousand objects individually in a loop.
def encode(obj) -> bytes:
return json.dumps(obj, separators=(",", ":")).encode()
JSON is the sensible default: readable in redis-cli, language-agnostic, debuggable. Reach for msgpack or protobuf when payloads are large and the difference shows up in a profile — and never pickle, which makes every cached value a remote code execution path if Redis is ever compromised.
Version the key prefix:
KEY = "app:v3:user:{user_id}"
When the cached structure changes, bumping v3 to v4 invalidates everything instantly with no deletion pass and no ambiguity about which format a key holds — the old keys expire on their own. That one convention prevents the worst class of cache bug: new code reading values written by old code.
Verify: deploying a format change requires only a prefix bump, and mixed-format reads are impossible.
Verification¶
A Redis cache is well built when:
- One client per process, pooled, with timeouts set.
- The pool's exhaustion behaviour is chosen, not discovered.
- Reads and writes are batched with
MGETand pipelines. - Every write carries a TTL, with jitter.
- Keys are namespaced and versioned.
- Cache failures degrade to a miss rather than an error.
Pitfalls & edge cases¶
- A plain
SETon refresh. Clears the TTL and caches the value forever. - One
GETper value in a loop. 48x slower thanMGETin the measurement above. decode_responses=Truewith binary payloads. It tries to UTF-8 decode everything; keep bytes for serialised data.- Blocking commands sharing the cache client. They occupy pool connections and can exhaust it.
- Treating a cache error as fatal. Catch
RedisError, count it, and fall through to the origin. - Pickle as the format. A compromised or shared Redis becomes arbitrary code execution.
- Unbounded key growth. Without TTLs, Redis evicts by
maxmemory-policy— or refuses writes if the policy isnoeviction.
Frequently Asked Questions¶
How do I use Redis as a cache from asyncio?
Create one redis.asyncio client per process with an explicit max_connections and socket timeouts, store it in your lifespan state, and close it at shutdown. Use MGET and pipelines to batch, always write with an ex TTL, and treat any Redis error as a cache miss rather than a request failure.
Why does redis.asyncio raise "Too many connections"?
Because the default pool fails fast instead of queueing when max_connections is reached. Raise the limit, or use BlockingConnectionPool with a timeout — measured, four blocking operations on a two-connection blocking pool completed in 2.04 s rather than erroring.
Is MGET really faster than concurrent GETs?
Substantially. Reading 1,000 keys took 61 ms sequentially, 35 ms with all the GETs issued concurrently, and 1.3 ms with one MGET. Concurrency hides latency; batching removes the round trips entirely.
Why did my Redis cache key stop expiring?
Almost certainly a plain SET refreshed it. SET without EX or KEEPTTL clears the existing TTL, leaving the key cached forever — verified, TTL returned -1 afterwards. Always pass an expiry, or keepttl=True when refreshing in place.
How should I invalidate cached data when its format changes?
Version the key prefix — app:v3:user:42 — and bump the version with the deploy. Old keys become unreachable and expire on their own, so there is no deletion pass and no chance of new code reading a value written in the old format.
Related¶
- Async Caching & Deduplication — up to the topic overview.
- Preventing cache stampedes in asyncio — what to do when a hot key expires.
- Invalidating caches across async workers — keeping local caches consistent with Redis.
- Concurrent Execution & Worker Patterns — the section overview.