Redis Interview Questions and Answers

Last updated:

Check out 45 of the most common Redis interview questions, then take an AI-powered practice interview

CachingPub/SubData StructuresLua ScriptingCluster
45+
Questions
18
Basic
18
Intermediate
9
Advanced
Q1

What is Redis, and when would you choose it over Memcached or a Postgres table?

BasicFundamentals

Answer

Redis is an in-memory data structure server. It keeps the entire working set in RAM, executes commands on a single thread so every individual command is atomic, and optionally persists to disk with RDB snapshots or an append-only file. Unlike a plain key-value cache it ships real data structures: hashes, lists, sets, sorted sets, streams, bitmaps, HyperLogLogs and geospatial indexes, each with commands that mutate them server side.

You choose Redis over Memcached when you need those structures, replication, persistence, Lua scripting, cluster mode or pub/sub. Memcached is still fine for one thing: a pure LRU string cache with multi-threaded reads and a very simple operational story. You choose Redis over a Postgres table when the access pattern is small, hot and extremely frequent, a session lookup on every request, a rate-limit counter, a leaderboard read, and when losing a few seconds of data on a hard failure is acceptable.

You do not choose Redis as the system of record for money, orders or user accounts, because replication is asynchronous and a failover can drop acknowledged writes. The framing interviewers want is that Redis is a latency tool that trades durability for speed, and that you know exactly which side of that trade each of your keys sits on.

Key Points

  • In-memory data structure server, not just a string cache
  • Single-threaded command execution makes each command atomic
  • Structures, replication, Lua and cluster are what beat Memcached
  • Asynchronous replication means it is not a system of record
  • Sub-millisecond p99 is the reason it exists in your stack
💡 Pro Tip: If your answer does not mention the durability trade-off, the interviewer will ask about it next. Volunteer it.
Q2

Which core data types does Redis provide, and how do you decide between a hash, a sorted set and a stream?

BasicData Types

Answer

The core set is strings (including counters and bitmaps), lists, hashes, sets, sorted sets, streams, HyperLogLog and geospatial indexes, plus vector sets and JSON in Redis 8. Picking correctly is most of Redis design. Use a hash when you have one entity with many fields and you read or write fields independently, HSET user:1042 name Priya plan gold, because HGET touches one field instead of deserialising a whole JSON blob.

Use a sorted set when you need ordering by a numeric score with fast rank and range queries: leaderboards, priority queues, time-ordered indexes, sliding-window rate limiters. Use a stream when you need an append-only log that multiple consumer groups read independently with acknowledgements and replay, which pub/sub cannot do. Use a plain set for membership and de-duplication, a list for a simple FIFO or LIFO queue, a bitmap for dense boolean flags across user IDs, and HyperLogLog when you need approximate unique counts over millions of items in 12 KB.

The wrong choice usually shows up as a big key: a single set with two million members, or a list you keep calling LRANGE 0 -1 on. Interviewers often give a scenario (recent searches, top sellers, live match scores) and ask which structure and why, so practise reasoning out loud about read pattern, write pattern and cardinality.

# Entity with independently updated fields -> hash
HSET user:1042 name Priya plan gold city Pune
HGET user:1042 plan
HINCRBY user:1042 login_count 1

# Ranked data -> sorted set
ZADD leaderboard:week32 4820 user:1042
ZINCRBY leaderboard:week32 50 user:1042
ZRANGE leaderboard:week32 0 9 REV WITHSCORES

# Append-only event log with consumer groups -> stream
XADD orders:events '*' order_id 88123 status PLACED

# Approximate uniques in 12 KB -> HyperLogLog
PFADD dau:2026-08-11 user:1042 user:1043
PFCOUNT dau:2026-08-11

Key Points

  • Hash for entities with independently accessed fields
  • Sorted set for anything ranked or time-ordered
  • Stream for replayable logs with consumer groups and acks
  • HyperLogLog for approximate uniques at ~0.81% error
  • Wrong structure choice usually surfaces later as a big key
Q3

How do TTLs work in Redis, and what do TTL replies of -1 and -2 mean?

BasicExpiry

Answer

Every key can carry an expiry, set either at write time with SET key value EX 300 (seconds) or PX 300000 (milliseconds), or afterwards with EXPIRE, PEXPIRE, EXPIREAT and PEXPIREAT. TTL returns the remaining seconds, PTTL the remaining milliseconds. Two reply values trip candidates up constantly: TTL returns -1 when the key exists but has no expiry, and -2 when the key does not exist at all.

Confusing those two is a real bug source, since code that treats -1 as missing will silently ignore your permanent keys. PERSIST removes an expiry and makes the key permanent. A subtle behaviour worth knowing: writing a new value with plain SET clears any existing TTL, so a background refresher that does SET without EX quietly converts a cache entry into a permanent key that then hangs around until eviction.

SET KEEPTTL preserves the existing expiry, and GETEX lets you read and re-arm the TTL in one round trip. Commands that mutate a value in place, such as INCR, HSET, LPUSH or ZADD, do not touch the TTL, so a counter you EXPIRE once keeps its original deadline no matter how many times you increment it. Redis 7.4 added per-field expiry inside hashes through HEXPIRE, HTTL and HPERSIST, and Redis 8 added HGETEX and HGETDEL, which finally removes the old workaround of one key per field just to get a TTL.

SET session:abc '{"uid":1042}' EX 1800
TTL session:abc          # 1800

SET session:abc '{"uid":1042,"plan":"gold"}'
TTL session:abc          # -1  TTL was wiped by the plain SET

SET session:abc '{"uid":1042}' KEEPTTL   # preserves remaining TTL
GETEX session:abc EX 1800                # read and re-arm together

TTL does:not:exist       # -2

# Redis 7.4+: TTL on individual hash fields
HSET cart:1042 sku:991 2 sku:772 1
HEXPIRE cart:1042 900 FIELDS 1 sku:991
HTTL cart:1042 FIELDS 1 sku:991
💡 Pro Tip: Wrap your cache writes in a helper that always requires a TTL argument. Most runaway memory incidents start with one SET that forgot EX.
Q4

What do the NX, XX, KEEPTTL and GET options on SET do, and why is SETNX considered legacy?

BasicStrings

Answer

SET grew into a small command language of its own. NX writes only if the key does not exist, XX writes only if it already exists, EX and PX attach a TTL in seconds or milliseconds, EXAT and PXAT attach an absolute Unix deadline, KEEPTTL retains the existing expiry, and GET (added in Redis 6.2) returns the previous value in the same atomic operation, which makes the old GETSET command redundant. The reason SETNX is legacy is that it can only set a key without a TTL.

The classic broken lock is SETNX lock:order:88 1 followed by EXPIRE lock:order:88 30 as two separate commands: if the client crashes between them, the lock exists forever and the resource is deadlocked until someone deletes it by hand. SET lock:order:88 <token> NX EX 30 does both atomically in one round trip and is the only form you should write in new code. Interviewers use this question as a quick filter, because a candidate who still reaches for SETNX plus EXPIRE usually has not thought about partial failure at all. Two related commands worth naming: GETDEL reads and deletes atomically (useful for one-time tokens such as OTPs or password-reset links), and GETEX reads and updates the TTL, which gives you sliding-session behaviour without a second write.

# Atomic lock acquire: value is a unique token, not 1
SET lock:order:88123 8f3c1a-owner-token NX EX 30

# Update only if present, keep the original TTL
SET session:abc newpayload XX KEEPTTL

# Read the old value and write the new one atomically
SET feature:flag on GET

# One-time token: read and destroy in a single round trip
SET otp:9876543210 482913 EX 300 NX
GETDEL otp:9876543210

Key Points

  • NX / XX for conditional writes, EX / PX / EXAT for expiry
  • SET with GET replaces GETSET atomically since 6.2
  • SETNX plus EXPIRE is not atomic and leaks permanent locks
  • GETDEL and GETEX cover one-time tokens and sliding sessions
Q5

Why is INCR safe under concurrency when a GET followed by a SET is not?

BasicAtomicity

Answer

Redis executes commands one at a time on a single thread, so every command is atomic with respect to every other command. INCR reads, adds one and writes back inside that single execution slot, and no other client can observe or interleave with the intermediate state. A read-modify-write done in your application, GET counter, add one in Node or Java, SET counter, is three separate trips across the network with two windows where another process can do the same thing.

Under any real concurrency you lose increments, and the loss rate grows with traffic, which is why the bug usually appears only in production. The INCR family covers most needs: INCR, DECR, INCRBY, DECRBY and INCRBYFLOAT for strings, HINCRBY and HINCRBYFLOAT for hash fields, ZINCRBY for sorted-set scores. INCR also creates the key at zero if it does not exist, so you do not need an initialisation step, which matters for counters keyed by a time bucket.

Two production notes. First, INCR does not set or refresh a TTL, so a fresh counter key needs an EXPIRE, and the standard pattern is INCR then EXPIRE only when the reply is 1. Second, string counters are 64-bit signed integers and overflow returns the error increment or decrement would overflow, so a counter you never reset can eventually fail rather than wrap.

// ioredis: atomic per-minute counter with a TTL armed once
const key = `api:calls:${userId}:${Math.floor(Date.now() / 60000)}`;
const count = await redis.incr(key);
if (count === 1) {
  await redis.expire(key, 120); // only the first caller arms expiry
}
if (count > 100) throw new Error('rate limit exceeded');

// Broken version: two round trips, lost updates under load
// const current = Number(await redis.get(key)) || 0;
// await redis.set(key, current + 1);
💡 Pro Tip: Any time you catch yourself writing GET then SET on the same key, ask whether an INCR, HINCRBY, a Lua script or SET with NX would do it in one atomic step.
Q6

When should you store an object as a Redis hash instead of a serialised JSON string?

BasicData Modelling

Answer

Use a hash when you read or update fields independently, and a JSON string when you always fetch the whole object at once. A hash lets you do HGET user:1042 plan without transferring the rest of the record, HINCRBY a counter field atomically, and HSET a single field without a read-modify-write cycle on the entire blob. That last point matters most: with a JSON string, two concurrent writers each doing GET, parse, mutate, stringify, SET will silently overwrite each other.

A hash write touches only the field you named. Memory is the other half of the answer. Small hashes are stored as a listpack, a compact flat encoding, as long as they stay under hash-max-listpack-entries (default 128) and hash-max-listpack-value (default 64 bytes).

Below those thresholds a hash is often smaller than the equivalent JSON string; above them Redis converts to a real hash table and per-field overhead jumps. Check with OBJECT ENCODING. JSON strings still win when the object is always consumed whole and your client compresses it, or when you need the Redis 8 JSON type with JSONPath queries via JSON.GET and JSON.SET.

The failure mode interviewers look for is the giant hash: a single key holding a million fields, where HGETALL blocks the server for milliseconds and blows up the client buffer. Shard those by prefix, or use HSCAN.

HSET user:1042 name Priya plan gold credits 40
OBJECT ENCODING user:1042      # listpack while small

HGET user:1042 plan            # one field, no full transfer
HINCRBY user:1042 credits -5   # atomic, no read-modify-write
HDEL user:1042 credits

# Never do this on a hash with a million fields
# HGETALL bigkey:everything
HSCAN user:1042 0 COUNT 100 NOVALUES   # NOVALUES added in 7.4

CONFIG GET hash-max-listpack-entries

Key Points

  • Hash for independent field access and atomic field updates
  • JSON string forces read-modify-write and loses concurrent edits
  • listpack encoding keeps small hashes memory efficient
  • hash-max-listpack-entries 128 / -value 64 are the conversion points
  • HGETALL on a huge hash stalls the single command thread
Q7

How would you build a leaderboard with sorted sets, and what does ZADD GT actually change?

BasicSorted Sets

Answer

A sorted set stores unique members each with a floating-point score, kept ordered by score, so a leaderboard is essentially free. ZADD writes a score, ZINCRBY adds to it, ZSCORE reads one member's score, ZRANK and ZREVRANK give the position (zero-based, so add one for display), and ZRANGE key 0 9 REV WITHSCORES returns the top ten with scores. Since Redis 6.2 the range commands consolidated: ZRANGE takes BYSCORE, BYLEX, REV and LIMIT, which deprecates ZREVRANGE, ZRANGEBYSCORE and ZREVRANGEBYSCORE, and ZRANGESTORE writes a range straight into another key so you can materialise a top-100 snapshot without shipping it to the client.

The flags interviewers ask about are NX, XX, GT and LT. NX only adds new members, XX only updates existing ones, GT updates the score only if the new score is greater than the current one, and LT only if it is lower. GT is what you want for a high-score board where a later, worse result must not overwrite a personal best, and it removes an entire read-compare-write race from your application code. Two operational notes: ties are broken lexicographically by member, so encode a timestamp into the score if ordering among equal scores matters, and a sorted set with millions of members is a big key whose ZRANGE 0 -1 will hurt, so always page with LIMIT.

ZADD lb:week32 GT CH 4820 user:1042   # only raises the score
ZINCRBY lb:week32 50 user:1042

ZRANGE lb:week32 0 9 REV WITHSCORES   # top 10
ZREVRANK lb:week32 user:1042          # 0-based rank
ZCOUNT lb:week32 1000 +inf

# Score range with paging (6.2+ syntax)
ZRANGE lb:week32 5000 1000 BYSCORE REV LIMIT 0 20

# Materialise a snapshot server-side, no data over the wire
ZRANGESTORE lb:week32:top100 lb:week32 0 99 REV
EXPIRE lb:week32:top100 60
💡 Pro Tip: CH makes ZADD return the number of changed elements rather than only newly added ones, which is what you usually want for metrics.
Q8

How do you use a Redis list as a work queue, and why is BRPOPLPUSH deprecated?

BasicLists

Answer

The simple pattern is a producer doing LPUSH queue:jobs payload and a worker doing BRPOP queue:jobs 5, which blocks for up to five seconds waiting for work instead of polling. Blocking pops are important: a polling loop with LPOP every 100 ms wastes round trips and adds latency, while BRPOP wakes the client the moment an item arrives. The catch is that BRPOP removes the job from Redis before the worker has processed it, so a worker crash loses the job.

The reliable-queue pattern fixes that by atomically moving the item to a per-worker processing list, doing the work, then removing it from that list on success, and having a reaper re-queue anything that sits in a processing list too long. That move used to be RPOPLPUSH and BRPOPLPUSH, both deprecated since Redis 6.2 in favour of LMOVE and BLMOVE, which take explicit LEFT and RIGHT arguments on both sides and so can express all four directions instead of just right-to-left. Other list commands worth knowing: LLEN for depth (your primary queue-health metric), LRANGE for inspection, LTRIM to cap a capped list such as recent activity, LPOS to find an element's index, and LMPOP and BLMPOP for popping several elements from the first non-empty of many keys. In practice most Node teams do not hand-roll this: BullMQ implements reliable queues, retries, delayed jobs and rate limits on top of these primitives.

# Producer
LPUSH queue:jobs '{"type":"invoice","id":88123}'

# Naive worker: job is lost if the process dies mid-processing
BRPOP queue:jobs 5

# Reliable queue: atomic hand-off to a per-worker processing list
BLMOVE queue:jobs queue:processing:worker-3 RIGHT LEFT 5
# ... process the payload ...
LREM queue:processing:worker-3 1 '{"type":"invoice","id":88123}'

# Health signals
LLEN queue:jobs
LLEN queue:processing:worker-3   # non-zero and growing means stuck workers
LTRIM activity:1042 0 99         # keep only the latest 100

Key Points

  • BRPOP blocks instead of polling; pass a timeout, never 0 blindly
  • Plain BRPOP loses the job if the worker dies after popping
  • LMOVE / BLMOVE replace RPOPLPUSH / BRPOPLPUSH since 6.2
  • LLEN on the queue and the processing list are your health metrics
  • BullMQ or Sidekiq already implement this correctly, prefer them
Q9

What are the practical uses of Redis sets, and what is the difference between SPOP and SRANDMEMBER?

BasicSets

Answer

A set is an unordered collection of unique strings with O(1) add, remove and membership checks, which makes it the right structure for de-duplication, tagging, permission lists and any answer to the question have I already seen this. SADD adds, SREM removes, SISMEMBER checks one member, SMISMEMBER (Redis 6.2) checks several in one round trip, SCARD gives the size and SMEMBERS returns everything, which you should avoid on large sets. The set-algebra commands are what people forget in interviews: SINTER, SUNION and SDIFF compute intersections, unions and differences server side, and the STORE variants (SINTERSTORE, SUNIONSTORE, SDIFFSTORE) write the result into a new key so nothing crosses the network.

SINTERCARD, added in 7.0, returns just the size of an intersection with an optional LIMIT, which is much cheaper than materialising the whole intersection to count it. SPOP versus SRANDMEMBER is a favourite quick check: SPOP removes and returns random members, SRANDMEMBER returns them without removing. SRANDMEMBER also has a signed count trick, a positive count returns distinct members while a negative count allows repeats and can return more elements than the set holds.

Use SPOP for consuming a pool (allocating a coupon code, handing out a slot) and SRANDMEMBER for sampling (showing three random recommendations). The production caveat is the same as everywhere: a set with millions of members turns SMEMBERS, SUNIONSTORE and SDIFF into blocking operations on a single-threaded server.

SADD article:991:tags redis caching backend
SISMEMBER article:991:tags redis        # 1
SMISMEMBER article:991:tags redis kafka # 1 0  (6.2+)

# Server-side set algebra, nothing shipped to the client
SINTERSTORE seg:gold_and_active seg:gold seg:active_30d
SINTERCARD 2 seg:gold seg:active_30d LIMIT 1000   # 7.0+, cheap count

# Consume from a pool vs sample from it
SPOP coupons:diwali 1          # removes the code it returns
SRANDMEMBER recos:1042 3       # 3 distinct, nothing removed
SRANDMEMBER recos:1042 -5      # 5 with repeats allowed

Key Points

  • Sets give O(1) membership and natural de-duplication
  • SINTERSTORE / SUNIONSTORE keep set algebra on the server
  • SINTERCARD counts an intersection without building it
  • SPOP removes, SRANDMEMBER does not; negative count allows repeats
Q10

Why is KEYS dangerous in production, and how does SCAN behave differently?

BasicKeyspace

Answer

KEYS pattern walks the entire keyspace in one command. Because Redis executes commands on a single thread, a KEYS on a database with ten million keys blocks every other client for the duration, which on a busy instance is enough to trigger timeouts, health-check failures and a cascading incident. It shows up in postmortems more than any other single command, usually run by someone debugging in redis-cli on the primary.

SCAN solves it with cursor-based iteration: SCAN 0 MATCH user:* COUNT 100 returns a small batch plus a cursor you feed into the next call until the cursor comes back as 0. Each call is bounded work, so other clients keep getting served. The guarantees are weaker than a snapshot and you must know them: elements present for the entire iteration are returned at least once, elements added or removed mid-iteration may or may not appear, and the same element can be returned more than once, so your code must be idempotent (collect into a Set, not a list).

COUNT is a hint about work per call, not a page size, and the reply can be empty while the cursor is still non-zero, which is not the end of the iteration. The TYPE option filters by data type server side. The same cursor model exists inside collections as HSCAN, SSCAN and ZSCAN. Operationally, redis-cli --scan --pattern 'session:*' wraps this for you, and you can disable KEYS entirely with an ACL rule.

# Never on a production primary
# KEYS user:*

# Cursor iteration: bounded work per call
SCAN 0 MATCH 'session:*' COUNT 200 TYPE string
# -> 1) "3072"  2) 1) "session:abc" ...
SCAN 3072 MATCH 'session:*' COUNT 200
# repeat until the returned cursor is "0"

# Inside collections
HSCAN cart:1042 0 COUNT 100
ZSCAN lb:week32 0 MATCH 'user:10*'

# Shell wrappers
redis-cli --scan --pattern 'tmp:*' | xargs -L 100 redis-cli UNLINK
ACL SETUSER app -keys -flushall -flushdb
💡 Pro Tip: SCAN can return duplicates. If you are counting or aggregating during iteration, deduplicate client side or you will report inflated numbers.
Q11

What is the difference between DEL and UNLINK, and when does deleting a key hurt latency?

BasicKeyspace

Answer

DEL frees the key's memory synchronously on the main thread. For a small string that is microseconds and nobody notices. For a collection with millions of elements, freeing means walking and releasing every element, and because Redis runs commands on one thread the whole server stalls for that time, sometimes hundreds of milliseconds.

UNLINK, added in Redis 4.0, removes the key from the keyspace immediately and hands the actual memory reclamation to a background thread, so the calling client gets its reply straight away and other clients are not blocked. Redis is smart enough to free small objects inline even under UNLINK, so there is no penalty for using it by default. The related config family is lazy freeing: lazyfree-lazy-eviction, lazyfree-lazy-expire, lazyfree-lazy-server-del and replica-lazy-flush make eviction, expiry, implicit deletes (such as the overwrite in a RENAME) and replica flushes asynchronous too.

On recent versions these default to enabled in many managed offerings, but on a self-managed instance you should check, because a mass expiry of large keys with lazy-expire off produces exactly the unexplained latency spikes people blame on the network. FLUSHALL and FLUSHDB have the same problem and both accept ASYNC. The related inspection commands are TYPE to learn a key's data type, OBJECT ENCODING to see the internal representation, MEMORY USAGE key to size it, and EXISTS with multiple keys to count how many of them are present.

TYPE feed:global
OBJECT ENCODING feed:global     # quicklist / listpack / skiplist ...
MEMORY USAGE feed:global        # bytes, sampled for big keys

DEL feed:global                 # blocks the server while freeing
UNLINK feed:global              # returns immediately, frees in background

FLUSHDB ASYNC                   # never a bare FLUSHDB on a big instance

CONFIG SET lazyfree-lazy-expire yes
CONFIG SET lazyfree-lazy-eviction yes
CONFIG SET lazyfree-lazy-server-del yes

EXISTS user:1042 user:1043 user:9999   # -> 2

Key Points

  • DEL frees memory on the main thread and can stall the server
  • UNLINK detaches immediately and reclaims in a background thread
  • lazyfree-lazy-* config extends this to expiry, eviction and flush
  • FLUSHDB ASYNC / FLUSHALL ASYNC exist for the same reason
Q12

How does Redis pub/sub work, and why is it not a substitute for a message queue?

BasicPub/Sub

Answer

PUBLISH sends a message to a channel and Redis forwards it to whoever is subscribed at that instant through SUBSCRIBE or pattern-matching PSUBSCRIBE. It is fire and forget with at-most-once delivery: there is no persistence, no acknowledgement, no replay and no consumer group. If a subscriber is disconnected, restarting, or simply not up yet, the message is gone permanently, and PUBLISH still returns the number of receivers as if all is well.

Slow subscribers are the second trap. Redis buffers undelivered messages in the client output buffer, and the pubsub class of client-output-buffer-limit (32 mb hard, 8 mb over 60 seconds soft by default) will disconnect a subscriber that cannot keep up, so your slowest consumer silently drops off under load. In RESP2 a connection in subscriber mode can only run subscribe and unsubscribe commands, which is why every client library needs a second connection for normal commands; RESP3 relaxes that by delivering messages as out-of-band push frames.

Pub/sub is the right tool for ephemeral fan-out: cache invalidation broadcasts, live presence, pushing a WebSocket event to whichever app server holds the socket, config reload signals. The moment you need the message to survive a restart, be processed exactly once, or be retried, use Redis Streams with consumer groups, or a real broker such as Kafka, RabbitMQ or SQS. In cluster mode, plain PUBLISH is broadcast to every node, which is why sharded pub/sub (SPUBLISH and SSUBSCRIBE) was added in Redis 7.0.

# Terminal 1
SUBSCRIBE cache:invalidate
PSUBSCRIBE 'order:*'

# Terminal 2
PUBLISH cache:invalidate user:1042    # -> (integer) 2 receivers right now

# Who is listening
PUBSUB CHANNELS 'order:*'
PUBSUB NUMSUB cache:invalidate

# Cluster-friendly variant (7.0+): stays inside the channel's shard
SSUBSCRIBE orders:shard3
SPUBLISH orders:shard3 '{"id":88123}'

# Why subscribers get dropped under load
CONFIG GET client-output-buffer-limit
💡 Pro Tip: If an interviewer asks you to design notifications with pub/sub, say up front that offline users will miss messages and propose Streams or a durable broker for anything that must not be lost.
Q13

What is pipelining in Redis, and how is it different from a MULTI/EXEC transaction?

BasicPerformance

Answer

Pipelining is a client-side technique: instead of sending a command and waiting for its reply before sending the next one, you write many commands into the socket back to back and then read all the replies. Redis processes them in order and the network round trips collapse from N to roughly one. On a link with 1 ms round-trip time, a hundred sequential GETs cost about 100 ms of pure waiting, while the same hundred pipelined cost a single round trip plus the actual execution time, which is usually microseconds each.

That is why pipelining is the single biggest easy win when a service looks slow but Redis itself reports low command latency: the time is in the network, not the server. It is not a transaction. Pipelined commands from other clients can interleave with yours, there is no atomicity across the batch, and a failure in the middle does not undo earlier commands.

MULTI/EXEC queues commands and runs the whole block without interleaving, but does not reduce round trips by itself, which is why clients usually pipeline the MULTI block too. The practical limits are memory and fairness: batch in chunks of a few hundred to a few thousand commands, because the server buffers all replies for the batch and one huge pipeline can grow the output buffer and delay other clients. In ioredis you use redis.pipeline() and in node-redis you can simply fire promises and await Promise.all, since the client auto-pipelines commands issued in the same tick.

// ioredis: explicit pipeline, one round trip
const results = await redis
  .pipeline()
  .hgetall('user:1042')
  .zscore('lb:week32', 'user:1042')
  .ttl('session:abc')
  .exec(); // [[null, {...}], [null, '4820'], [null, 1780]]

// Chunked bulk load: never one pipeline of 500k commands
for (let i = 0; i < ids.length; i += 500) {
  const p = redis.pipeline();
  for (const id of ids.slice(i, i + 500)) p.sadd('seg:active', id);
  await p.exec();
}

// Transaction (atomic) that is also pipelined by the client
await redis.multi().incr('orders:count').lpush('orders:new', '88123').exec();

Key Points

  • Pipelining removes round trips; it does not add atomicity
  • MULTI/EXEC adds atomicity; it does not remove round trips
  • Other clients can interleave between pipelined commands
  • Chunk large batches to avoid huge server output buffers
Q14

How do you connect to Redis from Node.js reliably, and what client settings matter in production?

BasicClients

Answer

The two mainstream Node clients are node-redis (the official one) and ioredis, which is still the default choice when you need Cluster or Sentinel support and is what BullMQ requires. Whichever you pick, the settings that actually matter in production are the same. Create one client (or a small pool) at process start and reuse it, because opening a connection per request is a classic cause of connection exhaustion and rejected_connections in INFO.

Configure a reconnect strategy with capped exponential backoff so a brief failover does not turn into a reconnect storm from a thousand pods. Set command timeouts, otherwise a stalled primary makes every request hang until the HTTP timeout instead of failing fast. Decide explicitly what happens while disconnected: ioredis queues commands offline by default, which is convenient but can flush a surge of stale writes after reconnect, so enableOfflineQueue: false plus a graceful cache-miss path is often safer for a read cache.

For BullMQ you must set maxRetriesPerRequest: null, because blocking commands must not be retried by the client. Give blocking commands their own dedicated connection: a connection sitting in BRPOP cannot serve anything else. Add TLS and a username plus password for any managed instance, and never log the connection URL, since it usually contains the credential. Finally, listen for the error event on the client, an unhandled error event on a Node EventEmitter crashes the process.

import Redis from 'ioredis';

export const redis = new Redis({
  host: process.env.REDIS_HOST,
  port: 6379,
  username: process.env.REDIS_USER,
  password: process.env.REDIS_PASSWORD,
  tls: process.env.REDIS_TLS === 'true' ? {} : undefined,
  connectTimeout: 5000,
  commandTimeout: 1000,        // fail fast instead of hanging the request
  enableOfflineQueue: false,   // for a read cache, prefer a miss to a queue
  maxRetriesPerRequest: 2,     // must be null for BullMQ connections
  retryStrategy: (times) => Math.min(times * 200, 3000),
});

redis.on('error', (err) => logger.warn({ err }, 'redis error'));

// Blocking consumers get their own connection
export const blockingRedis = redis.duplicate();
💡 Pro Tip: Treat a Redis outage as a cache miss, not a 500. Wrap reads in a try/catch that falls through to the database, and you will survive a failover without a customer-visible incident.
Q15

Which redis-cli flags do you reach for when diagnosing a live instance?

BasicTooling

Answer

redis-cli is a diagnostic toolkit, not just a REPL. redis-cli --stat prints a one-line-per-second summary of keys, memory, clients, blocked clients and ops per second, which is the fastest way to see whether traffic or memory is the problem. redis-cli --latency and --latency-history sample PING round trips to separate network latency from server latency, and --intrinsic-latency runs a busy loop on the server host to reveal whether the machine itself (a noisy neighbour, CPU steal on a shared VM) is the source. redis-cli --bigkeys scans the keyspace and reports the largest key per type, --memkeys reports the biggest by actual memory, and --hotkeys reports the most frequently accessed keys, though that one needs maxmemory-policy set to an LFU policy because it reads OBJECT FREQ. redis-cli --scan --pattern 'x:*' iterates safely instead of running KEYS, and --rdb dumps a snapshot from a replica for offline analysis with a tool such as rdb-tools. For cluster work, redis-cli --cluster check, --cluster info, --cluster reshard and --cluster fix are the standard operations. Inside the CLI, INFO with a section name (INFO memory, INFO replication, INFO stats, INFO clients), SLOWLOG GET 10, LATENCY LATEST and CLIENT LIST cover most triage. The one command to treat with care is MONITOR, which streams every command executed and can itself cost a double-digit percentage of throughput on a busy server, so use it for seconds, never leave it running.

redis-cli --stat                       # ops/sec, memory, clients per second
redis-cli --latency-history -i 5       # server-side latency over time
redis-cli --intrinsic-latency 10       # is the host itself stalling?

redis-cli --bigkeys                    # largest key per type
redis-cli --memkeys                    # largest by memory
redis-cli --hotkeys                    # needs an LFU maxmemory-policy

redis-cli --scan --pattern 'session:*' | head
redis-cli --cluster check 10.0.1.4:6379

redis-cli INFO memory | grep -E 'used_memory_human|fragmentation'
redis-cli SLOWLOG GET 10
redis-cli LATENCY LATEST

Key Points

  • --stat and --latency-history for a live picture in seconds
  • --bigkeys / --memkeys / --hotkeys to find the offending key
  • --intrinsic-latency isolates host problems from Redis problems
  • MONITOR is expensive; sample briefly and get off it
Q16

How do you tell from INFO whether your cache is actually helping?

BasicObservability

Answer

INFO stats gives you keyspace_hits and keyspace_misses, and the hit ratio is hits divided by hits plus misses. Read it as a rate over a window, not as a lifetime total, because a counter that has been accumulating since the last restart hides a hit ratio that collapsed an hour ago. What counts as good depends on the workload: a session store should sit above 99 percent, a product-detail cache in the mid 90s, and a long-tail search cache might be genuinely useful at 60 percent.

A sudden drop almost always has one of four causes: keys are being evicted because you hit maxmemory (check evicted_keys in INFO stats, which should normally be zero), TTLs are too short (check expired_keys), a deploy changed the key format so every read misses, or the cache was flushed. INFO keyspace shows keys, expires and avg_ttl per database, and a keyspace where expires is far below keys usually means someone is writing keys without a TTL. Other numbers worth alerting on: used_memory versus maxmemory as a ratio, mem_fragmentation_ratio, connected_clients and rejected_connections (connection leak), blocked_clients (workers parked in BRPOP or BLMOVE), instantaneous_ops_per_sec, and rdb_last_bgsave_status plus aof_last_write_status, both of which should read ok. The senior version of this answer is that you export all of these to Prometheus through redis_exporter or your APM agent and alert on evicted_keys greater than zero and on hit-ratio drops, rather than discovering the problem from a latency page.

redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys|expired_keys'
# keyspace_hits:98422311
# keyspace_misses:1104882
# evicted_keys:0        <- anything above 0 means you are over maxmemory
# expired_keys:8842190

redis-cli INFO keyspace
# db0:keys=1204331,expires=1204010,avg_ttl=1712334
#   keys >> expires means someone is writing without a TTL

redis-cli INFO memory | grep -E 'used_memory:|maxmemory:|mem_fragmentation_ratio'
redis-cli INFO clients | grep -E 'connected_clients|blocked_clients'
redis-cli INFO persistence | grep -E 'rdb_last_bgsave_status|aof_last_write_status'
💡 Pro Tip: Compute the hit ratio over a five-minute delta of the counters. Lifetime ratios look healthy long after the cache has stopped working.
Q17

What are numbered Redis databases, and why do most teams avoid them?

BasicKeyspace

Answer

A single Redis server exposes sixteen logical databases by default (the databases directive), numbered 0 to 15, and SELECT 3 switches the current connection to database 3. They share the same process, the same memory limit, the same single command thread and the same persistence files, so they are namespaces, not isolation. That is the core reason teams avoid them: a runaway workload in db 2 evicts keys in db 0, and a slow command in db 5 blocks everyone.

They also break in practice. Redis Cluster supports only database 0 and returns an error if you try to SELECT another, so any code that depends on numbered databases cannot be moved to cluster mode without a rewrite, and that migration usually happens under time pressure. Connection pools are the second trap: SELECT is per connection, so a pooled connection that was left on db 3 will happily serve the next request that assumes db 0, producing bugs that are almost impossible to reproduce.

FLUSHDB hits only the current database while FLUSHALL wipes all of them, which is a distinction people discover at the worst possible moment. The standard alternative is key prefixing, for example prod:sessions:abc and prod:cart:1042, which works identically on standalone and cluster, keeps SCAN patterns simple and lets you write per-prefix ACL rules. Reserve separate databases for genuinely throwaway cases such as local development or an integration test suite that flushes between runs.

SELECT 3            # per-connection, not per-instance
SET foo bar
SELECT 0
GET foo             # (nil)

# In cluster mode
SELECT 1
# (error) ERR SELECT is not allowed in cluster mode

# Prefix namespacing works everywhere, including cluster
SET prod:session:abc '{"uid":1042}' EX 1800
SET prod:cart:1042 '{...}' EX 86400

# ioredis applies a prefix to every command for you
# new Redis({ keyPrefix: 'prod:' })

FLUSHDB   # current db only
FLUSHALL  # every db on the instance

Key Points

  • Databases share memory, CPU and persistence, so they are not isolation
  • Cluster mode allows only database 0
  • SELECT is connection state and leaks through connection pools
  • Key prefixes are the portable alternative and support ACL patterns
Q18

What changes for you as a developer between self-managed Redis, ElastiCache, MemoryDB and Redis Cloud in 2026?

BasicDeployment

Answer

The command surface is mostly the same, but the operational contract differs in ways that show up in design reviews. On self-managed Redis (an EC2 instance, a Kubernetes StatefulSet) you own everything: persistence config, kernel settings such as vm.overcommit_memory and transparent huge pages, failover through Sentinel, upgrades and backups. Managed caches remove that but also remove access, CONFIG SET is restricted to an allowlist of parameters, and commands like DEBUG, SHUTDOWN and sometimes even CONFIG are blocked, so tooling that expects them fails.

AWS ElastiCache offers both Redis OSS and Valkey engines plus a serverless mode that scales capacity automatically and bills per gigabyte-hour and per request, and AWS prices the Valkey option below the Redis one, which is why plenty of Indian teams migrated their caches to Valkey during 2025. MemoryDB is the different one: it replicates every write to a Multi-AZ transaction log before acknowledging, so it is durable and can be a primary database rather than a cache, at higher latency and cost than ElastiCache. Redis Cloud from Redis Ltd. gives you the newest engine features first (Query Engine, JSON, Bloom, vector sets) and active-active geo-replication using CRDTs. The design implications to say out loud in an interview: on managed services you cannot tune everything, cross-AZ traffic costs money and adds latency so keep clients in the same AZ where possible, and failover is done for you but is still not instantaneous, so your client still needs retries and your code still needs a cache-miss path.

Key Points

  • Managed services restrict CONFIG, DEBUG and other admin commands
  • ElastiCache Serverless bills per GB-hour plus requests, no node sizing
  • MemoryDB durably logs writes Multi-AZ, so it can be a primary store
  • Redis Cloud ships new engine features and active-active CRDT geo-replication
  • Cross-AZ hops cost both rupees and milliseconds
Q19

Walk through the maxmemory eviction policies. Which one do you pick for a cache, and what breaks with volatile-lru?

IntermediateMemory Management

Answer

maxmemory sets the byte ceiling, and maxmemory-policy decides what happens when you reach it. The eight policies are noeviction (the default, which starts rejecting writes with OOM command not allowed when used memory > maxmemory), allkeys-lru, allkeys-lfu, allkeys-random, volatile-lru, volatile-lfu, volatile-random and volatile-ttl. The allkeys family can evict any key; the volatile family can evict only keys that have a TTL set.

For a pure cache the right answer is usually allkeys-lru, or allkeys-lfu when your access pattern has a stable hot set and you do not want a one-off scan (a batch job, a crawler) to wash out genuinely popular keys. LFU counts frequency with a probabilistic counter tuned by lfu-log-factor and decayed by lfu-decay-time, and you can inspect a key's counter with OBJECT FREQ. The classic production failure is volatile-lru on an instance where most keys have no TTL: Redis has nothing eligible to evict, so it behaves exactly like noeviction and every write starts failing while the dashboard shows plenty of keys.

The second gotcha is that LRU here is approximated, not exact, Redis samples maxmemory-samples keys (default 5) and evicts the best candidate from that sample, so raising it to 10 gives better accuracy at a small CPU cost. Third, if the same instance holds both cache entries and queue or lock keys, an eviction can silently delete a BullMQ job, which is why queues should live on an instance with noeviction.

CONFIG SET maxmemory 6gb
CONFIG SET maxmemory-policy allkeys-lru
CONFIG SET maxmemory-samples 10

# LFU instead, for a stable hot set
CONFIG SET maxmemory-policy allkeys-lfu
CONFIG SET lfu-log-factor 10
CONFIG SET lfu-decay-time 1
OBJECT FREQ product:991      # only valid under an LFU policy

# The symptom of noeviction or of volatile-* with no TTLs
# (error) OOM command not allowed when used memory > 'maxmemory'.

INFO stats | grep evicted_keys
INFO memory | grep -E 'used_memory:|maxmemory:|maxmemory_policy'

Key Points

  • noeviction is the default and returns OOM errors on write
  • allkeys-lru for general caches, allkeys-lfu for stable hot sets
  • volatile-* evicts nothing if your keys have no TTL
  • LRU is sampled, not exact; maxmemory-samples controls accuracy
  • Never mix an evictable cache and a job queue on one instance
💡 Pro Tip: Leave headroom. Set maxmemory to roughly 60-70 percent of the machine's RAM so a BGSAVE fork and copy-on-write pages do not push the host into swap or an OOM kill.
Q20

How does Redis actually remove expired keys, and why can a replica still return an expired key?

IntermediateExpiry

Answer

Redis uses two mechanisms. Lazy expiration happens on access: when a command touches a key, Redis checks the expiry and, if it has passed, deletes the key and behaves as if it were missing. Active expiration is a background cycle that runs roughly ten times per second, samples twenty keys from the set of keys with a TTL, deletes the expired ones and repeats the sample immediately if more than a quarter were expired, with a CPU budget so the cycle cannot monopolise the thread.

The consequence that surprises people is that a key which nobody reads can stay in memory well past its TTL, so used_memory does not fall the instant a million keys expire, and DBSIZE counts keys that are logically gone. If you need memory back promptly for a large batch, delete explicitly with UNLINK rather than trusting expiry timing. On replicas the rule is different and it is a favourite interview question: replicas never expire keys on their own.

Only the primary decides, and when it expires a key it propagates an explicit DEL (represented as UNLINK when lazy freeing is on) to the replicas. A replica that receives a read for a logically expired key returns a miss to the client rather than the stale value, but the key itself remains in the replica's memory until the primary's DEL arrives. This design keeps replicas consistent with the primary, and it is why replication lag can leave a replica holding keys the primary already dropped. The expired keyspace notification also fires at deletion time, not at TTL time, so notification-driven logic is approximate, not punctual.

CONFIG SET notify-keyspace-events Ex
SUBSCRIBE '__keyevent@0__:expired'
# fires when the key is actually deleted (lazy or active cycle),
# which can be noticeably later than the TTL deadline

INFO stats | grep -E 'expired_keys|expired_stale_perc'
INFO keyspace   # db0:keys=... ,expires=... ,avg_ttl=...

# Reclaim a big batch immediately instead of waiting for the cycle
redis-cli --scan --pattern 'tmp:import:*' | xargs -L 200 redis-cli UNLINK

# Tuning knob for the active cycle effort (default 1, up to 10)
CONFIG SET hz 10
CONFIG SET active-expire-effort 1

Key Points

  • Lazy expiry on access plus a sampled active cycle about 10 times a second
  • Memory is not reclaimed at the exact TTL moment
  • Replicas never expire keys themselves, they wait for the primary DEL
  • Reads on a logically expired key at a replica return a miss
  • expired keyspace events fire at deletion, so they are approximate
Q21

Compare RDB and AOF persistence. What does appendfsync everysec actually guarantee?

IntermediatePersistence

Answer

RDB is a point-in-time binary snapshot. On a save point or a BGSAVE, Redis forks a child process that writes the dataset to dump.rdb while the parent keeps serving traffic. It gives you a compact file, fast restarts and easy backups, but you lose everything written since the last snapshot, which with the default save rules can be several minutes.

AOF logs every write command to an append-only file that is replayed at startup. Durability is controlled by appendfsync: always fsyncs on every write (safest, slowest, and it turns each write into a disk sync), everysec fsyncs once per second, and no leaves it to the operating system. The honest guarantee of everysec is up to one second of writes lost on a hard crash, and slightly more if the disk stalls, because Redis will not block the main thread waiting for a slow fsync.

AOF files grow, so Redis rewrites them in the background with BGREWRITEAOF; since Redis 7.0 the AOF is multi-part, living in an appendonlydir with a base file, incremental files and a manifest, which made rewrites cheaper and safer. Most production setups run both: RDB for fast restore and off-box backups, AOF for point-of-failure recovery, with aof-use-rdb-preamble making the AOF base an RDB image for faster loading. The failure mode to name in an interview is the fork. BGSAVE and BGREWRITEAOF fork the process, and copy-on-write means a write-heavy workload can nearly double resident memory during the child's life, so an instance sized at 90 percent of RAM gets OOM-killed exactly when it tries to save.

# redis.conf
save 900 1
save 300 10
save 60 10000

appendonly yes
appendfsync everysec        # up to ~1s of writes at risk
appenddirname "appendonlydir"   # multi-part AOF, 7.0+
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# Watch the fork cost, this is the latency spike people cannot explain
redis-cli INFO stats | grep latest_fork_usec
redis-cli INFO persistence | grep -E 'rdb_bgsave_in_progress|aof_rewrite_in_progress|rdb_last_bgsave_status'

# Host settings that make forks safe
# sysctl vm.overcommit_memory=1 ; transparent huge pages disabled
💡 Pro Tip: If the interviewer asks how much data you can lose, the answer for a default cache setup is minutes with RDB alone and about a second with AOF everysec, plus whatever the asynchronous replica lag adds during a failover.
Q22

How does Redis replication work, and can you lose acknowledged writes during a failover?

IntermediateReplication

Answer

Replication is asynchronous. A replica connects with REPLICAOF (formerly SLAVEOF), performs a handshake and then either a full synchronisation, where the primary produces an RDB image (on disk or streamed directly with repl-diskless-sync) and the replica loads it, or a partial resynchronisation via PSYNC, where the replica presents its replication ID and offset and the primary replays only the missing bytes from the in-memory replication backlog. The backlog is a circular buffer sized by repl-backlog-size (1 MB by default, which is far too small for busy instances), and if a disconnected replica falls further behind than the backlog holds, it is forced into a full sync, which costs a fork, CPU and network on the primary at exactly the wrong time.

Sizing the backlog to cover several minutes of write throughput is one of the cheapest reliability wins available. And yes, you can absolutely lose acknowledged writes. The primary replies to the client before the write reaches any replica, so if it dies in that window and a replica is promoted, those writes are gone.

WAIT numreplicas timeout blocks until the given number of replicas have acknowledged the offset, which narrows the window but is not a consensus protocol and does not make Redis linearizable. WAITAOF (7.0) does the same for fsync on local and replica AOFs. Partial protection also comes from min-replicas-to-write and min-replicas-max-lag, which make the primary refuse writes when too few replicas are keeping up, converting silent data loss into a visible error. If you truly cannot lose a write, put it in Postgres or MemoryDB, not in Redis.

REPLICAOF 10.0.1.9 6379
INFO replication
# role:slave  master_link_status:up  slave_read_only:1
# master_repl_offset:882314  slave_repl_offset:882290   <- lag in bytes

# Sizing that avoids expensive full resyncs
CONFIG SET repl-backlog-size 256mb
CONFIG SET repl-backlog-ttl 3600
CONFIG SET repl-diskless-sync yes

# Refuse writes when replicas are not keeping up
CONFIG SET min-replicas-to-write 1
CONFIG SET min-replicas-max-lag 10

# Narrow (not close) the data-loss window for a critical write
SET ledger:txn:9912 committed
WAIT 1 200      # replies with how many replicas acked within 200ms

Key Points

  • Asynchronous: the client is acked before replicas see the write
  • PSYNC partial resync depends on repl-backlog-size; 1 MB default is too small
  • WAIT and WAITAOF narrow the loss window but are not consensus
  • min-replicas-to-write turns silent loss into a visible write error
  • Replicas are read-only by default via replica-read-only
Q23

How does Redis Sentinel decide to fail over, and what does the quorum setting really control?

IntermediateHigh Availability

Answer

Sentinel is a separate process that monitors primaries and replicas, coordinates automatic failover and acts as a discovery service for clients. Each Sentinel pings the primary; if it does not get a valid reply within down-after-milliseconds, it marks the primary subjectively down (SDOWN). Sentinels then ask each other, and once at least quorum Sentinels agree, the primary is marked objectively down (ODOWN).

Here is the distinction candidates miss: quorum only controls the threshold for declaring failure. To actually run the failover, a Sentinel must be elected leader by a majority of all Sentinels, which is a separate and stricter condition. So with three Sentinels and quorum 2 you need two to agree on the failure and two (a majority of three) to elect a leader.

Setting quorum to 1 does not let a single Sentinel fail over when the others are unreachable, because the majority requirement still applies. That is also why you deploy an odd number of Sentinels, at least three, spread across availability zones: two Sentinels in one AZ cannot form a majority when that AZ is lost. Once elected, the leader picks the best replica by replica-priority, replication offset and run ID, sends REPLICAOF NO ONE, repoints the other replicas and rewrites its config.

Clients must ask Sentinel for the current primary address rather than hardcoding it (ioredis and Lettuce both support this natively) and must handle the reconnect. Failover takes seconds, not milliseconds, and writes in flight during it are lost because replication is asynchronous.

# sentinel.conf (run 3 or 5, spread across AZs)
sentinel monitor mymaster 10.0.1.9 6379 2
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1
sentinel auth-pass mymaster <password>

redis-cli -p 26379 SENTINEL master mymaster
redis-cli -p 26379 SENTINEL replicas mymaster
redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster
redis-cli -p 26379 SENTINEL failover mymaster   # manual, for drills
💡 Pro Tip: Rehearse a failover in staging with SENTINEL failover and watch what your application does. Most Sentinel outages are client-side, an app that cached the old primary IP or never handled the READONLY You can't write against a read only replica error.
Q24

Explain Redis Cluster hash slots. What causes a CROSSSLOT error and how do hash tags fix it?

IntermediateCluster

Answer

Redis Cluster shards the keyspace into 16384 hash slots. The slot for a key is CRC16 of the key modulo 16384, and each primary node owns a contiguous or scattered subset of slots. A client that connects to the wrong node for a key gets a MOVED redirect naming the correct node, and smart clients cache the slot map so subsequent requests go direct; during a slot migration you instead get an ASK redirect, which is a one-shot instruction to try the target node with an ASKING prefix, and it must not update the cached map.

Multi-key commands are the constraint that shapes your data model: MGET, SUNIONSTORE, transactions and Lua scripts can only touch keys that live in the same slot, otherwise you get CROSSSLOT Keys in request don't hash to the same slot. Hash tags are the escape hatch. If a key contains braces, only the substring between the first opening and the next closing brace is hashed, so user:{1042}:cart and user:{1042}:profile land in the same slot and can be read together or scripted atomically.

Overuse creates the opposite problem: tagging everything with a tenant ID puts one huge tenant entirely on one node and you get a hot shard that you cannot rebalance away. Other cluster behaviours to know: SELECT is rejected because only database 0 exists, plain PUBLISH is broadcast to all nodes so sharded pub/sub exists for scale, and cluster-require-full-coverage defaults to yes, meaning the whole cluster stops serving writes if any slot is uncovered, which is a deliberate consistency choice you may want to flip for a pure cache.

CLUSTER KEYSLOT user:1042          # 12539
CLUSTER KEYSLOT 'user:{1042}:cart' # same slot as user:{1042}:profile
CLUSTER SHARDS
CLUSTER COUNTKEYSINSLOT 12539

MGET user:1042:cart user:1043:cart
# (error) CROSSSLOT Keys in request don't hash to the same slot

MGET 'user:{1042}:cart' 'user:{1042}:profile'   # fine, same slot

GET user:1042
# (error) MOVED 12539 10.0.1.7:6379   <- client should update its slot map
# (error) ASK 12539 10.0.1.8:6379     <- one-shot during migration only

CONFIG GET cluster-require-full-coverage

Key Points

  • 16384 slots, slot = CRC16(key) mod 16384
  • MOVED updates the client slot map; ASK is single-request only
  • Multi-key operations require all keys in one slot
  • Hash tags {...} co-locate related keys; overuse creates hot shards
  • cluster-require-full-coverage yes halts the cluster on an uncovered slot
Q25

How do MULTI, EXEC and WATCH work, and why does Redis not roll back on an error inside a transaction?

IntermediateTransactions

Answer

MULTI starts a transaction, subsequent commands are queued rather than executed and each returns QUEUED, and EXEC runs the whole queue as a single isolated unit with no other client interleaving. DISCARD throws the queue away. There are two different error classes and the distinction is the whole question.

If a command fails at queue time, for example a wrong number of arguments or an unknown command, Redis flags the transaction and EXEC aborts with EXECABORT Transaction discarded because of previous errors, so nothing runs. If a command fails at run time, for example calling INCR on a key holding a list, the other commands still execute and EXEC returns an array where that one entry is an error. There is no rollback.

The official rationale is that runtime type errors are programming bugs that would be caught in development, and that supporting rollback would add complexity and cost to a system whose entire value proposition is speed. In an interview, say plainly that Redis transactions give atomicity in the sense of no interleaving and all-or-nothing dispatch, but not the rollback semantics of a relational database. WATCH adds optimistic concurrency: WATCH key marks a key, and if any other client modifies it before your EXEC, the EXEC returns nil and you retry the whole read-compute-write cycle.

That gives you compare-and-swap without a lock, but it requires a retry loop and it does not work across slots in cluster mode. In practice, for anything beyond two or three commands, a Lua script is simpler, atomic by construction, and needs no retry loop.

// ioredis: optimistic check-and-set with WATCH
async function reserveSeat(seatKey) {
  for (let attempt = 0; attempt < 5; attempt++) {
    await redis.watch(seatKey);
    const taken = await redis.get(seatKey);
    if (taken) { await redis.unwatch(); return false; }

    const res = await redis
      .multi()
      .set(seatKey, 'held', 'EX', 120)
      .incr('seats:held:count')
      .exec();

    if (res !== null) return true;   // null means WATCH was invalidated
  }
  throw new Error('contention: could not reserve seat');
}

// Runtime error does NOT roll back the rest
// MULTI / SET a 1 / LPUSH a x / INCR b / EXEC
// -> [OK, (error) WRONGTYPE..., (integer) 1]

Key Points

  • Commands are queued and EXEC runs them without interleaving
  • Queue-time errors abort with EXECABORT; runtime errors do not
  • No rollback, by design
  • WATCH gives optimistic CAS, EXEC returns nil when invalidated
  • Prefer Lua once the logic needs branching
Q26

When do you reach for a Lua script, and how are Redis Functions different from EVAL?

IntermediateScripting

Answer

A Lua script runs on the server as a single atomic unit, so it is the tool for read-then-decide-then-write logic that cannot be expressed by one command: check a balance and debit it, compare a lock token before releasing, implement a token bucket, cap a sorted set after inserting. EVAL sends the script body every time; SCRIPT LOAD returns a SHA1 and EVALSHA runs it by hash, which is what clients do internally, falling back to EVAL when the server replies NOSCRIPT (typically after a restart or SCRIPT FLUSH). Every key the script touches must be declared in KEYS so that cluster clients can route the call and verify all keys share a slot; reading a key you constructed inside the script from ARGV works on a standalone server and breaks the moment you move to cluster.

Scripts must be deterministic in the sense that they should not depend on unseeded randomness or wall-clock decisions that differ per node; modern Redis replicates scripts by their effects rather than by shipping the script, which removes most of that hazard, but the discipline is still expected. The blocking caveat matters most: while a script runs, the server runs nothing else, so a loop over a million elements is an outage. Keep scripts to tens of operations, and remember busy-reply-threshold (formerly lua-time-limit) only makes the server start replying BUSY, it does not stop your script; you then need SCRIPT KILL, or SHUTDOWN NOSAVE if the script already wrote. Redis Functions, added in 7.0, are the productionised version: you FUNCTION LOAD a named library that registers functions with redis.register_function, call them with FCALL or FCALL_RO, and the library is persisted in RDB and propagated to replicas, so it survives restarts instead of living only in a volatile script cache.

-- release_lock.lua: only the owner may release
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0

# SHA-based invocation (1 = number of KEYS)
redis-cli SCRIPT LOAD "$(cat release_lock.lua)"
EVALSHA a1b2c3... 1 lock:order:88123 8f3c1a-owner-token

# Redis Functions (7.0+): persisted library, not a volatile cache entry
#!lua name=ratelib
redis.register_function('take_token', function(keys, args)
  local left = tonumber(redis.call('GET', keys[1]) or args[1])
  if left <= 0 then return 0 end
  redis.call('DECR', keys[1])
  return 1
end)

FUNCTION LOAD REPLACE "$(cat ratelib.lua)"
FCALL take_token 1 bucket:1042 100
FUNCTION LIST
FUNCTION STATS
💡 Pro Tip: Never build key names inside a script. Pass every key through KEYS or your script will fail with a cross-slot error the day the service moves to cluster mode.
Q27

How do you implement a correct distributed lock in Redis, and what is the argument against Redlock?

IntermediateDistributed Locks

Answer

The single-instance recipe is SET lock:resource <random-token> NX PX 30000. NX makes acquisition atomic, PX guarantees the lock cannot outlive a crashed holder, and the random token identifies the owner. Release must be a Lua script that compares the token and deletes only on a match, because a plain DEL can delete a lock that already expired and was re-acquired by someone else, which is the exact bug that turns a lock into a source of corruption.

If the critical section can outrun the TTL you need a watchdog that periodically extends it with another compare-and-extend script, which is what Redisson and node-redlock do for you. Redlock is the multi-instance algorithm: acquire the same lock on a majority of N independent primaries within a short window, and consider it held only if you got the majority and enough TTL remains. Martin Kleppmann's critique is standard interview material.

Because Redis has no consensus log, a process pause (a long GC, a container throttle, a VM migration) can let a lock expire while the holder still believes it owns the resource, and clock jumps on individual nodes break the timing assumptions the algorithm relies on. His remedy is fencing tokens: a monotonically increasing number handed out with the lock, which the downstream storage rejects if it is lower than the last one it saw. Salvatore Sanfilippo's counter-argument is that the same pause breaks any lease-based lock, including ZooKeeper's. The practical position to state in an interview: Redis locks are fine for efficiency (avoid doing duplicate work) and not sufficient for correctness (never let two writers corrupt state), and if correctness is at stake you want a database uniqueness constraint, a conditional write, or fencing.

// Acquire
const token = crypto.randomUUID();
const ok = await redis.set('lock:invoice:88123', token, 'PX', 30000, 'NX');
if (!ok) return { acquired: false };

// Release: compare-and-delete, never a bare DEL
const RELEASE = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
end
return 0`;
await redis.eval(RELEASE, 1, 'lock:invoice:88123', token);

// Extend (watchdog) while the job is still running
const EXTEND = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('PEXPIRE', KEYS[1], ARGV[2])
end
return 0`;
await redis.eval(EXTEND, 1, 'lock:invoice:88123', token, 30000);

Key Points

  • SET key token NX PX ttl for atomic acquire with a safety expiry
  • Release through a compare-and-delete Lua script, never plain DEL
  • A watchdog extends the TTL for long critical sections
  • Redlock does not survive process pauses or clock skew
  • Use fencing tokens or a database constraint when correctness matters
Q28

How do Redis Streams consumer groups work, and how do you recover messages from a consumer that died mid-processing?

IntermediateStreams

Answer

A stream is an append-only log. XADD appends an entry with an ID of milliseconds-sequence, and XGROUP CREATE stream group $ MKSTREAM registers a consumer group starting from now (pass 0 instead of $ to consume history). Each consumer in the group calls XREADGROUP GROUP billing worker-3 COUNT 10 BLOCK 5000 STREAMS stream >, where the > means give me entries no one in this group has seen.

The moment an entry is handed out, Redis records it in the group's Pending Entries List with the owning consumer name, a delivery timestamp and a delivery counter. XACK removes it from the PEL. That PEL is the whole reliability story: if your worker crashes after reading but before acking, the entry is still owned by that dead consumer name and nobody else will get it from >.

Recovery has two halves. On restart, the same consumer reads with an explicit ID instead of >, typically XREADGROUP ... STREAMS stream 0, which replays only its own unacked entries.

For a worker that never comes back, XPENDING stream group IDLE 60000 - + 10 lists entries idle longer than a minute, and XAUTOCLAIM (Redis 6.2) claims them for a live consumer in one cursor-based call, replacing the older XPENDING plus XCLAIM loop. The delivery counter in XPENDING is how you build a dead-letter path: after the fourth delivery, XACK the entry and XADD it to a failures stream instead of looping forever. Two production notes: streams grow without bound unless you XADD with MAXLEN ~ 100000 or MINID, where the tilde trims whole macro nodes and is dramatically cheaper than exact trimming, and consumer names must be stable per worker (not a fresh UUID per pod restart) or you accumulate orphan PELs nobody scans.

XADD orders:events '*' order_id 88123 status PLACED
XGROUP CREATE orders:events billing $ MKSTREAM

# Fresh entries for this group
XREADGROUP GROUP billing worker-3 COUNT 10 BLOCK 5000 STREAMS orders:events >
XACK orders:events billing 1786579200123-0

# After a restart: replay only this consumer's own unacked entries
XREADGROUP GROUP billing worker-3 COUNT 10 STREAMS orders:events 0

# A worker died: find stuck entries and steal them
XPENDING orders:events billing IDLE 60000 - + 10
XAUTOCLAIM orders:events billing worker-7 60000 0 COUNT 10

# Bound the stream; ~ trims whole nodes and is much cheaper
XADD orders:events MAXLEN '~' 100000 '*' order_id 88124 status PAID

# Consumer lag, exposed properly since 7.0
XINFO GROUPS orders:events   # entries-read, entries-pending, lag
XINFO CONSUMERS orders:events billing

Key Points

  • The Pending Entries List is what makes delivery recoverable
  • > gives new entries; an explicit ID replays that consumer's own PEL
  • XAUTOCLAIM replaces the XPENDING plus XCLAIM loop since 6.2
  • Delivery count in XPENDING drives your dead-letter threshold
  • MAXLEN ~ trims approximately and cheaply; exact trimming is O(N)
💡 Pro Tip: Alert on the lag field from XINFO GROUPS, not on XLEN. A stream that is trimmed on write has a flat length while consumers fall hours behind.
Q29

Implement API rate limiting in Redis. Compare a fixed-window counter, a sorted-set sliding window and a Lua token bucket.

IntermediateRate Limiting

Answer

The fixed window is the cheapest: INCR a key whose name embeds the time bucket, arm EXPIRE only when the reply is 1, and reject above the limit. One key per caller per window, O(1), trivially correct under concurrency. Its flaw is the boundary burst: a caller can spend the full quota in the last second of one window and the full quota in the first second of the next, so the real worst case is twice the limit inside a two-second span.

Interviewers ask about this specifically. The sliding-window log fixes it exactly: keep a sorted set per caller with score equal to the request timestamp, ZREMRANGEBYSCORE away anything older than the window, ZCARD to count what remains, and ZADD the new request. It is precise but stores one member per request, so a 1000-per-minute limit costs a thousand members per active user, and the three commands must run inside one Lua script or a MULTI or you race.

The token bucket is what most teams ship: a hash holding a token count and a last-refill timestamp, refilled lazily in a Lua script based on elapsed time. One key, constant memory, natural burst allowance up to the bucket capacity, and a steady drain rate. Because it is a read-decide-write cycle it must be a script.

Pass the current time in ARGV rather than depending on the caller's clock spread, or use redis.call('TIME') for a single server clock. Practical details worth volunteering: return the retry delay from the script so you can set a Retry-After header, put rate-limit keys on an instance with maxmemory-policy noeviction (an evicted counter silently resets the limit), and in cluster mode keep all keys for one decision in the same slot with a hash tag.

-- token_bucket.lua  KEYS[1]=bucket  ARGV=capacity, refill_per_sec, now_ms, cost
local cap  = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now  = tonumber(ARGV[3])
local cost = 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)
if tokens < cost then
  redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
  redis.call('PEXPIRE', KEYS[1], 120000)
  return {0, math.floor(((cost - tokens) / rate) * 1000)}  -- retry_after_ms
end
redis.call('HSET', KEYS[1], 'tokens', tokens - cost, 'ts', now)
redis.call('PEXPIRE', KEYS[1], 120000)
return {1, 0}

# Fixed window: O(1) but allows a 2x burst across the boundary
INCR rl:1042:2026081204
EXPIRE rl:1042:2026081204 120        # only when INCR replied 1

# Sliding window log: exact, O(requests) memory, wrap all three in Lua
ZREMRANGEBYSCORE rl:z:1042 0 1786579140000
ZCARD rl:z:1042
ZADD rl:z:1042 1786579200000 req-8f3c1a

Key Points

  • Fixed window is cheapest but permits 2x the limit at the boundary
  • Sliding-window log is exact and pays one sorted-set member per request
  • Token bucket gives bursts plus a steady rate from one hash key
  • Any read-decide-write limiter must be a Lua script to be atomic
  • Limiter keys belong on a noeviction instance, never a cache instance
Q30

Compare cache-aside, write-through and write-behind, and explain the correct ordering of the database write and the cache invalidation.

IntermediateCaching Patterns

Answer

Cache-aside (lazy loading) is what almost everyone runs: the application reads Redis, and on a miss it reads the database, writes the value back with SET key value EX ttl, and returns it. Only requested data is cached, a Redis outage degrades to slow rather than broken, and the code is easy to reason about. The costs are a cold first request per key and a window of staleness after a write.

Write-through puts the cache in the write path: every update writes both the database and Redis, so the cache is always warm and reads are consistent right after a write, but write latency now includes Redis, and you cache data nobody ever reads. Write-behind acknowledges the write to Redis and flushes to the database asynchronously. It gives the fastest writes and it is the pattern to be careful with in an interview: Redis replication is asynchronous and it is not a durable log, so a failover between the acknowledgement and the flush loses the write.

Use it only for data you can rebuild, like counters or view tallies. Ordering is the part interviewers actually probe. On an update, write the database first and then DEL the cache key, not SET it.

A SET writes a value you computed before the transaction committed, so under concurrency you can pin a stale value permanently; a DEL just means the next reader repopulates from the committed row. Even delete-after-write has a race: a reader that missed just before your commit can SET the old row after your DEL. The mitigations are a TTL on every key as a backstop (non-negotiable), a delayed second delete a few hundred milliseconds later, or driving invalidation from the database change stream (binlog, Debezium, logical decoding) so it provably happens after commit.

// Cache-aside read with TTL jitter so keys do not all expire together
async function getProduct(id) {
  const key = `product:${id}`;
  const hit = await redis.get(key);
  if (hit) return JSON.parse(hit);

  const row = await db.product.findUnique({ where: { id } });
  if (!row) return null;
  const ttl = 300 + Math.floor(Math.random() * 60);
  await redis.set(key, JSON.stringify(row), 'EX', ttl);
  return row;
}

// Write path: commit first, THEN invalidate. Delete, do not overwrite.
async function updatePrice(id, price) {
  await db.$transaction(async (tx) => {
    await tx.product.update({ where: { id }, data: { price } });
  });
  await redis.unlink(`product:${id}`);
  setTimeout(() => redis.unlink(`product:${id}`), 500); // delayed double delete
}

Key Points

  • Cache-aside degrades to slow, which is why it is the default
  • Write-through keeps reads fresh at the cost of write latency
  • Write-behind can lose acknowledged writes on a failover
  • Delete the key after committing; never SET a precomputed value
  • A TTL on every key is the backstop for every invalidation race
💡 Pro Tip: Add random jitter to every TTL. Ten thousand keys written during one deploy will otherwise expire in the same second and hand the whole miss storm to your database.
Q31

How do keyspace notifications and CLIENT TRACKING client-side caching work, and when is each one unsafe to rely on?

IntermediateKeyspace Events

Answer

Keyspace notifications are off by default because they cost CPU. You enable them with notify-keyspace-events and a flag string: K publishes __keyspace@0__:<key> channels carrying the event name, E publishes __keyevent@0__:<event> channels carrying the key name, and the class flags select what fires (g generic, $ string, l list, s set, h hash, z sorted set, x expired, e evicted, t stream, m key-miss, n new key, A for everything except m and n). The trap is that delivery rides on pub/sub, so it inherits every pub/sub weakness: at-most-once, no persistence, no replay, and a subscriber that is restarting or too slow simply loses events, possibly after being disconnected by the pubsub client-output-buffer-limit.

Never build a workflow whose correctness depends on receiving an expired event. Also remember that expired fires when the key is actually deleted by the lazy or active cycle, which can be well after the TTL, and that in cluster mode events are published on the node that owns the key, so a listener must connect to every node. Client-side caching is the other half of this topic and it is much stronger.

Over RESP3, CLIENT TRACKING ON makes the server remember which keys a connection read and push an invalidation message when any of them change, so your process can keep a local in-memory copy and skip the network entirely for hot reads. Default mode keeps a server-side invalidation table per key; BCAST mode with PREFIX cache: broadcasts invalidations for a prefix without per-key bookkeeping; OPTIN and OPTOUT with CLIENT CACHING yes let you choose per command. The unsafe part is reconnection: an invalidation delivered while your connection was down is lost, so libraries flush the entire local cache on reconnect and still keep a short local TTL as a backstop.

# Keyspace and keyevent notifications for expiry and eviction
CONFIG SET notify-keyspace-events KEA
SUBSCRIBE '__keyevent@0__:expired'
PSUBSCRIBE '__keyspace@0__:cart:*'

# Just expired plus evicted, the two people actually want
CONFIG SET notify-keyspace-events Exe

# RESP3 client-side caching: server pushes invalidations to this connection
HELLO 3
CLIENT TRACKING ON BCAST PREFIX cache: NOLOOP
GET cache:product:991
# -> push: invalidate ['cache:product:991'] when anyone writes it

CLIENT TRACKINGINFO
INFO clients | grep tracking_clients

// node-redis v5 keeps the local map for you
// const client = createClient({ RESP: 3, clientSideCache: { ttl: 5000, maxEntries: 5000 } });

Key Points

  • notify-keyspace-events is off by default; K, E and class flags select events
  • Events are pub/sub, so they are at-most-once and droppable
  • expired fires at deletion time, not at the TTL deadline
  • Cluster events are node-local; subscribe on every node
  • CLIENT TRACKING plus RESP3 gives a local L1 cache with server invalidation
Q32

INFO says used_memory is 9 GB but your keys should add up to 4 GB. How do you find where the rest went?

IntermediateMemory Management

Answer

Start by separating the accounting. used_memory is what the allocator handed out, used_memory_rss is what the operating system sees, and mem_fragmentation_ratio is rss divided by used_memory. A ratio around 1.0 to 1.5 is normal jemalloc behaviour; well above that is fragmentation you can fight with activedefrag; below 1.0 means part of the process is swapped out, which is a much worse problem. Then break used_memory down with MEMORY STATS, because the overheads people forget are large: mem_clients_slaves holds replica output buffers and can balloon during a full resync, mem_replication_backlog is exactly what you set repl-backlog-size to, mem_clients_normal grows with a MONITOR session or a client running a gigantic pipeline, and aof_buffer plus the rewrite buffer live here too.

MEMORY DOCTOR prints a plain-language verdict, and redis-cli --memkeys and --bigkeys find the individual offenders. The other half is encoding. OBJECT ENCODING tells you whether a collection is still in its compact form: listpack for small hashes, sorted sets and lists, intset for all-integer sets, and the expensive hashtable or skiplist form once it crosses hash-max-listpack-entries (128), hash-max-listpack-value (64 bytes), zset-max-listpack-entries (128), set-max-intset-entries (512) or list-max-listpack-size.

A million hashes that each sit just past 128 fields is a textbook overnight memory jump with no change in key count. Finally, per-key overhead is real: every key carries roughly 50 to 100 bytes of dict entry, robj and expiry bookkeeping, so at a hundred million keys your key names and structure choices matter more than the values.

redis-cli INFO memory | grep -E 'used_memory:|used_memory_rss:|used_memory_peak_human|mem_fragmentation_ratio|maxmemory:'
redis-cli MEMORY STATS | head -40
redis-cli MEMORY DOCTOR

# Which key, and how big exactly
redis-cli MEMORY USAGE feed:global SAMPLES 0
redis-cli --memkeys
redis-cli --bigkeys

# Is the structure still in its compact encoding?
OBJECT ENCODING cart:1042      # listpack  -> good
OBJECT ENCODING cart:9999      # hashtable -> crossed a threshold
CONFIG GET hash-max-listpack-*
CONFIG GET zset-max-listpack-entries

# Fragmentation above ~1.5 with jemalloc
CONFIG SET activedefrag yes
CONFIG SET active-defrag-ignore-bytes 200mb
CONFIG SET active-defrag-threshold-lower 15

Key Points

  • used_memory versus used_memory_rss separates data from fragmentation
  • MEMORY STATS exposes replica buffers, backlog and client buffers
  • OBJECT ENCODING shows whether listpack or intset compaction still applies
  • Crossing a listpack threshold multiplies per-element overhead
  • Fragmentation ratio below 1.0 means swap, which is an emergency
Q33

Redis p99 jumped from 0.8 ms to 90 ms at unchanged ops per second. Walk through the diagnosis.

IntermediateLatency

Answer

Work outward from the server. First SLOWLOG GET 20, remembering that slowlog-log-slower-than (10000 microseconds by default) measures execution time only: it excludes network time and, importantly, excludes time a command spent queued behind someone else's slow command. So an empty slowlog does not clear Redis, but a slowlog full of HGETALL or ZRANGE 0 -1 on one key immediately names your culprit.

Next enable the latency monitor with CONFIG SET latency-monitor-threshold 100 and read LATENCY LATEST, LATENCY HISTORY and LATENCY DOCTOR; the event names are the diagnosis, since command, fork, expire-cycle, eviction-del and aof-fsync-always each point somewhere different. INFO commandstats and the 7.0 LATENCYSTATS section give per-command percentiles, which is how you prove which call regressed rather than arguing about it. The usual causes, in the order they actually occur: a single O(N) command against a key that grew (a big key, a KEYS, a Lua loop, an SMEMBERS on a set that was small last quarter); a fork pause during BGSAVE or BGREWRITEAOF, visible as latest_fork_usec, which gets dramatically worse with transparent huge pages enabled or a large RSS on a burstable instance; eviction churn once you touch maxmemory, visible as a rising evicted_keys; swap, which shows up as rss below used_memory; the single command thread pegged, visible in INFO cpu against one core; and a MONITOR someone left running. If none of that fits, the latency is not in Redis: compare redis-cli --latency run from the application host against --intrinsic-latency run on the Redis host, which separates network and noisy-neighbour effects from the server, and then look at your own process for garbage-collection pauses or an exhausted connection pool.

redis-cli SLOWLOG GET 20        # execution time only, not queueing or network
redis-cli CONFIG SET slowlog-log-slower-than 5000

redis-cli CONFIG SET latency-monitor-threshold 100
redis-cli LATENCY LATEST        # event, last, max, timestamp
redis-cli LATENCY HISTORY fork
redis-cli LATENCY DOCTOR

redis-cli INFO commandstats | sort -t= -k2 -rn | head
redis-cli INFO latencystats | head    # per-command percentiles, 7.0+
redis-cli INFO stats | grep -E 'latest_fork_usec|evicted_keys|expired_keys'
redis-cli INFO cpu

# Network versus server versus host
redis-cli -h prod-redis --latency-history -i 5   # run from the app host
redis-cli --intrinsic-latency 10                 # run on the Redis host
💡 Pro Tip: Check latest_fork_usec before blaming the network. A 300 ms fork on a 30 GB instance with transparent huge pages left on explains most mystery spikes that arrive exactly every few minutes.
Q34

How do Redis 6+ ACLs work, and what can an attacker actually do with an unauthenticated Redis reachable from the internet?

IntermediateSecurity

Answer

Before Redis 6 there was one shared password through requirepass and the crude rename-command hack. ACLs replaced both. ACL SETUSER defines a user with four independent rule families: authentication (>plaintext or #sha256hex, plus on and off), command rules (+get, -flushall, or whole categories with +@read and -@dangerous and -@admin), key patterns (~cache:* and, since 7.0, the read-only and write-only forms %R~ and %W~), and pub/sub channel patterns (&events:*).

ACL WHOAMI, ACL LIST, ACL GETUSER, ACL CAT and ACL GENPASS are the inspection commands, rules persist in an aclfile with ACL SAVE and ACL LOAD, and ACL LOG is the underrated one: it records every denied command with the reason, which is your intrusion and misconfiguration signal. The default user matters, because it is on with nopass unless requirepass is set, so an instance with no explicit configuration accepts everything. The attack on an exposed instance is not theoretical and it is a favourite interview question.

With admin commands available, an attacker runs CONFIG SET dir /home/redis/.ssh, CONFIG SET dbfilename authorized_keys, SET a key containing a public key and then BGSAVE, giving them SSH access; variants abuse MODULE LOAD or point REPLICAOF at an attacker-controlled primary to load a malicious module. That history is why Redis 7 ships enable-protected-configs, enable-debug-command and enable-module-command defaulting to no, why protected-mode blocks external clients when there is no password and no explicit bind, and why the correct posture is a private subnet or security group, TLS with tls-port and tls-auth-clients, per-service ACL users scoped to their own key prefix, and credentials pulled from a secrets manager rather than baked into a connection URL that ends up in your logs.

# Least-privilege user for a read cache service
ACL SETUSER svc-catalog on '>S3cret!' ~cache:catalog:* +@read +@string +ttl -@admin

# A worker that owns one queue prefix and nothing else
ACL SETUSER svc-worker on '#<sha256hex>' ~jobs:* &jobs:events +@read +@write +@stream -@dangerous

ACL GETUSER svc-catalog
ACL LOG 10          # denied commands: object, reason, username, age-seconds
ACL SAVE            # persist to aclfile

# Lock the default user down
ACL SETUSER default off

# redis.conf hardening
bind 10.0.1.9 -::1
protected-mode yes
enable-protected-configs no
enable-debug-command no
enable-module-command no
tls-port 6379
tls-auth-clients yes

Key Points

  • ACL rules cover commands, categories, key patterns and channels
  • The default user is nopass until you configure it
  • ACL LOG is your denied-command audit trail
  • CONFIG SET dir plus BGSAVE is the classic file-write RCE path
  • Redis 7 gates protected configs, DEBUG and MODULE behind explicit flags
Q35

How do you test code that depends on Redis without a slow or flaky suite?

IntermediateTesting

Answer

Split it into three layers and be honest about what each proves. Unit tests put the client behind a narrow interface and stub it, or use an in-memory fake such as ioredis-mock or fakeredis. Fakes are fast, but they drift from the server: Lua semantics, WAIT, blocking commands, cluster CROSSSLOT errors and exact expiry behaviour are the first things they get wrong, so never validate a lock script, a rate-limiter script or a BullMQ flow against a fake.

Integration tests run a real server. Testcontainers (@testcontainers/redis in Node) starts a throwaway container on a random port per suite, which is the cleanest option in CI because there is no shared state and no port collisions; a docker compose service works too if startup cost matters more than isolation. Isolation is where suites go flaky.

FLUSHDB in beforeEach is fine when tests run serially, but with parallel Jest or Vitest workers on a shared instance it wipes another worker's data mid-test, so give each worker a key prefix derived from its worker ID and clean up with SCAN plus UNLINK, or hand each worker its own container. Time is the other flakiness source: never sleep two seconds to prove a TTL expired. Assert on TTL or PTTL, or inject the clock so the code computes an absolute deadline you can set with EXPIREAT and then verify. Finally, test the failure path, because that is the code that runs during an incident and it is almost never exercised: stop the container or point the client at a closed port, and assert that your read falls through to the database and your write returns a sensible error rather than a 500.

import { RedisContainer } from '@testcontainers/redis';
import Redis from 'ioredis';

let container, redis, prefix;

beforeAll(async () => {
  container = await new RedisContainer('redis:8-alpine').start();
  redis = new Redis(container.getConnectionUrl());
  prefix = `t${process.env.VITEST_WORKER_ID ?? '0'}:`;
}, 60_000);

afterAll(async () => {
  await redis.quit();
  await container.stop();
});

afterEach(async () => {
  // Prefix cleanup, not FLUSHDB: parallel workers share this instance
  const stream = redis.scanStream({ match: `${prefix}*`, count: 200 });
  for await (const keys of stream) if (keys.length) await redis.unlink(keys);
});

it('arms a TTL instead of sleeping for it', async () => {
  await redis.set(`${prefix}session:abc`, '1', 'EX', 1800);
  expect(await redis.ttl(`${prefix}session:abc`)).toBeGreaterThan(1700);
});
💡 Pro Tip: Run your Lua scripts against a real container in CI. A script that works in a fake and fails with a cross-slot error in production is the single most common Redis testing gap.
Q36

How would you make a payment webhook idempotent with Redis, and where does that design leak?

IntermediateIdempotency

Answer

The primitive is SET idem:<event_id> in-progress NX EX 86400. If it replies OK you are the first handler and you process the event. If it replies nil, someone already claimed it, so you read the stored record and either return the cached response or reply 200 without reprocessing, which is what payment providers expect.

Store the serialized response, not a bare flag, so a retry receives the identical body: claim with a short TTL and an in-progress marker, overwrite with the result and a longer TTL on success, and delete the key on failure so a genuine retry can proceed instead of being permanently blocked by a poison record. The TTL must comfortably exceed the provider's retry window, and Razorpay, Stripe and PayU all retry for hours or days, so 24 hours is a floor, not a generous choice. Key the record on the provider's own event or payment ID, never on a hash of the payload, because providers resend the same logical event with different timestamps and signatures.

Now the leaks, which is what a senior interviewer is really asking for. Redis is not durable: an unlucky failover between your SET and the replica catching up loses the record and you double-process, which for a wallet credit is a real financial incident. So the authoritative deduplication has to be a unique constraint in the database (a unique index on provider_event_id, or an upsert), with Redis as a fast pre-filter that saves the database round trip on the ninety-nine percent of retries that are obvious duplicates. Second leak: an in-progress marker whose TTL is shorter than the actual work lets a retry start a second execution, so treat that marker as a lock with a watchdog extension, or make the terminal database write itself conditional.

const KEY = `idem:${event.id}`;

// 1. Claim. Short TTL while in flight.
const claimed = await redis.set(KEY, JSON.stringify({ state: 'in_progress' }), 'EX', 120, 'NX');

if (!claimed) {
  const prior = JSON.parse((await redis.get(KEY)) ?? '{}');
  if (prior.state === 'done') return reply.code(200).send(prior.response);
  return reply.code(409).send({ error: 'processing, retry shortly' });
}

try {
  // 2. The database unique index is the real dedupe, Redis is the fast path
  const result = await db.payment.create({
    data: { providerEventId: event.id, amount: event.amount },
  });
  await redis.set(KEY, JSON.stringify({ state: 'done', response: result }), 'EX', 172800);
  return reply.code(200).send(result);
} catch (err) {
  if (err.code !== 'P2002') await redis.unlink(KEY); // release on real failure
  throw err;
}

Key Points

  • SET NX with a TTL is the claim; store the response, not a flag
  • TTL must outlast the provider's full retry window
  • Key on the provider event ID, never a payload hash
  • A failover can lose the record, so the database unique index is authoritative
  • An in-progress TTL shorter than the work reopens the double-execution window
Q37

One key is taking a disproportionate share of traffic and one shard is at 95 percent CPU. How do you find the hot key and fix it?

AdvancedScaling

Answer

First confirm it is a hot key and not simply an overloaded shard. In cluster mode, compare INFO commandstats and instantaneous_ops_per_sec across nodes; a single node far above the others with an even slot distribution points at key skew rather than slot skew. redis-cli --hotkeys samples OBJECT FREQ across the keyspace and needs maxmemory-policy set to allkeys-lfu or volatile-lfu to return anything meaningful. If you cannot switch the policy on a live primary, sample instead: run MONITOR for two or three seconds on a replica and aggregate the key names, or use CLUSTER COUNTKEYSINSLOT with your slot map to find a slot carrying an unreasonable share.

Hot keys in India are usually predictable: the flash-sale product, the live match scoreboard, the one feature flag every request reads, a global counter. The fixes, in the order you should try them, are: put an L1 cache in the application process with a one to five second TTL, which removes most of the traffic instantly and is even safe against staleness if you pair it with CLIENT TRACKING invalidation; split the key into N replicas (product:991:0 through product:991:7) and have each client read a random shard, which multiplies read capacity but forces you to write all N on update; move reads to replicas with READONLY on a cluster connection, accepting replica lag; and for counters, shard the increment across N keys and sum on read, which converts one write-hot key into N cool ones. Big keys are the sibling problem: --bigkeys or --memkeys finds them, and the remedy is structural, shard a giant hash by field hash into hash:{id}:0..15, cap lists with LTRIM, and never call HGETALL or SMEMBERS on something unbounded.

# 1. Prove it: which node, and which key
redis-cli --cluster call 10.0.1.7:6379 INFO commandstats | head
redis-cli -h 10.0.1.7 --hotkeys        # needs an LFU maxmemory-policy
redis-cli -h 10.0.1.7-replica MONITOR | head -20000 \
  | awk '{print $4}' | sort | uniq -c | sort -rn | head

# 2. Read-replica fan-out for the hot key
CLUSTER KEYSLOT product:991
OBJECT FREQ product:991

# 3. Split a read-hot key across N copies
# writer
for (let i = 0; i < 8; i++) await redis.set(`product:991:${i}`, json, 'EX', 300);
# reader
const v = await redis.get(`product:991:${(Math.random() * 8) | 0}`);

# 4. Shard a write-hot counter, sum on read
INCR views:991:{shard3}
MGET views:991:{shard0} views:991:{shard1} views:991:{shard2}

Key Points

  • Uneven node ops with even slots means key skew, not slot skew
  • --hotkeys requires an LFU policy; MONITOR sampling on a replica is the fallback
  • An in-process L1 cache with a 1-5 second TTL kills most hot-key load
  • Split read-hot keys into N copies, shard write-hot counters
  • Big keys need structural fixes: sharded hashes and LTRIM-capped lists
💡 Pro Tip: Say out loud that a hot key is a data-model problem, not a capacity problem. Adding nodes to a cluster does nothing for a single key, because that key still lives in exactly one slot on one primary.
Q38

Walk through resharding a live Redis Cluster. What happens to requests for a slot while it is being moved?

AdvancedCluster

Answer

Slots move one at a time and no data is unavailable during the move, which is the point of the design. The operator side is usually redis-cli --cluster reshard or --cluster rebalance, but you should be able to describe the primitives underneath because that is what the question is testing. For each slot the coordinator sets CLUSTER SETSLOT <slot> IMPORTING <source-node-id> on the destination and CLUSTER SETSLOT <slot> MIGRATING <destination-node-id> on the source.

It then repeatedly calls CLUSTER GETKEYSINSLOT to fetch a batch of keys and MIGRATE to move each one, which serializes the key on the source, restores it on the destination and deletes the original, atomically from the client's perspective. While the slot is in this half-moved state, the source node answers normally for keys it still holds and replies ASK <slot> <destination> for keys that have already moved; the client then sends ASKING followed by the command to the destination. The critical detail is that ASK is a one-shot redirect and must not update the client's cached slot map, unlike MOVED, which must.

Once the last key is gone, CLUSTER SETSLOT <slot> NODE <destination-id> is issued on both nodes and then on the other primaries so the new ownership propagates, and clients start getting MOVED and refresh their maps. Things that go wrong in practice: multi-key operations and Lua scripts against a migrating slot can fail with TRYAGAIN when some keys have moved and some have not, so your client needs a retry; a single enormous key makes MIGRATE block both nodes for the duration, which is why you fix big keys before resharding, not during; and cluster-require-full-coverage yes means an aborted reshard that leaves a slot unassigned stops the entire cluster from serving. redis-cli --cluster fix and --cluster check are how you recover from that state.

redis-cli --cluster check 10.0.1.7:6379
redis-cli --cluster rebalance 10.0.1.7:6379 --cluster-use-empty-masters
redis-cli --cluster reshard 10.0.1.7:6379 \
  --cluster-from <src-id> --cluster-to <dst-id> --cluster-slots 512 --cluster-yes

# The primitives underneath, per slot
CLUSTER SETSLOT 12539 IMPORTING <src-node-id>     # on destination
CLUSTER SETSLOT 12539 MIGRATING <dst-node-id>     # on source
CLUSTER GETKEYSINSLOT 12539 100
MIGRATE 10.0.1.8 6379 '' 0 5000 KEYS user:1042 user:1043
CLUSTER SETSLOT 12539 NODE <dst-node-id>          # finalise on both, then peers

# What clients see mid-migration
# (error) ASK 12539 10.0.1.8:6379      one-shot, do NOT cache
# (error) MOVED 12539 10.0.1.8:6379    permanent, refresh the slot map
# (error) TRYAGAIN Multiple keys request during rehashing of slot

redis-cli --cluster fix 10.0.1.7:6379

Key Points

  • IMPORTING plus MIGRATING marks the slot, MIGRATE moves keys in batches
  • ASK is single-request; only MOVED updates the cached slot map
  • Multi-key commands on a migrating slot can return TRYAGAIN
  • A huge key makes MIGRATE block both nodes, so fix big keys first
  • cluster-require-full-coverage turns an aborted reshard into an outage
Q39

Distinguish cache stampede, cache penetration and cache avalanche, and give the Redis-side mitigation for each.

AdvancedCaching at Scale

Answer

These three failure modes all end with your database on fire, but the cause and the fix are different, and interviewers use the vocabulary to separate people who have run a cache from people who have read about one. A stampede (also called a dogpile or cache breakdown) is one hot key expiring while a thousand requests are in flight, so all of them miss simultaneously and all of them query the database for the same row. The fix is single-flight: the first misser takes a short lock with SET lock:rebuild:<key> token NX EX 10 and rebuilds while the others either wait briefly and re-read or serve the previous value.

The stronger version is a logical expiry, where you store the value with an embedded soft deadline and a much longer physical TTL, so on a soft miss you serve the slightly stale value immediately and rebuild in the background; nobody ever waits. Probabilistic early recomputation (the XFetch approach) is the same idea with a randomised trigger. Penetration is different: the requested item does not exist anywhere, so every request misses the cache and hits the database, and an attacker can generate infinite non-existent IDs.

Cache the negative result with a short TTL (SET product:0000 __nil__ EX 30) and put a Bloom filter in front for a bounded-memory membership check, which Redis 8 ships natively as BF.RESERVE, BF.ADD and BF.EXISTS with no separate module install. Avalanche is the mass-expiry case: a warm-up job or a deploy wrote a million keys with the same 300-second TTL, so five minutes later they all expire in the same second. Jitter every TTL, stagger warm-up writes, and treat evicted_keys above zero as the alarm that says your avalanche is really a memory problem.

// Logical expiry: physical TTL long, soft deadline inside the value
async function getWithSoftTtl(key, loader, softSec = 300) {
  const raw = await redis.get(key);
  if (raw) {
    const { value, softExpiry } = JSON.parse(raw);
    if (Date.now() < softExpiry) return value;

    // stale but usable: one caller rebuilds, everyone else keeps serving it
    const token = crypto.randomUUID();
    if (await redis.set(`lock:rebuild:${key}`, token, 'EX', 10, 'NX')) {
      queueMicrotask(() => rebuild(key, loader, softSec, token));
    }
    return value;
  }
  return rebuild(key, loader, softSec);
}

# Penetration: cache the miss, and keep a Bloom filter of real IDs
SET product:0000 '__nil__' EX 30
BF.RESERVE products:exists 0.001 10000000
BF.ADD products:exists 991
BF.EXISTS products:exists 4242     # 0 -> reject before touching Postgres

# Avalanche: never a constant TTL on a bulk write
# EX (300 + Math.floor(Math.random() * 120))

Key Points

  • Stampede: one hot key, many concurrent misses; fix with single-flight or logical expiry
  • Penetration: keys that never exist; fix with negative caching plus a Bloom filter
  • Avalanche: synchronised TTLs; fix with jitter and staggered warm-up
  • Bloom filter commands are built into Redis 8, no module install
  • evicted_keys above zero means the real problem is maxmemory, not TTLs
Q40

Redis executes commands on one thread. What does io-threads actually parallelise, and when do you scale up versus shard?

AdvancedInternals

Answer

Command execution has always been single-threaded and still is, which is deliberate: it removes locking from every data structure, makes each command atomic for free, and keeps latency predictable. What was never free is the socket work, reading bytes off the kernel, parsing RESP and writing replies back, which on a busy instance is a large share of the CPU time. The io-threads directive (default 1, meaning main thread only) hands that syscall and protocol work to a pool of threads while the actual command still runs on the main thread.

In Redis 6 the pool only wrote replies unless you also set io-threads-do-reads yes; Redis 8 reworked the implementation so a single io-threads setting covers both directions and the old toggle no longer does anything. Separately, Redis has always had background threads for lazy freeing (UNLINK, lazyfree-lazy-*), AOF fsync and closing file descriptors, which is why UNLINK does not block you. The practical guidance is to set io-threads to roughly the number of physical cores minus one, up to about eight, and only when INFO cpu shows the process saturating a core with small values and high throughput; if your workload is a few large values or heavy Lua, I/O threads buy you nothing because the bottleneck is on the main thread.

That is where the scale-up versus shard decision lives. Scale up (a bigger instance, more I/O threads, better network) when the limit is bytes moved or connections handled. Shard into a cluster when the limit is command execution on the single thread, when the dataset no longer fits comfortably in one machine's RAM with fork headroom, or when a BGSAVE fork on a 100 GB instance has become an unacceptable latency event. Also worth naming in 2026: Valkey pushed further with multi-threaded I/O and per-core efficiency work, and Dragonfly rebuilt the whole engine around a shared-nothing thread-per-core model, which is the standard follow-up question here.

# redis.conf
io-threads 6                 # roughly cores - 1, cap around 8
# io-threads-do-reads yes    # required in 6.x, a no-op in Redis 8

# Is the single command thread actually the bottleneck?
redis-cli INFO cpu
# used_cpu_sys / used_cpu_user climbing toward one full core = saturated
redis-cli INFO stats | grep -E 'instantaneous_ops_per_sec|total_net_input_bytes'
redis-cli INFO clients | grep connected_clients

# Background threads you already rely on
CONFIG SET lazyfree-lazy-eviction yes
CONFIG SET lazyfree-lazy-expire yes
CONFIG GET appendfsync            # everysec fsync happens off the main thread

# Shard when execution, not I/O, is the wall
redis-cli --cluster create 10.0.1.7:6379 10.0.1.8:6379 10.0.1.9:6379 \
  --cluster-replicas 1

Key Points

  • Commands still run on one thread; io-threads only parallelises socket I/O
  • io-threads-do-reads mattered in 6.x and is ignored in Redis 8
  • Lazy free, AOF fsync and fd close already run on background threads
  • Scale up for bytes and connections, shard for command-thread saturation
  • Fork cost on very large instances is itself a reason to shard
Q41

A team asks whether to stay on Redis or move to Valkey. What is the actual state of the fork and the licensing in 2026?

AdvancedEcosystem

Answer

In March 2024 Redis Ltd. moved the source from the BSD licence to a dual RSALv2 and SSPLv1 arrangement, which is source-available rather than open source and is aimed squarely at cloud providers reselling Redis as a managed service. The community response was Valkey, a fork of Redis 7.2.4 hosted by the Linux Foundation under BSD-3, with AWS, Google Cloud, Oracle and Ericsson contributing, and it was adopted quickly by Linux distributions that could no longer ship Redis under their open-source policies. Valkey has since shipped its own releases with multi-threaded I/O and significant per-key memory reductions, and AWS made Valkey a first-class engine on ElastiCache and MemoryDB priced below the Redis option, which is why a lot of Indian teams quietly switched their caches during 2025.

Redis Ltd. responded in May 2025 by adding AGPLv3 as a third licence option from Redis 8 and by folding the previously separate modules into the core distribution, so JSON, the Query Engine (formerly RediSearch), Time Series and the probabilistic types ship in the box, along with vector sets as a new native data type. For a working engineer the honest summary is that the command surface is still essentially identical, clients speak to either, and a cache migration is a configuration change rather than a rewrite. The differences that matter are: Valkey if you want BSD licensing, the AWS pricing and the distribution packages; Redis if you want the Query Engine, JSON, vector sets and active-active CRDT geo-replication without assembling them yourself. The answer that lands in an interview is not picking a winner, it is naming the compatibility line: anything using the bundled Redis 8 modules is not portable to Valkey today, and plain key-value, streams and Lua workloads are.

Key Points

  • March 2024: Redis left BSD for dual RSALv2 and SSPLv1
  • Valkey forked from 7.2.4 under the Linux Foundation, BSD-3 licensed
  • AWS prices ElastiCache and MemoryDB for Valkey below the Redis engine
  • Redis 8 added AGPLv3 and bundled JSON, Query Engine, Bloom and vector sets
  • Plain workloads are portable; anything using bundled modules is not
💡 Pro Tip: If you are asked which one you would pick, answer by workload. A pure cache has almost no switching cost and should follow the cheaper engine; a service built on the Query Engine or vector sets is committed to Redis.
Q42

How would you migrate a live 40 GB Redis workload to a new provider with no downtime?

AdvancedMigration

Answer

Decide first whether the data is rebuildable. If the instance is a pure cache, do not migrate it at all: stand up the new cluster empty, cut traffic over behind a feature flag with a small percentage ramp, and accept a temporary dip in hit ratio while it warms. That is a two-hour change instead of a two-week project, and saying so is usually the strongest answer.

When the data cannot be rebuilt (sessions, queues, streams, tokens, rate-limit state), you have three real options. The cleanest is native replication: point the new deployment at the old one with REPLICAOF, let it complete a full sync and stay in lockstep, then during a short write freeze issue REPLICAOF NO ONE on the new primary and flip the clients. This needs both ends to be reachable and to allow the command, which managed services often block, though AWS exposes it as ElastiCache online migration for exactly this reason.

The second option is a streaming migration tool, RIOT or redis-shake, which does a SCAN plus DUMP and RESTORE snapshot and then tails changes, and which is the practical choice when the source is a managed service that will not act as a primary. The third is application-level dual write: write to both instances for a period, backfill historical keys with a throttled SCAN plus DUMP and RESTORE REPLACE loop that preserves TTLs, shadow-read from the new instance and compare, then cut over reads and finally stop writing to the old one. Details that decide whether the cutover works: DUMP and RESTORE payloads are version-sensitive, so restore to an equal or newer server; RESTORE takes the remaining TTL in milliseconds and you must carry it forward with PTTL or your sessions all become immortal; a cluster-to-cluster move needs slot-aware batching; and blocked consumers on lists and streams need to be drained before cutover or they will sit on the old instance forever.

# Option A: native replication cutover (both ends reachable)
redis-cli -h new-primary REPLICAOF old-primary 6379
redis-cli -h new-primary INFO replication | grep -E 'master_link_status|master_sync_in_progress'
# freeze writes briefly, confirm offsets match, then:
redis-cli -h new-primary REPLICAOF NO ONE

# Option B: tool-driven snapshot plus change stream
riot replicate --mode live redis://old:6379 redis://new:6379 --key-pattern 'session:*'

# Option C: throttled backfill that preserves TTLs
for await (const keys of old.scanStream({ count: 200 })) {
  const pipe = neu.pipeline();
  for (const k of keys) {
    const [dump, pttl] = await Promise.all([old.dumpBuffer(k), old.pttl(k)]);
    if (dump) pipe.restore(k, pttl > 0 ? pttl : 0, dump, 'REPLACE');
  }
  await pipe.exec();
  await sleep(20); // throttle so the source keeps serving traffic
}

Key Points

  • A pure cache should be cut over cold, not migrated
  • REPLICAOF plus REPLICAOF NO ONE is the cleanest path when allowed
  • RIOT or redis-shake handle managed sources that cannot be a primary
  • RESTORE needs the remaining PTTL or keys become permanent
  • Drain blocking consumers before the cutover or they stay on the old host
Q43

When do Redis Streams stop being the right answer and you should move to Kafka?

AdvancedStreams at Scale

Answer

Streams give you a lot: an append-only log, consumer groups with per-consumer pending lists, acknowledgements, claim and replay, and single-digit millisecond end-to-end latency because everything is in memory. For in-process fan-out, work distribution and event handoff between services inside one product, they are simpler and faster than running a broker, and BullMQ and Sidekiq-style queues are built on exactly these primitives. The limits are structural, not incidental.

Retention is memory: every entry you keep occupies RAM, so a Kafka-style seven-day replayable log of high-volume events is either impossible or absurdly expensive, and in practice you trim with MAXLEN ~ to whatever fits, which means your replay window is hours, not days. A stream lives in one hash slot, so a single stream cannot be sharded across cluster nodes; you shard by creating stream:0 through stream:N yourself and routing producers, which also means you own partition assignment and rebalancing rather than getting it from the broker. There is no built-in tiered storage, no compacted topic, no exactly-once transactional producer, no schema registry, and no consumer rebalancing protocol.

Durability is the last one: with appendfsync everysec you can lose a second of entries on a hard failure, and asynchronous replication means a failover can drop acknowledged XADDs, which Kafka avoids with acks=all and min.insync.replicas. So the honest boundary is this. Use Streams when the consumers are yours, the retention you need is minutes to hours, the throughput is tens of thousands of events per second, and losing a rare event during a failover is survivable. Move to Kafka when you need multi-day replay, multiple independent teams subscribing to the same topic, ordered partitions numbering in the hundreds, compaction, or a durability guarantee you can put in a compliance document.

# Streams: hard bound on retention because retention is RAM
XADD events:clicks MAXLEN '~' 1000000 '*' user 1042 sku 991
XLEN events:clicks
MEMORY USAGE events:clicks

# Trim by age instead of count
XTRIM events:clicks MINID '~' 1786492800000

# Manual sharding: a stream cannot span slots, so you route producers
XADD 'events:clicks:{s3}' '*' user 1042 sku 991
CLUSTER KEYSLOT 'events:clicks:{s3}'

# The metric that tells you when you have outgrown it
XINFO GROUPS events:clicks
# 1) name: analytics  consumers: 8  pending: 41022  lag: 918334
#    a lag that never drains means consumers cannot keep up with producers

XINFO STREAM events:clicks FULL COUNT 0 | head -20

Key Points

  • Streams retention is bounded by RAM, so replay windows are hours
  • One stream lives in one slot; sharding is your problem, not the server's
  • No compaction, tiered storage, schema registry or rebalance protocol
  • everysec plus async replication means rare entry loss on failover
  • Kafka wins on multi-day replay, many independent teams and hard durability
Q44

How does vector search work in Redis 8, and what would you watch when using it as a semantic cache?

AdvancedVector Search

Answer

Redis 8 gives you two routes. The Query Engine path uses FT.CREATE with a VECTOR field, choosing FLAT for exact brute-force search over small sets or HNSW for approximate search with the usual M and EF_CONSTRUCTION build parameters and EF_RUNTIME at query time, then FT.SEARCH with the KNN syntax to get the nearest neighbours plus a distance score. Because the vectors live on hashes or JSON documents alongside your normal fields, you can combine a KNN query with a filter on tags or a numeric range in the same call, which is the main reason teams pick Redis over a bolt-on vector database: one store for the embedding, the metadata and the cache.

The second route is vector sets, a native data type added in Redis 8 with VADD, VSIM, VREM and VDIM, which behaves like a sorted set for vectors, quantises to 8-bit by default (with NOQUANT and BIN alternatives) and supports attribute filtering through VSETATTR. For a semantic cache the design is: embed the incoming prompt, VSIM or KNN against stored prompt vectors, and if the best match is within a similarity threshold return the cached completion instead of calling the model. The things to watch are all operational.

Memory is the first: a 1536-dimension FLOAT32 vector is about 6 KB before index overhead, so a million of them is several gigabytes of RAM and quantisation is not optional at that scale. Second, the threshold is a product decision, not a technical one, and too loose a threshold returns confidently wrong answers, so log the distance of every hit and tune it against real traffic. Third, HNSW index build is CPU work on a single-threaded server, so bulk-load during off-peak. Fourth, embeddings are model-versioned: change the embedding model and every stored vector becomes meaningless, so put the model name in the index name and rebuild rather than mix.

# Query Engine route: HNSW index over hash documents
FT.CREATE idx:prompts ON HASH PREFIX 1 prompt: SCHEMA \
  tenant TAG \
  model TAG \
  answer TEXT \
  vec VECTOR HNSW 8 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE M 16 EF_CONSTRUCTION 200

# Hybrid: filter first, then nearest neighbours inside the filtered set
FT.SEARCH idx:prompts '(@tenant:{acme} @model:{v3})=>[KNN 5 @vec $blob AS score]' \
  PARAMS 2 blob "<float32 bytes>" SORTBY score DIALECT 2 RETURN 2 answer score

# Native vector sets: quantised by default, attributes for filtering
VADD prompts:v3 VALUES 1536 0.014 -0.221 ... 'prompt:88123'
VSETATTR prompts:v3 'prompt:88123' '{"tenant":"acme"}'
VSIM prompts:v3 ELE 'prompt:88123' WITHSCORES COUNT 5
VDIM prompts:v3
VCARD prompts:v3

FT.INFO idx:prompts   # vector_index_sz_mb, num_docs, indexing status
💡 Pro Tip: Log the similarity score of every semantic-cache hit alongside whether a human accepted the answer. That dataset, not intuition, is what sets the threshold, and it is the first thing a senior interviewer asks for.
Q45

Size and load-test Redis for a sale event. What do you benchmark, and what do the numbers actually tell you?

AdvancedCapacity Planning

Answer

Start from the workload, not from a benchmark. Estimate peak requests per second, the number of Redis commands per request (this is usually the surprise: a single API call often makes eight to fifteen calls), the value size distribution and the read-to-write ratio. Multiply out to get commands per second and bytes per second, because network is a real ceiling: 200,000 commands per second at 2 KB average payload is roughly 400 MB per second, over three gigabits, which will saturate a modest instance's NIC long before the CPU gives up.

Memory sizing is dataset plus roughly 30 percent for overhead and fragmentation, then leave the instance at 60 to 70 percent of host RAM so a BGSAVE fork with copy-on-write does not trigger an OOM kill at peak. Then benchmark properly. redis-benchmark is fine for a sanity check but its defaults lie about your workload: it hammers a tiny key space unless you pass -r, uses 3-byte values unless you pass -d, and pipelining with -P inflates throughput by an order of magnitude, so a number quoted without the pipeline depth is meaningless. memtier_benchmark is the better tool because it models a read-write ratio, key patterns and data sizes. Run the load generator on a separate host in the same availability zone, never on the Redis box, and report percentiles rather than averages.

What the numbers tell you: if p99 stays flat while throughput climbs and then knees sharply, you found the saturation point; if p99 degrades linearly from the start, you are network-bound or your values are too large. Finally, rehearse the operational parts, warm the cache before the sale rather than letting the first thousand customers pay for it, confirm evicted_keys stays at zero at projected peak, and run one deliberate failover under load so you find out that your client caches the old primary IP in staging rather than during the sale.

# Sanity check only, and always state the pipeline depth with the number
redis-benchmark -h prod-redis -t get,set -n 1000000 -c 50 -P 16 -d 512 -r 1000000 --threads 4

# Realistic mix: 9 reads per write, 512-byte values, random keys
memtier_benchmark -s prod-redis -p 6379 \
  --ratio=1:9 --data-size=512 --key-pattern=R:R --key-maximum=5000000 \
  -c 25 -t 4 --pipeline=4 --test-time=300 --hide-histogram

# What to watch while it runs
redis-cli --stat
redis-cli INFO stats | grep -E 'instantaneous_ops_per_sec|evicted_keys|rejected_connections'
redis-cli INFO clients | grep -E 'connected_clients|client_recent_max_output_buffer'
redis-cli INFO memory | grep -E 'used_memory_peak_human|maxmemory:'

# Rehearse the failure you will otherwise meet during the sale
redis-cli -p 26379 SENTINEL failover mymaster

Key Points

  • Commands per request, not requests per second, is the number that matters
  • Bandwidth saturates before CPU on large-value workloads
  • Keep maxmemory near 60-70 percent of host RAM for fork headroom
  • redis-benchmark defaults (tiny keyspace, 3-byte values, -P) distort results
  • Warm the cache and rehearse one failover under load before the event

Companies Hiring Redis

Flipkart
Swiggy
Zomato
Razorpay
PhonePe
Dream11
Meesho
Zerodha

Salary Insights

Average in India
₹7-24 LPA

Frequently Asked Questions

What does strong Redis knowledge pay in India in 2026?

Redis is rarely the whole job title, so it is priced as a multiplier on a backend or platform role. Typical bands run ₹7-24 LPA: around ₹7-12 LPA for two to four years where Redis means cache-aside plus sessions, ₹14-20 LPA for engineers who have run cluster mode, tuned eviction and debugged a production latency incident, and ₹22 LPA and above for platform or SRE roles that own Redis fleets, capacity planning and failover drills. Consumer-scale employers pay at the top of the band because the failure cost is highest there: Flipkart, Swiggy, Zomato, Dream11, PhonePe, Razorpay, Meesho and Zerodha all run Redis at loads where a bad eviction policy is a revenue event. The single biggest jump in the band comes from being able to reason about an INFO dump under pressure rather than from knowing more commands.

How long does it take to prepare for a Redis interview?

If you already use Redis daily, two focused weeks is usually enough: one week on the areas people skip (eviction policies, persistence and fork behaviour, replication and failover semantics, cluster hash slots) and one week building three or four things end to end, a token-bucket limiter in Lua, a reliable queue on streams with XAUTOCLAIM recovery, a lock with compare-and-delete release, and a cache with logical expiry. If Redis has only been a GET and SET dependency for you, budget four to six weeks and spend most of it running a local instance and deliberately breaking it: fill it past maxmemory, watch evicted_keys, kill a primary under Sentinel, trigger a BGSAVE on a large dataset and look at latest_fork_usec. Reading about these produces answers that sound rehearsed; doing them produces answers that survive follow-up questions.

Are Redis questions different for freshers and experienced engineers?

Very. A fresher round stays inside the first twenty questions here: data types and when to use each, TTL semantics, why INCR is atomic, KEYS versus SCAN, pipelining, and the difference between pub/sub and a queue. Getting those crisply right, with a reason rather than a definition, clears the bar. From roughly three years the questions turn operational and scenario-shaped: what happens to in-flight writes during a failover, why your hit ratio dropped after a deploy, what CROSSSLOT means and how you would have avoided it, how you sized maxmemory. At senior and staff level the interview is mostly incident narration, they will describe a symptom and expect you to name the diagnostic commands in order and defend the trade-off you would make. Freshers should optimise for precision on fundamentals; experienced candidates should optimise for one real war story they can tell in detail.

Is Redis still worth learning in 2026 with Valkey, Dragonfly and KeyDB around?

Yes, and the alternatives are an argument for learning it rather than against. Valkey is a fork of Redis 7.2.4 and speaks the same protocol and commands, Dragonfly and KeyDB are drop-in-compatible reimplementations, so the mental model you build (data structures, expiry, eviction, replication, slots, the single command thread) transfers to every one of them. What you are actually learning is a protocol and an operating model that the entire in-memory-datastore category is built on. The licence split has changed which engine teams deploy, not what engineers need to know, and interview questions are still phrased in Redis terms at almost every Indian employer. The one thing worth tracking is the divergence at the feature edge: the Query Engine, JSON and vector sets are Redis 8 features that Valkey does not ship, so know which side of that line your skills sit on.

Should I learn Redis or Kafka first?

Redis, in almost every case. It appears in more job descriptions, you can run it locally in one command, and the concepts you learn (expiry, eviction, atomicity, replication lag) show up again everywhere else including in Kafka. Kafka is a bigger commitment: partitions, consumer groups, offsets, rebalancing, retention and a much heavier local setup, and it only becomes relevant once your systems are large enough to need durable multi-day event logs. The natural order is Redis for caching and coordination, then Redis Streams so you understand consumer groups and pending entries in a small setting, then Kafka when you hit the limits described in the streams-versus-Kafka question above. Teams hiring for a Kafka role will usually assume you already know Redis, rarely the other way around.

Do I need Redis Cluster experience, or is standalone enough?

Standalone is enough to get hired at most product companies, but cluster knowledge is what separates a mid-level answer from a senior one, and it costs very little to acquire. You do not need to have operated a fifty-node cluster; you need to be able to explain hash slots, why CROSSSLOT happens, what hash tags do and what they cost, the difference between MOVED and ASK, and why SELECT does not work in cluster mode. That is one evening with a local three-node cluster started by redis-cli --cluster create. It also protects your standalone code, because the habits cluster forces on you (declare every key in KEYS, avoid multi-key commands across unrelated entities, prefix instead of numbered databases) are exactly the habits that make a migration to cluster mode a configuration change instead of a rewrite.

Introduction

Redis stopped being just a cache a long time ago. In 2026 the same server that fronts your product catalogue is also holding rate-limit counters, session tokens, leaderboards, distributed locks, BullMQ job queues, Streams consumer groups and, since Redis 8, vector sets and a bundled query engine. That breadth is exactly why interviews rarely stop at GET and SET. The moment you say you have used Redis in production, the questions turn to eviction policy, replication lag, fork pauses during BGSAVE, CROSSSLOT errors in cluster mode and what actually happens to in-flight writes when a primary fails over at peak traffic.

The Indian market makes this sharper. Sale events, IPL-season spikes, UPI payment bursts and food-delivery dinner peaks mean backend teams here run Redis at load levels where every design shortcut eventually shows up as a latency graph. Interviewers at companies like Flipkart, Swiggy, Razorpay, PhonePe and Dream11 probe the operational side hard: how you sized maxmemory, why your hit ratio dropped, how you detected a hot key, whether your lock was actually safe. Candidates who can only recite data type names get filtered in the first round; candidates who can read an INFO dump and reason about it get offers.

This guide covers the 45 Redis interview questions that decide those rounds, ordered from fundamentals to advanced production topics. Each answer explains how Redis genuinely behaves (not how the docs summary reads), the gotcha that bites teams in production, and what the interviewer is actually testing. Most questions carry a runnable command sequence or client-side snippet. Work through the basic section to lock down data structures and expiry semantics, then spend real time on the intermediate and advanced sections: eviction, persistence, cluster resharding, latency triage and consistency guarantees are where senior offers are won or lost.

Ready to practice Redis interviews?

Don't just read, practice these Redis questions live with an AI interviewer that asks follow-ups and scores your answers.

AI-powered practice
Instant feedback
Free to start
Start Free Mock Interview