Redis in Production: Patterns That Work and Anti-patterns That Will Break You

Redis is fast until it isn't. Here are the caching patterns, data structure choices, and operational decisions that determine whether Redis is a performance multiplier or a fragile dependency.

Redis is the database most teams add without a meeting. You add it for caching, it works immediately, and six months later it's carrying session state, rate limiting, pub/sub messaging, distributed locks, and the leaderboard. The original cache is fine. The session store is fine. The distributed lock implementation is broken in a subtle way that only manifests under specific failure conditions.

The patterns matter. Here's what I've learned from running Redis at scale.

Data structure selection is architectural

Redis isn't a key-value store. It's a data structure server that happens to have strings. The data structure you choose affects performance, memory usage, and correctness.

Strings: for simple values, counters, and serialised JSON. Use INCR and INCRBY for counters — they're atomic. Don't store large JSON objects as strings if you frequently update single fields; you'll serialise/deserialise the whole object on every write.

Hashes: for objects with multiple fields where you update fields independently. A user profile stored as a Hash lets you HSET user:123 email new@email.com without touching the rest of the profile. Hashes are also memory-efficient for small collections — Redis uses a more compact encoding for hashes with fewer than 128 fields.

Sorted Sets: for leaderboards, priority queues, and range-by-score queries. ZADD and ZRANGEBYSCORE are O(log N). If you're building a leaderboard, a sorted set is the right structure — don't simulate it with strings and sorting in application code.

Sets: for unique collections, tag systems, and membership tests. SMEMBERS, SUNION, SINTERSTORE. The SINTERSTORE for computing the intersection of tag sets (e.g., "articles tagged both 'cloud' and 'finops'") is fast and idiomatic.

Lists: for queues and stacks. LPUSH / RPOP for a FIFO queue. Be careful using Redis Lists as a production job queue for anything critical — consider using a purpose-built system (Kafka, SQS, RabbitMQ) for durability requirements.

Streams: added in Redis 5.0, these are the right structure for append-only event logs with consumer group support. If you're using Lists as a poor man's message queue, evaluate Streams as an upgrade path.

TTL is not optional

Every key that isn't permanent state should have a TTL. This isn't about memory pressure (though it helps) — it's about correctness. A cached value without a TTL becomes stale data that persists indefinitely. A session key without a TTL becomes a session that never expires.

The most common mistake: setting TTLs on new keys but forgetting to reset them on update. If you cache a user's permissions with a 15-minute TTL and then update the permissions, the cached value still has whatever time remains on the original TTL. Use SET key value EX seconds to set the value and TTL atomically, or explicitly EXPIRE the key after every update.

The second most common mistake: all cache keys expiring at the same time. If you populate a cache at startup and set TTL=3600 on everything, one hour later your cache is empty and the database gets a spike of requests as everything misses simultaneously. Add jitter: TTL = base_ttl + random(0, base_ttl * 0.1).

The distributed lock problem

Redis-based distributed locks are widely used and frequently implemented incorrectly.

The pattern that looks correct but isn't:

SET lock:resource lock_id NX EX 30

NX means "set if not exists" — only acquires the lock if it's not already held. EX 30 means the lock expires after 30 seconds even if not released — prevents deadlocks if the holder crashes. The lock_id is a unique value generated by the lock holder — you only release a lock if the value matches (preventing releasing another process's lock).

The release is:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

The Lua script makes the check-and-delete atomic.

Why this is still not guaranteed safe: Redis is single-threaded but not instantaneous. Under partition scenarios (split-brain), two clients can both acquire the lock. For most applications this is an acceptable trade-off. For financial operations, inventory management, or any operation where double-execution has serious consequences — consider a different approach or use the Redlock algorithm (with understanding of its limitations).

The Redlock algorithm requires acquiring the lock on a majority of N independent Redis nodes. It's more robust against single-node failures but adds complexity. Martin Kleppmann's critique of Redlock is worth reading before implementing it.

Memory management: eviction policies and memory pressure

Redis stores everything in memory. When memory is full, the eviction policy determines what happens:

  • noeviction: return errors on write when memory is full. The right choice for a data store, never for a cache.
  • allkeys-lru: evict the least recently used key across all keys. The right choice for a general cache.
  • volatile-lru: evict the LRU key from keys with TTLs set. Preserves keys without TTLs (permanent data) while evicting cache entries.
  • allkeys-lfu: evict the least frequently used key. Often better than LRU for access patterns that are skewed (hot keys accessed very frequently).

For cache use cases: allkeys-lru or allkeys-lfu. Set maxmemory to a value below the system memory limit (leave headroom for Redis's own memory management). Monitor used_memory_rss and used_memory — a large gap between them indicates fragmentation.

The hot key problem

Redis is single-threaded. A key accessed extremely frequently (a viral post's view count, a configuration value read on every request) can saturate a single Redis instance. Signs: CPU on the Redis node is high, latency for all operations increases, slow commands log shows fast commands taking milliseconds.

Approaches:

  • Read replicas + read from replicas: valid for read-heavy hot keys, doesn't help write contention.
  • Local cache with short TTL: cache the value in process memory for 100ms. Reduces Redis load by the request rate times 100ms.
  • Key sharding: instead of one key counter:popular_post_123, use multiple keys counter:popular_post_123:shard_{0..9} and increment a random shard. Read by summing all shards. More complex, reduces write contention significantly.

Sentinel vs. Cluster vs. managed services

Redis Sentinel: high availability for a single-primary setup. Sentinel processes monitor the primary, detect failures, and promote a replica. Simple to operate; limited throughput scaling.

Redis Cluster: horizontal scaling across multiple nodes. Data is partitioned across 16,384 hash slots. Supports multiple primaries with their own replicas. Operations involving multiple keys must map to the same hash slot (use hash tags: {user:123}:profile and {user:123}:sessions both map to the same slot).

Managed services (AWS ElastiCache, Azure Cache for Redis, GCP Memorystore): operational overhead handled by the cloud provider. Automatic failover, patching, monitoring. If you're running on cloud infrastructure, there's rarely a good reason to run self-managed Redis — the operational cost of self-managing doesn't justify the control you get.

The choice between Sentinel and Cluster depends on throughput. If a single Redis instance handles your peak load comfortably with headroom, use Sentinel. If you're hitting single-instance CPU or memory limits, move to Cluster.

Diagnosing Redis performance issues or planning a migration from single-node to cluster? Happy to talk through the specifics.

Working on this in production?

We do this work directly alongside engineering teams — architecture review, migration, and hands-on enablement.