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.

  • nameredis-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.

~/.claude/skills/redis-patterns/SKILL.md

--- name: redis-patterns description: Applies proven Redis and Valkey patterns - cache-aside with TTL jitter and stampede protection, rate limiters (fixed window, sliding… license: MIT ---

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}:items and cart:{user42}:total share a slot.
  • Pick the type for the access pattern:
NeedType
Opaque cached blobString (JSON/MessagePack), with TTL
Object with independently updated fieldsHash (HSET, HINCRBY); per-field TTL via HEXPIRE on Redis 7.4+
Leaderboard, time-ordered index, sliding windowSorted set
Unique membershipSet; HyperLogLog (PFADD/PFCOUNT) for approximate counts
Durable work queue with acksStream with consumer groups
Fire-and-forget broadcastPub/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

AlgorithmProsConsImplementation
Fixed window2 commands, cheapBurst of 2x at window edgesINCR + EXPIRE
Sliding window logExactMemory per requestSorted set of timestamps
Sliding window counterGood accuracy, cheapApproximateTwo fixed-window counters weighted
Token bucketAllows controlled burstsNeeds atomic scriptLua 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 ~ or MINID; 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

Usemaxmemory-policy
Pure cacheallkeys-lru or allkeys-lfu (skewed popularity)
Mixed: cache keys have TTL, others must persistvolatile-lru / volatile-lfu (only keys with TTL evicted)
Primary store / queues / locks must never vanishnoeviction (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; use SCAN with MATCH and COUNT.
  • Never FLUSHALL / FLUSHDB or 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), not DEL.
  • Avoid O(N) commands on big collections in hot paths: HGETALL, SMEMBERS, LRANGE 0 -1, ZRANGE 0 -1. Paginate with HSCAN/SSCAN/ZSCAN or 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

  1. Cache: hit ratio from keyspace_hits / (hits + misses) and app metrics; confirm behavior with Redis stopped (requests still succeed, slower).
  2. Rate limiter/lock: concurrency test with parallel clients (e.g. 50 workers) shows limits honored and no double execution beyond what the design tolerates.
  3. Memory: used_memory stable over a day; evicted_keys near zero unless intended; no keys with TTL -1 in cache namespaces.
  4. Latency: SLOWLOG GET free of O(N) commands; redis-cli --latency p99 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.

esc