The commands worth memorizing, the patterns interviewers are actually listening for, and the failure modes that separate a senior answer from a staff answer. Every command is tagged by what it does to the server: read, write, or atomic.
Redis is not a cache with extra features. It is a set of in-memory data structures behind a network port. Almost every Redis question in a system design interview reduces to one decision: which shape do I store this in, and what does that shape make cheap? Here is the same subject, one game session, stored five ways, plus a sixth structure that only earns its place at population scale.
game:42 on purpose, to show the same data in two different shapes; only one of them can exist at a time. Storing it as a string means GET game:42 hands back the whole blob of bytes you put there, and Redis has no idea it is JSON. Storing it as a hash instead means GET game:42 is rejected with a wrong-type error, and you read fields with HGET game:42 players. If a design genuinely needs two shapes of the same entity, they live under two different key names, which is why the list, set, and sorted set cards below use suffixed keys. The bitmap card deliberately leaves the game session behind: one bit per member only pays off across millions of users, and at the scale of a single match a set is cheaper and simpler.SET game:42 '{"id":42,"map":"crossroads","players":8,"state":"live"}' GET game:42 -> {"id":42,"map":"crossroads","players":8,"state":"live"} -- Redis returns the exact bytes you stored. It does not parse the JSON.
Makes cheap: whole-object get and set, one round trip. Makes expensive: reading one field, and updating one field without a read-modify-write cycle.
HSET game:42 map crossroads players 8 state live round 3 HGET game:42 players -> 8 HMGET game:42 map state -> crossroads, live HINCRBY game:42 round 1 -> 4, one atomic command, no read first
Makes cheap: atomic single-field increment, partial reads, memory-efficient small objects. Makes expensive: nothing much, which is why hashes are the default for object caching.
LPUSH game:42:events match_start -- oldest, pushed first LPUSH game:42:events kill:u3 LPUSH game:42:events join:u9 LPUSH game:42:events kill:u7 LPUSH game:42:events round_start -- newest, now at the head LRANGE game:42:events 0 4 -> round_start, kill:u7, join:u9, kill:u3, match_start
Makes cheap: newest-first feeds, capped history, and first in first out work queues with a blocking pop. Makes expensive: reading or removing from the middle.
SADD game:42:players u3 u7 u9 u12 u18 SISMEMBER game:42:players u9 -> 1, meaning yes SISMEMBER game:42:players u4 -> 0, meaning no SADD game:42:players u7 -> 0, already present, nothing added SCARD game:42:players -> 5
Makes cheap: constant time membership tests, deduplication, and intersections between two sets computed on the server. Makes expensive: any notion of order or ranking.
ZADD game:42:score 1450 u7 1120 u3 980 u18 640 u9 ZRANGE game:42:score 0 2 REV WITHSCORES -> u7 1450, u3 1120, u18 980 ZREVRANK game:42:score u3 -> 1, second place, ranks start at 0 ZINCRBY game:42:score 200 u9 -> 840, u9 moves up the board
Makes cheap: rank lookups, top-K queries, range-by-score scans, and anything where a number orders the world (points, timestamps, priorities). This is the single most useful structure in interviews.
SETBIT dau:2026-08-15 8321004 1 -- user 8321004 was active today BITCOUNT dau:2026-08-14 -> 4 active yesterday BITCOUNT dau:2026-08-15 -> 4 active today BITOP AND retained:2day dau:2026-08-14 dau:2026-08-15 BITCOUNT retained:2day -> 3 active on both days GETBIT retained:2day 3 -> 0, user 3 came yesterday and did not return
Makes cheap: one boolean per user at a cost of one bit, so 100 million users fit in 12.5 MB per day, and questions like two-day retention, weekly streaks, and "active on Monday but not Tuesday" become one server-side command over whole populations. Makes expensive: anything that is not a dense integer identifier, and anything at small scale, where a set is both cheaper and easier.
Four facts about how the server runs. Everything else on this board follows from them.
Redis executes commands on a single thread, one to completion before the next starts. There is no lock to take and no partial state to observe: every individual command is already atomic, including compound ones like an increment or a conditional set.
Separate input and output threads exist for reading and writing sockets, and separate background threads handle disk syncing and freeing large objects, but command execution itself is serial.
Because execution is serial, a command that is linear in the size of a collection is a latency incident, not a slow query. Fetching a million-member set, deleting a huge key, or running a long script freezes every other client for that duration.
Consequences you should state unprompted: never run a full keyspace scan in production, cap collection sizes, and keep scripts short.
KEYS pattern is the classic disqualifier. SCAN cursor COUNT 100 is the answer.
The working set lives in random access memory (RAM). Capacity planning is a memory calculation, not a disk one: number of keys, bytes per value, plus roughly 50 to 100 bytes of per-key overhead, plus fragmentation.
When the memory ceiling is hit, the configured eviction policy decides between refusing writes and throwing data away. Both are user-visible, so pick deliberately.
Replication is asynchronous by default, so a primary can acknowledge a write and then fail before any replica has it. Snapshots and the append-only log narrow the window but do not close it.
The rule to carry into every interview: Redis is a fast copy of the truth, not the truth. If losing the last second of writes would corrupt money, ownership, or identity, that data belongs in a durable store and Redis holds a derived view of it.
For each structure: the commands you should be able to write on a whiteboard, their cost, and the design situations that call for it. N is the number of elements in the collection.
10 structures| Command | Cost | What it does |
|---|---|---|
| SET key value EX 300 NX | O(1) | Set with a 300 second expiry, only if the key does not exist. NX makes it a lock or a claim. |
| GET key | O(1) | Fetch the value, or a null reply if absent. |
| MGET k1 k2 k3 | O(k) | Batch fetch in one round trip. Cluster requires all keys in one slot. |
| INCR key | O(1) | Atomic increment, creating the key at zero first. Also INCRBY and INCRBYFLOAT. |
| GETDEL key | O(1) | Read and delete atomically, useful for one-shot tokens. |
| GETEX key EX 300 | O(1) | Read and refresh the expiry in one command, the sliding-session primitive. |
| Command | Cost | What it does |
|---|---|---|
| HSET key field value | O(1) | Set one or more fields, creating the hash if needed. |
| HGET key field | O(1) | One field. HMGET fetches several. |
| HGETALL key | O(N) | Every field. Safe for a 20 field object, dangerous for a 200,000 field one. |
| HINCRBY key field 1 | O(1) | Atomic counter inside an object, the basis of token bucket state. |
| HSCAN key cursor | O(1) per call | Cursor iteration over a large hash without blocking. |
| HEXPIRE key 60 FIELDS 1 f | O(1) | Per-field expiry, available from Redis 7.4. Before that, expiry was key-level only. |
| Command | Cost | What it does |
|---|---|---|
| LPUSH key v / RPUSH key v | O(1) | Push at head or tail. |
| LPOP key / RPOP key | O(1) | Pop at head or tail, optionally several at once. |
| LRANGE key 0 49 | O(offset+n) | Read a window. Cheap at the head, costly deep into a long list. |
| LTRIM key 0 999 | O(removed) | Keep only the newest 1000 entries. Paired with LPUSH this caps a feed forever. |
| BLPOP key 5 | O(1) | Blocking pop with a 5 second timeout. The consumer sleeps on the server, no polling. |
| BLMOVE src dst RIGHT LEFT 5 | O(1) | Atomically pop from one list and push to another, blocking if empty. The reliable queue primitive; it replaces the older RPOPLPUSH pair. |
| Command | Cost | What it does |
|---|---|---|
| SADD key member | O(1) | Add, returning how many were new. That return value is a free deduplication test. |
| SISMEMBER key member | O(1) | Membership test. SMISMEMBER checks a batch. |
| SCARD key | O(1) | Cardinality, stored not computed. |
| SMEMBERS key | O(N) | Every member. Use SSCAN for anything large. |
| SINTERCARD 2 k1 k2 LIMIT 10 | O(N×M) | Size of the intersection with an early exit, for questions like mutual friends. |
| SPOP key 1 | O(1) | Remove and return a random member, an atomic raffle draw or ticket claim. |
| Command | Cost | What it does |
|---|---|---|
| ZADD key 1450 u7 | O(log N) | Insert or update. Modifiers matter: NX only adds, XX only updates, GT keeps the higher score, INCR makes it behave as an increment that returns the new score. |
| ZINCRBY key 10 u7 | O(log N) | Atomic score delta, the leaderboard write path. |
| ZRANGE key 0 9 REV WITHSCORES | O(log N + k) | Top ten by score, highest first. Since Redis 7 this one command covers rank ranges, score ranges with BYSCORE, and lexicographic ranges with BYLEX. |
| ZRANGE key 1000 2000 BYSCORE LIMIT 0 100 | O(log N + k) | Everything in a score band, paged. This is how a delayed job poller finds due work. |
| ZREVRANK key u7 | O(log N) | A single player's rank, zero based, without scanning the board. |
| ZCOUNT key 1000 5000 | O(log N) | How many members fall in a score band, without fetching them. |
| ZREMRANGEBYSCORE key 0 1755200000 | O(log N + k) | Drop everything older than a cutoff. The sliding window eviction step. |
| ZPOPMIN key / BZPOPMIN key 5 | O(log N) | Pop the lowest score, blocking variant included. A priority queue in one command. |
| ZUNIONSTORE dst 2 k1 k2 WEIGHTS 1 0.5 | O(N log N) | Merge boards with weights, for example blending this week and last week into a decayed ranking. |
| Command | Cost | What it does |
|---|---|---|
| SETBIT dau:2026-08-15 40312 1 | O(1) | Mark user 40312 active today. |
| GETBIT dau:2026-08-15 40312 | O(1) | Was that user active. |
| BITCOUNT dau:2026-08-15 | O(N) | How many were active, N being the byte length, so about 12.5 MB scanned for 100 million users. |
| BITPOS seats:UA118 0 | O(N) | Position of the first zero bit, which is the first free slot in an allocation map. |
| BITOP AND dst d1 d2 d3 | O(N) | Store the intersection of three days into a new key, giving three-day retention with one command. |
| BITFIELD stats INCRBY u8 8 1 | O(1) | Treat the string as an array of small integers, here incrementing the 8-bit value at bit offset 8. |
BITPOS seats:UA118 0 -> 47, the first unsold seat -- claim it atomically, or two bookings both take seat 47 EVAL "if redis.call('GETBIT',KEYS[1],ARGV[1])==0 then redis.call('SETBIT',KEYS[1],ARGV[1],1) return 1 else return 0 end" 1 seats:UA118 47 BITCOUNT seats:UA118 -> seats sold so far
One quirk to know: on a bitmap whose bits are all set, the first-free search returns the position just past the end of the string, because the string is treated as followed by infinite zeroes. Compare the result against your capacity rather than trusting it blindly.
BITFIELD stats:u8321004 SET u8 0 3 INCRBY u8 8 1 GET u8 0 -- one key holding an array of 8-bit values: set the first to 3, -- increment the second by 1, read the first back
Offsets are absolute, so a bitmap costs whatever its highest set bit costs, regardless of how few bits are set. Writing bit 4 billion allocates half a gigabyte immediately, even for a single user.
That is also the hard wall. A bitmap is a string, the maximum string is 512 MiB, and the offset limit is 2 to the power 32, which is 4,294,967,296 bits, exactly 512 MiB. Long before reaching it, a full population count on one key means scanning half a gigabyte on the single execution thread, and the key is copied whole on replica sync and cluster resharding.
Shard by identifier range. Total memory is unchanged, but every operation is bounded and the work spreads across nodes.
-- shard = id >> 24, which is 16,777,216 users and 2 MiB per shard -- offset = id & 0xFFFFFF SETBIT dau:2026-08-15:s119 8321004 1 BITCOUNT dau:2026-08-15:s119 -> count for this shard only -- 239 shards cover 4 billion users; pipeline the counts and sum in the client
Check density before choosing the structure at all. A bitmap costs one bit per possible user, while a set costs roughly 50 bytes per actual member once it outgrows its compact encoding, so a set is cheaper below roughly a quarter of one percent activity. If only the count is needed and not the identities, a HyperLogLog holds it in about 12 KB at any scale.
| Command | Cost | What it does |
|---|---|---|
| PFADD key element | O(1) | Observe an element. Storage does not grow with the number of distinct elements. |
| PFCOUNT key | O(1) | Estimated distinct count, with a standard error near 0.81 percent. |
| PFMERGE dst src1 src2 | O(k) | Union of sketches, so daily keys roll up into a weekly unique count without double counting. |
| Command | Cost | What it does |
|---|---|---|
| GEOADD drivers -122.19 47.61 d:88 | O(log N) | Store a member's longitude and latitude, encoded into a single sortable number. |
| GEOSEARCH drivers FROMLONLAT -122.2 47.6 BYRADIUS 3 km ASC COUNT 10 | O(N+log M) | Ten nearest members inside three kilometers, nearest first. |
| GEODIST drivers d:88 d:91 km | O(log N) | Distance between two members. |
Because it is a sorted set underneath, ordinary sorted set commands work on it: remove a member with the sorted set removal command, and count members with the cardinality command.
| Command | Cost | What it does |
|---|---|---|
| XADD events MAXLEN ~ 1000000 * type kill user u7 | O(1) | Append an entry with field value pairs. The star asks the server for a monotonically increasing identifier of the form milliseconds-sequence. The approximate length cap trims old entries cheaply. |
| XGROUP CREATE events workers 0 | O(1) | Create a consumer group starting at the beginning of the stream. |
| XREADGROUP GROUP workers w1 COUNT 10 BLOCK 5000 STREAMS events > | O(k) | Claim up to ten undelivered entries for consumer w1, blocking up to five seconds. Delivered entries move into that group's pending list. |
| XACK events workers 1755200000-0 | O(1) | Acknowledge, which removes the entry from the pending list. Without this, the entry stays claimable. |
| XAUTOCLAIM events workers w2 60000 0 COUNT 10 | O(k) | Reassign entries pending longer than 60 seconds to consumer w2. This is how a dead worker's in-flight work is recovered. |
| XRANGE events 1755200000-0 1755203600-0 | O(k) | Read a time range directly, since identifiers are timestamps. Replay is a range read. |
| Command | Cost | What it does |
|---|---|---|
| PUBLISH room:42 payload | O(N+M) | Deliver to every current subscriber. Returns how many received it. |
| SUBSCRIBE room:42 | O(1) | Listen. The connection enters subscriber mode. |
| SPUBLISH room:42 payload | O(k) | Sharded publish, from Redis 7. In a cluster it reaches only the shard owning that channel, instead of broadcasting to every node. |
| Command | Cost | What it does |
|---|---|---|
| BF.ADD seen key123 | O(1) | Bloom filter insert. Membership tests can produce false positives but never false negatives. |
| BF.EXISTS seen key123 | O(1) | Probable membership at a few bits per element, the guard that keeps lookups for nonexistent rows away from the database. |
| CMS.INCRBY traffic path:/a 1 | O(1) | Count-min sketch, approximate frequency per item in fixed memory. |
| TOPK.LIST hot | O(k) | Heavy hitters, for detecting hot keys or abusive clients. |
Mention these as options rather than assumptions, then confirm availability: "if the probabilistic module is available I would use a Bloom filter here, otherwise I would build the same guard with a set of known identifiers."
Time to live, written TTL, is what turns a data structure server into a cache. The mechanics are asked about often because they are quietly surprising.
| Command | Notes |
|---|---|
| SET key v EX 30 / SET key v PX 30000 | The same 30 second lifetime, expressed in seconds and in milliseconds. The P prefix means milliseconds throughout: EXAT and PXAT set an absolute expiry instant instead of a duration, EXPIRE and PEXPIRE set a lifetime on an existing key, and TTL and PTTL report what is left. |
| EXPIRE key 300 NX | Set a lifetime only if none exists. The GT and LT modifiers extend or shorten conditionally, which avoids a read-then-write race. |
| TTL key | Seconds remaining. Minus one means no expiry, minus two means no key. |
| PERSIST key | Remove the expiry, making the key permanent. |
| SET key v KEEPTTL | Overwrite the value without resetting the clock. A plain overwrite clears the expiry, which is a common accidental cache-forever bug. |
| DEL bigkey | Frees memory inline, so deleting a huge collection blocks the server. |
| UNLINK bigkey | Unlinks immediately and frees in a background thread. Prefer it for anything large. |
Use a colon-delimited convention that encodes owner, entity, and version, for example cache:v3:user:42:profile. The version segment lets a schema change invalidate everything by moving to v4 rather than scanning for keys to delete.
Expiry is not a scheduled deletion. Two mechanisms run:
The consequence: an expired key can still occupy memory, sometimes for a while, if nothing reads it and sampling has not reached it. Capacity planning based on "it expires after an hour so memory is bounded by an hour of traffic" is optimistic.
A second consequence: replicas do not expire keys on their own. They wait for the deletion to arrive from the primary, and in the meantime a read on the replica of a logically expired key correctly returns nothing.
Set a memory ceiling, then choose what happens at the ceiling:
| Policy | Behavior |
|---|---|
| noeviction | Writes fail with an error, reads keep working. Correct when Redis holds data nobody can regenerate. |
| allkeys-lru | Evict the approximately least recently used key, expiry or not. The default choice for a pure cache. |
| allkeys-lfu | Evict the approximately least frequently used key. Better when a small set of keys is hot and occasional scans would otherwise flush them. |
| volatile-lru | Same, but only among keys that carry an expiry. Lets a cache and durable working data share one instance, with only the cache evictable. |
| volatile-ttl | Evict whichever expiring key dies soonest. |
Both recency and frequency policies are approximations by sampling, not exact orderings, because maintaining a true ordering would cost memory and time on every access.
SCAN 0 MATCH cache:v3:* COUNT 200 returns a cursor and a batch. Keep calling until the cursor comes back as zero.
Its guarantee is worth stating precisely, because interviewers probe it: every element present for the entire iteration is returned at least once. Elements added or removed during the iteration may or may not appear, and duplicates are possible, so the caller must tolerate them.
The same cursor pattern exists for hashes, sets, and sorted sets. Reach for it any time the alternative is a command whose cost is the size of the keyspace or the collection.
Four mechanisms, each with a different guarantee. Knowing which one a problem needs is a senior-level signal, and picking the wrong one is a common blocker in design reviews.
Already atomic, no ceremony required. Whenever a problem can be expressed as one command, it should be.
# claim, count, and pop are all single-command atomic SET lock:job:9 token-a1b2 NX PX 30000 INCR quota:user:42:2026-08-15T14 ZPOPMIN jobs:due
Guarantee: full isolation. Nothing interleaves.
Commands between the transaction markers are queued, then executed back to back with nothing interleaved.
MULTI INCR feed:u42:unread LPUSH feed:u42 post:991 LTRIM feed:u42 0 999 EXEC
Guarantee: isolation, and nothing else. There is no rollback: if the third command fails at runtime, the first two still applied. And you cannot read a value inside the block and branch on it, because results arrive only after execution.
Watch a key, read it, compute in the client, then execute. If any watched key changed in the meantime, execution aborts and returns a null reply, and the client retries.
WATCH inventory:sku99 GET inventory:sku99 # client sees 3 # client decides 3 is enough for an order of 2 MULTI DECRBY inventory:sku99 2 EXEC # null reply if another client touched the key
Guarantee: optimistic concurrency. Correct, but it costs an extra round trip and degrades under contention, since a hot key means constant retries.
A script runs atomically on the server and can read, branch, and write within one execution. This is the tool for read-then-decide-then-write logic, which is exactly what rate limiters, lock releases, and inventory guards need.
-- release a lock 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
Load it once and invoke it by digest thereafter, so the script body is not shipped on every call. Declare every key the script touches in the key arguments, because that is how a cluster client routes the call and how it verifies all keys live on one node.
Guarantee: atomic read-modify-write. Cost: the script blocks the single execution thread, so keep it to microseconds and never loop over an unbounded collection inside one.
Each pattern below is written to stand on its own: the shape, the commands, the failure mode, and the sentence that earns the point.
11 patternsThe application, not the cache, owns the logic. On a read, look in Redis first; on a miss, read the database, write the value back with a lifetime, and return it. On a write, update the database, then delete the cached key rather than overwriting it.
Deleting rather than overwriting on a write matters because two concurrent writers can otherwise interleave their read of the database and their write to the cache, leaving the cache holding the older of the two values permanently. Deletion makes the next reader repopulate from the database instead.
A popular key expires and every in-flight request misses at the same instant, so the database receives thousands of identical queries. Fix by letting exactly one request recompute: each misser attempts SET lock:user:42 token NX PX 5000, the single winner queries the database and repopulates, and the losers either wait briefly and re-read or serve the previous value. An alternative that avoids the lock entirely is to store the value with a logical freshness timestamp and let a request refresh it probabilistically as it approaches expiry, so refreshes are spread out in time rather than synchronized.
Populating ten thousand keys in one batch with an identical lifetime makes all ten thousand expire in the same second. Add randomness to every lifetime, for example a base of 300 seconds plus a random 0 to 60, so expiry is spread across a minute.
Requests for identifiers that have no row miss the cache every time and always reach the database, which is also the shape of an easy denial of service attack. Cache the absence itself as a sentinel value with a short lifetime, or keep a Bloom filter of known identifiers and reject unknown ones before touching the database.
Sharding does not help when a single key takes the traffic, because a key lives on exactly one node. Three remedies, in the order you should offer them: a short-lived in-process cache in the application layer, which absorbs most reads and is bounded by its own lifetime; splitting the key into a small number of copies with a suffix so readers choose one at random and spread the load; and read replicas, if serving a slightly stale value is acceptable.
Rate limiting is the most common Redis question in a system design loop because it exercises expiry, atomicity, and a real accuracy versus memory tradeoff.
The key embeds the window, so the window rolls over by key naming alone.
INCR rl:user:42:2026-08-15T14:23 # returns the new count EXPIRE rl:user:42:2026-08-15T14:23 120 # only needed when the count came back as 1
One small key per user per window, constant time, trivially correct under concurrency because the increment is atomic. Its flaw is the boundary: a caller who spends the full allowance at the end of one minute and again at the start of the next has sent twice the limit inside a two second span. Offer this first, name the flaw, and let the interviewer decide whether it matters.
Exact enforcement over a continuously moving window. The member is a unique request identifier and the score is the arrival time in milliseconds. All four steps run in one script so that concurrent requests cannot both pass the check.
-- KEYS[1] = rl:user:42 ARGV = now_ms, window_ms, limit, request_id redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2]) local used = redis.call('ZCARD', KEYS[1]) if used < tonumber(ARGV[3]) then redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4]) redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end return 0
Walked through completely, with a limit of 5 requests per 60,000 milliseconds on the key rl:user:42. Every step shows the full contents of the sorted set after the step runs.
Cost: memory proportional to the limit per active caller, because every admitted request is stored for the length of the window. A limit of 1,000 per minute across a million active users is a billion members, which is the moment to switch designs.
Constant memory per caller regardless of the limit, and it permits a controlled burst. Two fields hold the state: tokens remaining and the time of the last refill. On each request a script computes how many tokens have accrued since the last refill, caps that at the bucket capacity, and spends one if any remain.
-- KEYS[1] = tb:user:42 ARGV = now_ms, refill_per_ms, capacity, cost local s = redis.call('HMGET', KEYS[1], 'tokens', 'ts') local tokens = tonumber(s[1]) or tonumber(ARGV[3]) local ts = tonumber(s[2]) or tonumber(ARGV[1]) tokens = math.min(tonumber(ARGV[3]), tokens + (ARGV[1] - ts) * ARGV[2]) if tokens >= tonumber(ARGV[4]) then redis.call('HSET', KEYS[1], 'tokens', tokens - ARGV[4], 'ts', ARGV[1]) redis.call('PEXPIRE', KEYS[1], 60000) return 1 end redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[1]) return 0
| Design | Memory per caller | Accuracy | Burst |
|---|---|---|---|
| Fixed window | One integer | Allows double the limit at a boundary | Uncontrolled at boundaries |
| Sliding log | One member per admitted request | Exact | None |
| Token bucket | Two fields | Exact against a rate, not a window | Configurable, equal to capacity |
Acquire by claiming a key only if it does not exist, with a value unique to this holder and a lifetime that bounds the damage from a crash:
SET lock:invoice:88 <random-token> NX PX 30000
Release with a script that compares the token before deleting, shown in the atomicity section above. A plain delete is a bug: if the lifetime expired and another holder took the lock, a plain delete releases their lock. Extend a long-running job by a script that compares the token and then pushes the expiry out.
Writes are a score increment, reads are a rank range. Both are logarithmic, so a board with millions of players answers the top ten in microseconds.
ZINCRBY lb:season:9 25 u7 # award 25 points ZRANGE lb:season:9 0 9 REV WITHSCORES ZREVRANK lb:season:9 u7 # this player's rank ZCOUNT lb:season:9 1450 +inf # how many are ahead of a score
Time-boxed boards come from key naming: a daily, weekly, and all-time key updated in the same pipeline, with lifetimes on the periodic ones so old boards evict themselves.
Scaling notes to volunteer: one board is one key on one node, so it is a hot key by construction. At tens of millions of members, memory is on the order of tens of bytes per entry and rank queries stay cheap, so the first thing to break is usually write throughput rather than read cost. If it does break, shard by score band and answer a rank query by counting members above the player's score in each shard, which keeps ranks exact while spreading writes.
Store the job identifier as the member and the time it should run as the score, then have pollers read the band from the beginning of time up to now.
ZADD jobs:due 1755205200000 job:9912 -- claim atomically so two pollers cannot take the same job local due = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 20) for i, id in ipairs(due) do redis.call('ZREM', KEYS[1], id) end return due
The claim has to remove and return in one atomic step, otherwise two pollers read the same batch and the job runs twice.
The pattern worth stealing from this one is not the game, it is the shape: when a workload needs two orderings over the same population, you keep two sorted sets over one member set, because a sorted set carries exactly one score. A matchmaker picks whom to build a match around by longest wait, then searches for opponents by skill rating. Those are two different axes over the same players, so neither one set nor a single composite score can serve both.
| Key | Shape | Score means |
|---|---|---|
| mm:pool:na:ranked | Sorted set | Matchmaking rating, so a range read is a rating window |
| mm:enq:na:ranked | Sorted set | Enqueue time, so index 0 is always the longest waiter |
| mm:player:9182 | Hash | Region, mode, rating, state. It is what tells a cancel which two keys to clean |
Each tick reads the anchor from the time set, derives a rating window from how long that anchor has waited, and reads the window out of the rating set. Only steps 1 and 4 and 6 are Redis calls; the rest is arithmetic, and saying so is what stops the trace sounding like magic.
# 1. the anchor. 0 0 are POSITIONS, not scores: no BYSCORE here ZRANGE mm:enq:na:ranked 0 0 WITHSCORES -> p:9182, 1755530400000 # 2. w = now - that score = 5000 ms (arithmetic) # 3. window = 50 + 25 * floor(w / 5000) = 75, capped at 400 ZSCORE mm:pool:na:ranked p:9182 -> "1450", the anchor's rating # 5. bounds = 1450 +/- 75 (arithmetic) # 6. same command name, different key, different axis: these ARE scores ZRANGE mm:pool:na:ranked 1375 1525 BYSCORE LIMIT 0 50 WITHSCORES
Anchor on wait, search on the other axis. Sweeping the rating axis alone starves whoever sits at a sparse part of the curve. Anchoring on the oldest member puts a ceiling on wait time, and widening the window with age is what pays for that ceiling. The same shape works for any "match these two populations, but do not let anyone starve" problem.
Reading candidates and removing them are two round trips, and in the gap another worker or a cancel can take one. Put the check and the removal in one script and the gap closes with no lock:
-- KEYS[1] = the rating set, KEYS[2] = the time set, ARGV = candidate ids for i = 1, #ARGV do if redis.call('ZSCORE', KEYS[1], ARGV[i]) == false then return 0 end end redis.call('ZREM', KEYS[1], unpack(ARGV)) redis.call('ZREM', KEYS[2], unpack(ARGV)) return #ARGV
The property to name out loud: there is no rollback path to write, because a partial claim is not reachable. The script either finds every member present and removes them all, or finds one missing and removes nothing. A cancel is the same shape, so Redis serializes cancel against claim for free, and the design work moves to what the losing side is told: a cancel that removes zero members means a match already exists, so answer 409 with the match identifier rather than a bare failure.
Shard on region:mode, so two workers never touch the same keys. Do not shard on the score axis you search: a rating band boundary is a wall the widening window can never cross, so someone just under a boundary waits forever while a fine opponent sits just above it. Region and mode are product walls; a rating wall is an artifact of your sharding scheme.
SET mm:lease:na:ranked worker-3 NX PX 10000 # renew at ~1/3 of the TTL INCR mm:fence:na:ranked -> 41, this owner's token -- inside the claim script, before anything is removed if redis.call('GET', KEYS[3]) ~= ARGV[1] then return -1 end
A client sends a unique key with a request that must not be applied twice. The claim and the check are the same command:
SET idem:pay:<key> in-progress NX EX 86400
A successful set means this caller is the first, so proceed and then overwrite the value with the stored response. A null reply means the request was already handled, so read the key: if it holds a response, return that identical response; if it still says in progress, the original is running, so answer with a retry-later status rather than executing a second time.
Two details worth naming: the lifetime must exceed the longest plausible client retry window, and the stored value should include a hash of the request body so that the same key sent with different content is rejected as a conflict instead of silently returning the wrong result.
The list version is two commands. A producer pushes onto the head; a consumer atomically moves an entry from the tail of the queue onto its own processing list and deletes it from there when done.
LPUSH q:email job:551 BLMOVE q:email q:email:w1 RIGHT LEFT 5 # work happens here LREM q:email:w1 1 job:551
If the worker dies mid-job the entry sits in its processing list, so a reaper must find abandoned entries and push them back. The stream version gets that machinery for free: delivery is tracked per consumer group and stranded work is reassigned by a single reclaim command with an age threshold.
Choose the list when the queue is simple, short lived, and one consumer group is enough. Choose the stream when you need acknowledgements, redelivery, or multiple independent consumers of the same events.
| Question | Shape | Cost |
|---|---|---|
| How many events | String counter, incremented | Exact, 8 bytes per counter |
| How many distinct users | HyperLogLog | About 0.81 percent error, roughly 12 KB per counter |
| Which specific users | Bitmap by user offset, or a set | Exact, one bit per user, or full identifiers |
The interviewer is usually testing whether you ask about accuracy requirements before choosing. Ask whether the number is displayed to users, billed on, or reported to a regulator, then pick.
For high-volume counters, batch in the application layer and flush an aggregate increment periodically, rather than sending one command per event. Say the tradeoff: fewer round trips, but any counts buffered in a process that crashes are lost.
A filter applied to a ranking. Combining a board with a set of identifiers narrows it, because a set contributes a score of one per member, so weighting that side at zero preserves the original points.
ZINTERSTORE lb:friends:u42 2 lb:global friends:u42 WEIGHTS 1 0 ZRANGE lb:friends:u42 0 9 REV WITHSCORES -> a friends-only leaderboard
Last touched, which gives deduplication a list cannot. Viewing an item twice appends twice to a list. Here the member is the item and the score is the time, so a repeat view moves it instead of duplicating it, and trimming is by rank.
ZADD rv:u42 1755205200 item:991 -- a second view just updates the score ZREMRANGEBYRANK rv:u42 0 -21 -- keep only the 20 newest ZRANGE rv:u42 0 19 REV -> recently viewed, newest first
Nothing at all, so ordering falls to the member itself. Give every member the same score and the set sorts lexicographically, which turns a range query into a prefix scan for typeahead.
ZADD terms 0 banana 0 band 0 bandana 0 bank 0 cactus ZRANGE terms "[ban" "[ban\xff" BYLEX -> banana, band, bandana, bank
An attribute value, making the set a secondary index. Redis cannot search by anything but the key, so an index you maintain yourself is the only way to ask for a price band. The same shape gives stable pagination, since paging by an exclusive score bound does not shift when rows are inserted, the way an offset does.
ZADD idx:price 2499 sku:88 1899 sku:91 3200 sku:14 ZRANGE idx:price 1800 2600 BYSCORE -> sku:91, sku:88 ZRANGE feed:u42 (1755205200 -inf BYSCORE REV LIMIT 0 20 -- the page after the item scored 1755205200, the parenthesis makes the bound exclusive
Popularity that fades. Events raise the score and a periodic job multiplies every score down at once, so trending decays with no per-item bookkeeping and no background timer per entry.
ZINCRBY trending 1 post:551 -- on each view ZUNIONSTORE trending 1 trending WEIGHTS 0.9 -- every 10 minutes, decay all ZREMRANGEBYSCORE trending -inf 0.5 -- drop what has faded out
Presence. Each client heartbeats into a sorted set with the current time as the score. Online means a score newer than the cutoff, and pruning is one range removal on a timer.
ZADD presence:room:42 1755205200 u7 ZRANGEBYSCORE presence:room:42 1755205140 +inf # seen in the last 60s ZREMRANGEBYSCORE presence:room:42 0 1755205140
Sessions. A hash per session with a lifetime refreshed on each request, which makes idle timeout automatic. Since the store is shared, any application server can serve any request, so no sticky routing is needed.
The operational half of the interview. Candidates who only know the commands stop here; the ones who get the offer can describe what happens when a node dies.
A primary streams its writes to replicas asynchronously. It does not wait for them, which is why write latency stays low and why a failover can lose recently acknowledged writes.
There is a command that blocks until a given number of replicas have confirmed, which converts some of that risk into latency. It is not a consensus commit, since a failover can still promote a replica that did not confirm, but it is worth naming as a mitigation.
Replicas serve reads at the cost of staleness. Reading your own write from a replica may return the previous value, so route reads that must reflect a just-completed write to the primary.
| Mechanism | Behavior |
|---|---|
| Snapshot | A point-in-time image of the dataset, written by forking the process and copying pages as they change. Compact, fast to reload, and it loses every write since the last snapshot. |
| Append-only log | Every write command appended to a file. Syncing to disk every second is the usual setting, which bounds loss at about one second. Syncing on every write is durable and slow. The file is periodically rewritten into a compact form so it does not grow without bound. |
Running both is common: the log for recovery granularity, the snapshot for fast restarts and backups.
Sentinel is a monitoring layer for a single primary with replicas. A quorum of sentinels agrees the primary is down, promotes a replica, and clients ask the sentinels who the current primary is. One dataset, one node's worth of memory and throughput.
Cluster shards the keyspace across many primaries, each with its own replicas, and performs failover per shard. Choose it when the dataset or the write rate exceeds a single node, and accept the constraints in the next card.
The keyspace is divided into 16,384 hash slots. A key's slot is a checksum of the key name reduced into that range, and each primary owns a contiguous set of slots. Clients cache the slot map and are redirected when it changes during resharding.
Multi-key commands, transactions, and scripts require every key to live in one slot. If any key names appear in braces, only the text inside the braces is hashed, which lets related keys be pinned together deliberately:
CLUSTER KEYSLOT user:42:cart -> 12984 CLUSTER KEYSLOT user:42:profile -> 9133 CLUSTER KEYSLOT {user:42}:cart -> 15880 CLUSTER KEYSLOT {user:42}:profile -> 15880 CLUSTER KEYSLOT {user:42}:orders -> 15880 CLUSTER SHARDS -- which node owns which slot range
The first two land on different nodes, so a script touching both is rejected. The last three are pinned to one slot by the brace tag, so they can be read together, updated in one transaction, and touched by one script.
| Failure | Effect | Design response |
|---|---|---|
| Primary dies | Seconds of unavailability for that shard during promotion, and writes not yet replicated are lost. | Ensure the application degrades to the database instead of erroring, and never keep unrecoverable state only in Redis. |
| Cache is cold after restart | Every read misses and the database sees full traffic at once. | Single-flight repopulation, request-level admission control, and warming the top keys before shifting traffic back. |
| Memory ceiling reached | Either writes are refused or unrelated keys are evicted, depending on policy. | Alert on used memory and eviction rate, keep the cache and any durable working set on separate instances or use an expiry-only eviction policy. |
| One key gets hot | One node saturates while the rest of the cluster is idle. | A short-lived in-process cache in front, key splitting across a few copies, or read replicas for stale-tolerant reads. |
| One command is slow | Every client's latency rises together, because execution is serial. | Cap collection sizes, use cursor iteration instead of full reads, unlink large keys instead of deleting them, and watch the slow command log. |
N is collection size, k is the number of elements returned or touched. The pattern to internalize: writes and rank lookups on a sorted set are logarithmic, everything on strings and hashes is constant, and anything that returns a whole collection is linear and therefore dangerous.
| Command | Use |
|---|---|
| GET SET INCR GETDEL GETEX | Cache reads and writes, counters, claims |
| HSET HGET HINCRBY | Object fields, bucket state |
| LPUSH RPUSH LPOP RPOP BLPOP BLMOVE | Queues, capped feeds |
| SADD SISMEMBER SCARD SPOP | Membership, deduplication |
| SETBIT GETBIT | Per-user flags, allocation maps, daily activity |
| PFADD PFCOUNT | Approximate distinct counts |
| XADD XACK | Event append and acknowledgement |
| Command | Use |
|---|---|
| ZADD ZINCRBY ZREM | Score writes |
| ZSCORE ZRANK ZREVRANK ZCOUNT | Point and rank lookups |
| ZRANGE (plus k) | Top-K, score bands, time ranges |
| ZPOPMIN BZPOPMIN | Priority queue pop |
| GEOADD GEODIST | Position writes and distances |
| Command | Safer alternative |
|---|---|
| KEYS | SCAN with a cursor |
| SMEMBERS HGETALL | SSCAN, HSCAN, or targeted field reads |
| DEL on a large collection | UNLINK, which frees in the background |
| LRANGE deep into a long list | Cap the list with LTRIM so depth is bounded |
| BITCOUNT over a huge bitmap | Range-limited counts, or precomputed rollups |
Delivery notes. These are the sentences that move a Redis answer from competent to senior, and the questions that should trigger them.
In most of these it is a supporting component rather than the headline, which is why it is easy to leave out and easy to score with.
| Question | What the score is | The commands |
|---|---|---|
| Ticket booking | Seat hold expiry, so abandoned carts release themselves | ZADD holds:show:9 1755205500 seat:47 then a reaper on ZRANGE holds:show:9 0 now BYSCORE |
| Virtual waiting room | Join time, so position in line is a rank lookup | ZRANK queue:show:9 u42 for position, ZPOPMIN queue:show:9 500 to admit a batch |
| News feed | Post time, chosen over a list so edits update in place and paging stays stable | ZADD feed:u42 1755205200 post:991, ZREMRANGEBYRANK feed:u42 0 -801 to cap depth |
| Top K and trending | Event count per time bucket | ZINCRBY top:2026-08-15T14 1 post:551, then ZUNIONSTORE across 60 buckets for the hour view |
| Ride hailing and proximity | An encoded coordinate, since the geo commands are a sorted set underneath | GEOSEARCH drivers FROMLONLAT lon lat BYRADIUS 3 km ASC COUNT 10, with a parallel last-update set pruned by ZREMRANGEBYSCORE |
| Web crawler | Earliest time a domain may be fetched again, which enforces politeness and fairness in one pop | ZPOPMIN frontier |
| Chat and messaging | Sequence number of undelivered messages per recipient | ZRANGE outbox:u42 (last_ack +inf BYSCORE on reconnect, ZREMRANGEBYSCORE on acknowledgement |
| Payments and notifications | Next retry time, so exponential backoff needs no queue per delay tier | ZADD retries 1755205800 job:88, claimed by a script that reads the due band and removes in one execution |
| Rate limiting | Arrival time of each admitted request | The four-step script in cluster 06 |
| Contest leaderboard | Points, with an inverted timestamp in the fraction so an earlier finish outranks a later one at equal points | ZADD lb:contest:7 1450.0173 u7, ZREVRANK lb:contest:7 u7 |
Never say "cache it in Redis" alone. Say the key, the shape, the access pattern, and the lifetime, in one breath: "key is lb:season:9, a sorted set, member is the player identifier and score is points, no expiry because the season key is dropped at rollover."
Naming all four takes ten seconds and pre-empts the three follow-up questions the interviewer had queued.
Volunteer that replication is asynchronous and a failover can lose recent writes, then say what your design does about it. Every strong answer eventually contains one of these three sentences: this data is derived and rebuildable, this data is written to the durable store first and Redis holds the read view, or this data can be lost and here is the user-visible consequence.
The moment a design says "check the count and then add if it is under the limit," or "delete the lock if we still own it," the two steps must be one atomic execution. Watch-based retries are the alternative, and they are correct but degrade under contention. Naming this fork, and picking a side with a reason, is a reliable senior signal.
Saying where the tool stops is more convincing than claiming it does everything.