Redis Caching Patterns, Invalidation and Cache Stampedes
Cache-aside, write-through and write-behind with Redis, a safe invalidation order, and working code that stops a cache stampede.
Redis for system design and backend interviews: data structures, caching patterns and eviction, TTLs, persistence, replication, Cluster, locks and rate limiting.
Official reference: Redis docs
Cache-aside, write-through and write-behind with Redis, a safe invalidation order, and working code that stops a cache stampede.
Strings, hashes, lists, sets, sorted sets, streams, HyperLogLog and bitmaps: what each Redis type costs and which interview use case it fits.
Build a correct Redis lock with SET NX PX and a token-checked release, add fencing tokens, and explain the Redlock algorithm and its critique.
How Redis expires keys lazily and actively, what each maxmemory-policy evicts, and how approximated LRU and LFU choose a victim.
How RDB snapshots and the append-only file work, how much data each appendfsync setting can lose, and how to enable AOF without losing data.
Fixed window, sliding log and token bucket rate limiters in Redis, why they must be atomic, and tested Lua scripts for each one.
How Redis replicates asynchronously, how Sentinel fails over, and how Cluster shards keys into 16,384 hash slots with MOVED and ASK redirects.
Why one Redis thread handles huge request rates, what io-threads and background threads change, and which commands block every client.
Redis is an in-memory data structure server. Keys map to typed values (strings, hashes, lists, sets, sorted sets, streams and more), and commands operate on those structures server-side, so INCR, ZADD or LPUSH are atomic without any client locking.
Typical uses are caching, sessions, rate limiting, leaderboards, distributed locks, queues and lightweight messaging.
Compared with Memcached:
Since 2024 Redis is source-available (RSALv2/SSPLv1, with AGPLv3 added in Redis 8), and the BSD-licensed Valkey fork exists under the Linux Foundation.
Likely follow-up: When would you still pick Memcached? · Can Redis be your primary database?
io-threads change?midCommands are executed by one main thread running an event loop on top of epoll/kqueue. It is fast because data lives in memory, the data structures are compact and well-chosen, and a single executor needs no locks or context switches. One thread can serve on the order of 100,000+ simple operations per second; the usual bottleneck is network and syscalls, not CPU.
Redis is not literally one thread, though:
UNLINK), AOF fsync and closing files.fork() creates a child process for RDB snapshots and AOF rewrites.io-threads N (Redis 6+, reworked in Redis 8), extra threads read sockets, parse requests and write replies, while command execution stays serial.The consequence for design: every command runs alone, which makes single commands atomic, but one slow command (KEYS *, a huge DEL, a long Lua script) blocks every client.
Likely follow-up: How would you use more CPU cores on one machine? · Which commands are dangerous on a large production instance?
INCR, flags, locks with SET NX.HSET.LPUSH + BRPOP), recent-activity feeds trimmed with LTRIM.SINTER.Redis 8 also ships JSON, the Query Engine (secondary indexes and full-text search), time series, probabilistic types and vector sets in the core distribution.
Likely follow-up: How would you model a "top 10 this week" feature? · Why might you store an object as a hash rather than a JSON string?
A sorted set is the natural fit: member = player ID, score = points. Internally it is a skip list plus a hash table (or a compact listpack when small), so it supports both lookups by member and ordered range queries.
ZINCRBY lb:global 50 player:42, O(log N), atomic.ZRANGE lb:global 0 9 REV WITHSCORES, O(log N + 10).ZREVRANK lb:global player:42, O(log N), zero-based.ZRANGE a window around it.Store display names in a hash per player so the sorted set holds only IDs. For weekly boards, use a key per week (lb:2026-w41) with an expiry, and combine periods with ZUNIONSTORE. Ties are ordered by member name lexicographically, so if you need "first to reach the score wins", encode a time component into the score.
Likely follow-up: How would you shard a leaderboard with 500 million players? · How do you show a percentile instead of an exact rank?
It depends on how you read and write it.
SET user:42 '{...}'): simplest, one round trip to read everything, works with any cache library. But updating one field means read, modify, write, which races between clients unless you use WATCH or Lua.HSET user:42 name Ana plan pro): fields are updated independently and atomically (HINCRBY user:42 logins 1), and you can fetch only the fields you need with HMGET. Small hashes use the compact listpack encoding, so they are memory-efficient. Hashes are flat: nested data must be serialized per field. Redis 7.4 added per-field expiry (HEXPIRE).JSON.SET, built into Redis 8, a module before): nested documents with path-level updates like JSON.NUMINCRBY user:42 $.stats.logins 1, and indexable by the Query Engine.For a read-mostly cache blob, a string is fine. For objects with frequent partial updates, a hash. For nested documents you query, JSON.
Likely follow-up: What is the race when two services update different fields of a JSON string?
Two options, depending on whether you need exactness.
HyperLogLog gives an approximate distinct count with a standard error of about 0.81%, using at most 12 KB per key no matter how many items you add. PFADD uv:2026-10-07 user:42 records a visit, PFCOUNT uv:2026-10-07 estimates the count, and PFMERGE or PFCOUNT over several keys gives the weekly unique count. You cannot list members or test membership.
Bitmaps are exact when user IDs are dense integers: SETBIT active:2026-10-07 42 1, then BITCOUNT for the total. Ten million users need about 1.2 MB per day, and BITOP AND across days answers "active every day this week". Sparse or huge IDs waste memory, because the string grows to the highest offset set.
A plain set is exact and supports membership, but costs tens of bytes per member.
Likely follow-up: Why is BITOP on two huge bitmaps a latency risk? · What does a Bloom filter answer that HyperLogLog cannot?
You set a TTL with EXPIRE/PEXPIRE, SET key value EX 60, or an absolute time with EXPIREAT. TTL returns the remaining seconds, -1 if the key has no expiry and -2 if it does not exist.
Removal uses two mechanisms:
hz, 10 times a second by default) samples keys that have a TTL and deletes the expired ones, repeating while a large share of the sample was expired. Memory from keys nobody reads is reclaimed this way.Gotchas: a plain SET on an existing key removes its TTL unless you pass KEEPTTL; INCR, HSET or LPUSH keep it. Replicas do not expire keys on their own; they wait for the primary's DEL, but they already report logically expired keys as missing.
Likely follow-up: Why can memory stay high after millions of keys have expired? · How do you expire a single field of a hash?
maxmemory? Compare the eviction policies.midWhen a write would exceed maxmemory, Redis applies maxmemory-policy:
noeviction (the default): writes fail with an OOM error, reads still work.allkeys-lru / allkeys-lfu: evict the least recently / least frequently used key among all keys. The usual choice for a pure cache.volatile-lru / volatile-lfu: same, but only keys with a TTL. Useful when one instance mixes cache keys (with TTL) and data that must stay (without).allkeys-random / volatile-random: random choice.volatile-ttl: evict keys with the shortest remaining TTL first.If a volatile-* policy finds no key with an expiry, it behaves like noeviction and writes fail. Eviction is approximate: Redis samples maxmemory-samples keys (5 by default) and keeps a small pool of good candidates, rather than tracking an exact LRU list. Also leave headroom for replication buffers and copy-on-write during forks; maxmemory is not the process's peak memory.
Likely follow-up: How do you notice that eviction is hurting your hit rate? · Why is a primary database with noeviction safer than allkeys-lru?
Neither is exact. Each object header has a 24-bit field. Under LRU it stores a coarse last-access clock. On eviction Redis samples maxmemory-samples keys, compares their idle time and keeps the best candidates in a 16-entry pool, which approximates true LRU closely at 5 to 10 samples without a linked list per key.
Under LFU the same 24 bits hold an 8-bit logarithmic access counter plus a 16-bit minutes timestamp. The counter increments probabilistically (controlled by lfu-log-factor, default 10), so 255 can represent around a million hits, and it decays over time (decremented once per lfu-decay-time minutes, default 1) so yesterday's hot keys cool down.
Choose LFU when popularity is skewed and stable: a nightly batch job that scans many keys once would flush hot keys out of an LRU cache, but barely moves LFU counters. Choose LRU when recency predicts reuse, as with sessions. OBJECT FREQ key shows the counter under LFU.
Likely follow-up: What does raising maxmemory-samples cost? · How would you find hot keys under LFU?
In cache-aside (lazy loading), the application owns the logic:
GET product:42. On a hit, return it. On a miss, load from the database, then SET product:42 <json> EX 300 and return.Strengths: only requested data is cached, the cache can fail without breaking reads (you fall back to the database), and it works with any data source.
Weaknesses: the first read after a miss is slow, every miss costs three steps, and there is a window where cache and database disagree. Races exist: a reader can load an old row, a writer updates and deletes, then the reader writes the old value back. A TTL bounds how long that stale value can live. Popular keys expiring at once can also cause a stampede of database queries.
It is the default pattern for read-heavy workloads.
Likely follow-up: Why delete the key on write instead of updating it? · How would you prevent a stampede on a hot key?
Write-through: every write goes to the cache and the database synchronously, usually via a layer that writes both before acknowledging. The cache is always warm and consistent for keys written through it, but writes pay both latencies and you cache data that may never be read. If the second write fails, you need a compensation or retry policy.
Write-behind (write-back): the application writes to Redis and returns; changes are flushed to the database asynchronously, often batched through a stream or queue. Writes are very fast and the database sees fewer, larger writes, which suits counters, likes and telemetry. The cost is durability: if Redis loses data before the flush (a crash with appendfsync everysec, a failover to a lagging replica), those writes are gone, and the database is eventually consistent.
Read-through is the read-side counterpart where the cache layer loads misses itself. In practice, cache-aside plus delete-on-write covers most services; write-behind is reserved for high-volume data that tolerates loss or has a durable buffer.
Likely follow-up: How would you make a write-behind pipeline survive a Redis restart?
Prefer update the database, then delete the key. Updating the cached value instead races: two writers can commit A then B to the database but write B then A to Redis, leaving A cached indefinitely. A delete is idempotent and lets the next reader load the committed value.
Deleting first and then updating the database is worse: a reader can repopulate the old value between the two steps.
Remaining gaps and fixes:
Also version or namespace keys (product:v3:42) when the cached shape changes, so a deploy cannot read incompatible data.
Likely follow-up: Why is delete-then-update worse than update-then-delete? · How would you invalidate a cached list that contains the changed item?
When a hot key expires, every concurrent request misses at the same moment and all of them query the database and recompute the value. A query that normally runs once per TTL suddenly runs thousands of times, which can take the database down, and while it is slow even more requests pile up.
Mitigations, often combined:
SET lock:product:42 <token> NX PX 5000 and rebuilds; others wait briefly and retry the cache, or serve a stale copy.In-process single-flight also helps.
Likely follow-up: What happens if the lock holder crashes mid-rebuild? · How is a stampede different from cache penetration?
These terms come up often in system design rounds:
SET product:999 "__none__" EX 60) and reject impossible IDs early, for example with a Bloom filter of valid IDs (BF.ADD / BF.EXISTS, built into Redis 8).The common thread is protecting the backing store from load the cache normally absorbs.
Likely follow-up: What TTL would you give a negative cache entry, and why short?
RDB writes point-in-time snapshots: BGSAVE forks a child that writes a compact binary file while the parent keeps serving. Files are small, restart is fast, and they make good backups, but you lose everything since the last snapshot (minutes, with the default save rules).
AOF logs every write command. appendfsync controls durability:
always: fsync on every write batch; safest, slowest.everysec (default): fsync once a second in a background thread; lose about a second of writes.no: let the OS flush, typically within 30 seconds.The AOF grows, so Redis rewrites it in the background. Since Redis 7 it is a multi-part AOF: a base file (RDB format by default) plus incremental files tracked by a manifest.
For data you care about, enable AOF with everysec (which uses the RDB preamble) and keep RDB snapshots for backups. For a pure cache, persistence can be off or RDB-only; remember that a replica restarting with no data is still safe because it resyncs from the primary.
Likely follow-up: Why is it dangerous to run a primary with persistence off and auto-restart? · What does BGREWRITEAOF do?
BGSAVE, AOF rewrites and full replica syncs all call fork(). Two costs follow.
Fork latency: the kernel copies the parent's page tables, proportional to memory size. On a 40 GB process that can stall the main thread for a noticeable time, worse on some virtualized hosts. INFO stats exposes latest_fork_usec.
Copy-on-write memory: parent and child share pages until the parent writes. A write-heavy workload touches many pages during the snapshot, and each touched page is duplicated, so memory can grow toward double in the worst case. If the box has no headroom, the OOM killer may kill Redis. Transparent huge pages make it worse (a one-byte write copies 2 MB), which is why Redis warns at startup when THP is enabled.
Fixes: disable THP, size maxmemory well below RAM, set vm.overcommit_memory = 1 so fork does not fail, run persistence on replicas instead of the primary, shard into smaller instances, and schedule snapshots off-peak.
Likely follow-up: How do you measure fork time? · Why move snapshots to a replica?
A replica connects to the primary (REPLICAOF host port) and asks to continue from its replication ID and offset with PSYNC. If the primary's in-memory backlog (repl-backlog-size, 1 MB by default) still holds the missing range, it sends only that (partial resync). Otherwise it does a full resync: an RDB snapshot (streamed diskless by default since Redis 7) followed by the buffered write stream.
Replication is asynchronous: the primary acknowledges the client before replicas receive the write. So yes, if the primary dies right after acknowledging and a replica is promoted, that write is lost.
Mitigations reduce the window but do not make Redis strongly consistent:
WAIT numreplicas timeout blocks until replicas have the write; WAITAOF (7.2) also waits for fsync.min-replicas-to-write and min-replicas-max-lag make an isolated primary stop accepting writes.Replicas are read-only by default, and reads from them may be slightly stale.
Likely follow-up: Why should you increase repl-backlog-size on a busy primary? · Does WAIT make Redis linearizable?
Sentinel provides high availability for a non-clustered primary with replicas. Sentinel processes (at least three, on separate failure domains) monitor the primary and replicas, act as a configuration provider for clients, and run failovers.
The failover sequence:
down-after-milliseconds marks the primary subjectively down (SDOWN).Clients do not hard-code the primary. They ask Sentinel (SENTINEL get-master-addr-by-name mymaster) and reconnect on the switch event. Because replication is asynchronous, writes acknowledged by the old primary but not replicated are lost, and a partitioned old primary can keep accepting writes unless min-replicas-to-write is set.
Likely follow-up: Why do you need at least three Sentinels? · When do you choose Cluster over Sentinel?
The keyspace is split into 16,384 hash slots. A key's slot is CRC16(key) mod 16384, and each primary owns a subset of slots (each with replicas). Clients cache the slot-to-node map (CLUSTER SHARDS) and send each command straight to the owner, so there is no proxy hop.
If the client is wrong, the node answers with a redirect instead of forwarding:
MOVED 3999 10.0.0.3:6379: the slot lives elsewhere permanently; update the map.ASK: the slot is being migrated; retry this one command on the target after sending ASKING.Resharding moves slots key by key (MIGRATE) while the cluster stays online. Nodes gossip over a separate cluster bus port (data port + 10000), detect failures, and promote a replica when a majority of primaries agree.
Limits: only database 0, multi-key commands need all keys in one slot, and like Sentinel setups it uses asynchronous replication, so acknowledged writes can be lost on failover.
Likely follow-up: Why 16,384 slots and not consistent hashing with a ring? · What happens to clients during resharding?
MOVED and ASK redirects in Redis Cluster?hardBoth tell the client "this node does not serve that key", but they mean different things.
MOVED means the slot is now owned by another node. The client should retry there and update its slot map, because every future command for that slot belongs to the new owner. Smart clients usually refresh the whole map after a MOVED, since one move often means many.
ASK happens only during a slot migration. The source node still owns the slot but no longer has this particular key, which already moved. The client must send ASKING followed by the command to the target node, for this one request only, and must not update its map. Without ASKING, the target would reply MOVED back to the source, because it does not own the slot yet.
When migration finishes, the slot ownership flips and clients start receiving MOVED. Multi-key commands during migration can fail with TRYAGAIN if their keys are split between source and target.
Likely follow-up: What does a client library do if it receives MOVED for every request after a failover?
MSET user:1:name Ana user:2:name Bo against Redis Cluster and gets CROSSSLOT. Why, and how do you fix it?midIn Cluster, commands with several keys (MSET, MGET, SUNION, RENAME), transactions and Lua scripts only work when every key hashes to the same slot, because a node can only atomically operate on data it owns. user:1:name and user:2:name hash to different slots, so the node rejects the command with CROSSSLOT.
Fixes:
{...}, only the substring inside the first braces is hashed. {user:1}:name and {user:1}:email always share a slot, so one user's keys can be updated together in MULTI or a script.MGET into per-slot calls and merge the results. You lose atomicity across keys, but often you never needed it.Do not put everything under one tag such as {app}: all those keys land on one shard, creating a hot slot that cannot be scaled out. Tag by the entity that needs atomic updates.
Likely follow-up: How would you design keys for a per-user rate limiter that also touches a global counter?
Acquire with one atomic command: SET lock:order:42 <random-token> NX PX 30000. NX only sets the key if it is absent, PX gives it an expiry so a crashed holder cannot block others forever, and the random token identifies the owner. A reply of OK means you hold the lock; nil means someone else does.
Release with a compare-and-delete Lua script: delete only if the value still equals your token. A plain DEL is a bug: if your work outlived the TTL, the lock expired, another client acquired it, and your DEL removes their lock.
Pitfalls:
SETNX followed by EXPIRE is two commands; a crash between them leaves a lock with no expiry.Likely follow-up: What is a fencing token and who checks it? · How does Redisson implement lock renewal?
Redlock is the algorithm described in the Redis docs for locks that survive single-node failure. Use N independent primaries (typically five). The client tries SET key token NX PX ttl on each with a short timeout, and holds the lock only if it acquired a majority and the elapsed time is less than the TTL. The remaining validity is TTL minus elapsed time minus a clock drift allowance. On failure it releases everywhere.
Martin Kleppmann's 2016 critique: a lock with an expiry cannot guarantee mutual exclusion when a client can pause (GC, page faults, network delay) past the TTL and then act as if it still holds the lock. Redlock also assumes bounded clock drift and network delay, and a node that restarts without persisted state can grant the lock again. His conclusion: for efficiency (avoiding duplicate work) a single Redis lock is enough; for correctness use fencing tokens with a consensus-based system such as ZooKeeper or etcd. Antirez replied defending Redlock's assumptions. A strong answer names both sides and asks which kind of lock is needed.
Likely follow-up: Why does Redlock not provide fencing tokens? · When would you use a database row lock instead?
Fixed window: key per client per window (rl:42:1700000040), INCR it and set an expiry when the count is 1. Cheap, one key, but a burst at the window boundary can let through twice the limit in a short span.
Sliding window log: a sorted set of request timestamps. Remove entries older than the window (ZREMRANGEBYSCORE), count (ZCARD), add if under the limit. Exact, but stores one member per request. The sliding window counter approximates it with two fixed-window counters weighted by overlap.
Token bucket: a hash with tokens and last_refill. Each request refills tokens by elapsed time times rate, capped at capacity, then spends one. It allows controlled bursts and a smooth average rate, in constant memory.
Whatever the algorithm, the read-check-write must be atomic, so implement it as a Lua script (or a function). Use redis.call('TIME') or a single clock source to avoid skew between app servers, return the remaining quota for headers, and decide whether to fail open or closed when Redis is unavailable.
Likely follow-up: How would you rate limit across a Redis Cluster? · Why is INCR then EXPIRE in two calls risky?
MULTI/EXEC) work? Do they roll back on error?midAfter MULTI, commands are queued (each replies QUEUED), and EXEC runs them all in sequence with no other client's command interleaved. DISCARD drops the queue.
There is no rollback:
EXEC refuses with EXECABORT and nothing runs.LPUSH on a string gives WRONGTYPE), that command returns an error and the others still apply.Inside MULTI you cannot read a value and branch on it, because replies arrive only at EXEC. For read-modify-write, use WATCH: watch the keys, read them, then MULTI ... EXEC. If any watched key changed in between, EXEC returns a null reply and you retry. That is optimistic locking.
Under contention, WATCH retries can spin, so a Lua script is usually simpler: it reads, decides and writes atomically on the server in one round trip.
Likely follow-up: What does EXEC return when a watched key was modified? · Are transactions durable if the server crashes during EXEC?
A script sent with EVAL runs atomically on the server: no other command runs until it finishes. That makes conditional logic (check then write) safe without WATCH loops, and saves round trips.
Rules and practices:
KEYS, and pass other values in ARGV. Redis Cluster routes the script by its keys, and all keys must be in one slot.busy-reply-threshold (5 seconds by default) others get BUSY, and SCRIPT KILL only works if the script has not written yet.EVALSHA; handle NOSCRIPT by falling back to EVAL (client libraries do this).false becomes nil, and a table stops at the first nil.redis.call raises on error, redis.pcall returns the error to handle.Since Redis 7, Functions (FUNCTION LOAD, FCALL) are the managed alternative: named, persisted and replicated with the dataset.
Likely follow-up: What happens to a script mid-way when the server crashes? · Why must scripts be deterministic in older Redis versions?
Without pipelining, each command waits a full network round trip for its reply. With 1 ms RTT, 10,000 GETs take at least 10 seconds even though Redis processes each in microseconds. Pipelining sends many commands without waiting, then reads all replies. It also cuts syscalls, because many commands are read and written per socket call.
It is purely a client-side and network optimization:
A transaction (MULTI/EXEC) guarantees that the queued commands run without interleaving, but by itself still pays a round trip per command unless you also pipeline it, which most clients do. Lua scripts give atomicity and conditional logic in one round trip.
Pipelining is the first fix when a batch job is slow because of many small calls.
Likely follow-up: How does pipelining interact with Redis Cluster?
Pub/Sub (PUBLISH, SUBSCRIBE) is fire-and-forget fan-out. Messages are delivered to whoever is subscribed at that instant and are not stored. A subscriber that is disconnected, restarting or too slow misses messages, and a slow subscriber is cut off when its output buffer exceeds client-output-buffer-limit pubsub. Good for ephemeral notifications: cache invalidation broadcasts, live dashboards, chat presence. Redis 7 added sharded Pub/Sub (SPUBLISH) so messages stay within one shard in Cluster.
Streams (XADD, XREADGROUP) are an append-only log stored in Redis. Messages persist (with MAXLEN or MINID trimming), readers can resume from an ID, and consumer groups split work across consumers with acknowledgements: each delivered entry stays in the pending entries list until XACK, and XAUTOCLAIM reassigns entries from dead consumers. That gives at-least-once processing.
Choose Pub/Sub when losing a message is acceptable; Streams when every event must be processed. For very high volume or long retention, Kafka is the better tool.
Likely follow-up: How do you handle a stream entry that fails processing repeatedly? · How is a Redis Stream different from a Kafka topic?
A big key holds a huge value or collection: a 50 MB string, a hash with millions of fields. Commands like HGETALL, SMEMBERS or DEL on it take a long time on the single execution thread and block every client; it also skews memory across Cluster shards and slows migrations. A hot key receives a disproportionate share of traffic, saturating the one shard (and one CPU) that owns it.
Finding them:
redis-cli --bigkeys and --memkeys scan the keyspace with SCAN; MEMORY USAGE key checks one key.redis-cli --hotkeys works under an LFU policy; SLOWLOG GET and LATENCY DOCTOR show the damage.KEYS * to look: it is O(N) and blocks.Fixes: split big collections into buckets (followers:42:{0..63}), iterate with HSCAN/SSCAN, delete with UNLINK (frees memory in a background thread). For hot keys, add a short-lived local in-process cache, read from replicas, or replicate the key under several names (config:v1#0..7) and pick one at random.
Likely follow-up: Why does SCAN sometimes return the same key twice? · How would you delete a hash with 20 million fields safely?
Start with INFO memory: used_memory (what Redis allocated), used_memory_rss (what the OS sees), and mem_fragmentation_ratio. A ratio well above 1.5 suggests fragmentation; below 1 means swapping. MEMORY DOCTOR, MEMORY STATS and redis-cli --memkeys show where memory goes.
Common wins:
hash-max-listpack-entries 128 and hash-max-listpack-value 64. Grouping many tiny string keys into hashes of about 100 fields can save a lot of per-key overhead. Check with OBJECT ENCODING.activedefrag yes (jemalloc) reclaims memory online.Likely follow-up: Why can RSS stay high after you delete half the keys? · What does OBJECT ENCODING return for a hash with 200 fields?
No questions match that filter.
Prefer multiple choice? All 20 Redis MCQs with answers →