Redis Patterns skill for Claude Code
Caches, limits and locks that survive a flush. Claude decides first whether Redis is a cache, a coordination layer or a primary store, and configures eviction and persistence to match. Keys get a naming scheme and TTLs, caches invalidate after commit with jitter and stampede guards, locks release only with an owner check, rate limiters run as atomic Lua, and memory problems are traced with --bigkeys and MEMORY USAGE instead of KEYS.
- name
redis-patterns - size226 lines · ≈2,450 tokens
- files1
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Applies proven Redis and Valkey patterns - cache-aside with TTL jitter and stampede protection, rate limiters (fixed window, sliding window, token bucket in Lua), distributed locks with SET NX PX and safe release, Streams consumer groups, key naming, eviction policy choice, and memory diagnosis with --bigkeys and MEMORY USAGE. Use when adding a cache, building rate limiting or locking on Redis, using Redis as a queue, Redis memory or latency spikes, choosing maxmemory-policy, or reviewing code that calls KEYS, large hashes, or keys without TTLs. Try asking
- Add a Redis cache in front of our product lookup without serving stale prices
- Build a per-user API rate limiter on Redis that allows short bursts
- Redis memory doubled this week - find what's eating it
Use it when
- You are adding a cache or session store backed by Redis or Valkey
- You need rate limiting, locks, or deduplication across app instances
- You want a lightweight queue with acknowledgements using Streams
- Redis memory, evictions, or latency spiked
Skip it when
- You need a full job system with retries and schedules - use background-jobs
- The question is HTTP or CDN caching headers - use http-caching
How to check the result
Stop Redis and confirm requests still succeed via the source of truth, run a parallel-client test against limiters and locks, and check that evicted_keys and keys without TTL stay flat.
The common mistake it prevents
Releasing a lock with a plain DEL after it expired deletes another worker's lock, letting a third worker in and running the critical section twice.
Redis is used well when every key has an owner, a naming scheme, and either a TTL or a documented reason to live forever; when the system stays correct if Redis is flushed or unavailable (unless Redis is explicitly the system of record); and when no command scans or returns an unbounded amount of data on the main thread.
Before changing anything
redis-cli INFO server | grep -E 'redis_version|valkey_version'
redis-cli INFO memory | grep -E 'used_memory_human|maxmemory_human|maxmemory_policy|mem_fragmentation_ratio'
redis-cli INFO stats | grep -E 'evicted_keys|expired_keys|keyspace_hits|keyspace_misses'
redis-cli INFO keyspace
redis-cli CONFIG GET appendonly # persistence mode
redis-cli SLOWLOG GET 10
Decide what Redis is in this system: a cache (data loss acceptable), a coordination layer (locks, rate limits, short-lived state), or a primary store (requires AOF, replication, backups). Most designs go wrong by drifting from the first to the third without changing configuration.
Key design
- Format:
<app>:<entity>:<id>[:<facet>]-shop:user:42:profile,shop:ratelimit:ip:1.2.3.4. - Version cached shapes in the key (
shop:v3:product:99) so a deploy that changes the serialized shape does not read old entries; old versions expire via TTL. - Keep keys short but readable; millions of keys make long prefixes cost real memory.
- Redis Cluster: multi-key commands and Lua scripts need all keys in one hash slot. Use a
hash tag:
cart:{user42}:itemsandcart:{user42}:totalshare a slot. - Pick the type for the access pattern:
| Need | Type |
|---|---|
| Opaque cached blob | String (JSON/MessagePack), with TTL |
| Object with independently updated fields | Hash (HSET, HINCRBY); per-field TTL via HEXPIRE on Redis 7.4+ |
| Leaderboard, time-ordered index, sliding window | Sorted set |
| Unique membership | Set; HyperLogLog (PFADD/PFCOUNT) for approximate counts |
| Durable work queue with acks | Stream with consumer groups |
| Fire-and-forget broadcast | Pub/Sub (no persistence, at-most-once) |
Cache-aside
import json, random
def get_product(pid: int) -> dict:
key = f"shop:v3:product:{pid}"
cached = r.get(key)
if cached is not None:
return json.loads(cached)
product = db.fetch_product(pid) # source of truth
ttl = 300 + random.randint(0, 60) # jitter avoids synchronized expiry
r.set(key, json.dumps(product), ex=ttl)
return product
def update_product(pid: int, data: dict) -> None:
db.update_product(pid, data)
r.delete(f"shop:v3:product:{pid}") # invalidate after commit, not before
- Invalidate (delete) on write rather than writing the new value into the cache; it avoids races where an older read repopulates stale data after a newer write.
- Cache misses for nonexistent rows too (short TTL sentinel) to stop repeated DB hits.
- TTL jitter of 10-20% prevents mass expiry at the same second.
Stampede protection for expensive values (many requests miss simultaneously):
lock_key = f"{key}:rebuild"
if r.set(lock_key, "1", nx=True, ex=30): # one rebuilder
try:
value = rebuild(); r.set(key, value, ex=ttl)
finally:
r.delete(lock_key)
else:
value = serve_stale_or_wait(key) # short sleep + retry, or a stale copy
Alternatives: store a soft expiry inside the value and refresh early probabilistically,
or keep a stale copy (key:stale) with a longer TTL.
Rate limiting
| Algorithm | Pros | Cons | Implementation |
|---|---|---|---|
| Fixed window | 2 commands, cheap | Burst of 2x at window edges | INCR + EXPIRE |
| Sliding window log | Exact | Memory per request | Sorted set of timestamps |
| Sliding window counter | Good accuracy, cheap | Approximate | Two fixed-window counters weighted |
| Token bucket | Allows controlled bursts | Needs atomic script | Lua script with tokens + timestamp |
Fixed window (atomic in a pipeline/transaction):
key = f"api:rl:{user_id}:{int(time.time() // 60)}"
pipe = r.pipeline()
pipe.incr(key)
pipe.expire(key, 61)
count, _ = pipe.execute()
allowed = count <= 100
Token bucket as a Lua script (atomic, one round trip):
-- KEYS[1] bucket key; ARGV: capacity, refill_per_sec, now_ms, cost
local cap, rate, now, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
local b = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(b[1]) or cap
local ts = tonumber(b[2]) or now
tokens = math.min(cap, tokens + (now - ts) / 1000 * rate)
local allowed = tokens >= cost
if allowed then tokens = tokens - cost end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate * 1000) + 1000)
return allowed and 1 or 0
Pass now_ms from the app or use redis.call('TIME'); be consistent across instances.
Return limit headers (RateLimit-* / Retry-After) from the app. Broader rate-limit
design belongs to rate-limiting.
Distributed locks
import uuid
token = str(uuid.uuid4())
if r.set("lock:invoice:991", token, nx=True, px=30_000):
try:
do_work() # must finish well within 30 s, or extend the lock
finally:
release(keys=["lock:invoice:991"], args=[token])
-- release: delete only if we still own it
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end
- Never release with a plain
DEL: if your lock expired and someone else acquired it, you delete their lock. - A Redis lock is an efficiency lock (avoid duplicate work), not a correctness guarantee: GC pauses, failover with async replication, and clock jumps can let two holders overlap. For correctness, also use a fencing token checked by the resource (e.g. a monotonically increasing version written in the DB with a conditional update), or use database locks (see transactions-locking).
- Redlock across multiple independent masters reduces but does not remove these risks.
Streams as a queue
XADD jobs MAXLEN ~ 100000 * type email user 42 # producer, capped
XGROUP CREATE jobs workers $ MKSTREAM # once
XREADGROUP GROUP workers w1 COUNT 10 BLOCK 5000 STREAMS jobs >
XACK jobs workers 1718000000000-0 # after successful processing
XPENDING jobs workers - + 10 # stuck/unacked entries
XAUTOCLAIM jobs workers w2 60000 0-0 COUNT 10 # take over entries idle > 60 s
- Processing must be idempotent: entries can be delivered more than once after a consumer crash.
- Track delivery counts from
XPENDING; move entries past N attempts to a dead-letter stream. - Trim with
MAXLEN ~orMINID; untrimmed streams grow until memory runs out. - For complex job semantics (retries, schedules, priorities), prefer a job library (BullMQ, Sidekiq, Celery) over hand-rolled streams; see background-jobs.
Memory and eviction
| Use | maxmemory-policy |
|---|---|
| Pure cache | allkeys-lru or allkeys-lfu (skewed popularity) |
| Mixed: cache keys have TTL, others must persist | volatile-lru / volatile-lfu (only keys with TTL evicted) |
| Primary store / queues / locks must never vanish | noeviction (writes fail when full; alert well before) |
Always set maxmemory explicitly (leave headroom for fork during RDB/AOF rewrite and
for fragmentation, e.g. 70-80% of the instance’s RAM on self-hosted).
Diagnose memory:
redis-cli --bigkeys # largest key per type (SCAN-based, safe-ish; run off-peak)
redis-cli --memkeys # largest by memory (newer redis-cli)
redis-cli MEMORY USAGE shop:user:42:profile
redis-cli --scan --pattern 'shop:session:*' | head
redis-cli OBJECT ENCODING somekey # listpack vs hashtable etc.
redis-cli MEMORY DOCTOR
- Keys without TTL: sample with
SCAN+TTL(returns -1 for no expiry) to find leaks. - Many small hashes are compact (listpack encoding) until they exceed
hash-max-listpack-entries/-value; very large single hashes or sets are “big keys” that block on delete and skew cluster slots.
Hard rules
- Never
KEYS *in production; useSCANwithMATCHandCOUNT. - Never
FLUSHALL/FLUSHDBor bulk deletes on a shared instance without explicit user confirmation and verifying the target (redis-cli -h ... INFO server). - Delete big keys with
UNLINK(non-blocking), notDEL. - Avoid O(N) commands on big collections in hot paths:
HGETALL,SMEMBERS,LRANGE 0 -1,ZRANGE 0 -1. Paginate withHSCAN/SSCAN/ZSCANor ranges. - Use pipelining for batches; avoid one round trip per key in loops.
- Set client timeouts and handle Redis being down: caches should fall back to the source of truth, not fail the request.
- Lua scripts block the server while running; keep them short and bounded.
- Licensing changed in 2024-2025 (Redis 8 offers AGPL among its licenses; Valkey is the
BSD fork). Commands in this skill work on both unless noted; check version for newer
features like
HEXPIRE.
Verification
- Cache: hit ratio from
keyspace_hits / (hits + misses)and app metrics; confirm behavior with Redis stopped (requests still succeed, slower). - Rate limiter/lock: concurrency test with parallel clients (e.g. 50 workers) shows limits honored and no double execution beyond what the design tolerates.
- Memory:
used_memorystable over a day;evicted_keysnear zero unless intended; no keys with TTL -1 in cache namespaces. - Latency:
SLOWLOG GETfree of O(N) commands;redis-cli --latencyp99 within target.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.