Redis

All 26 notes on one page

Fundamentals

1

What is Redis & Common Use Cases

beginner redis basics use-cases

Redis stands for REmote DIctionary Server. In simple language, it’s a giant key-value store that lives entirely in RAM. We talk to it over TCP, throw keys at it, and get values back in microseconds.

Think of it like a Map<String, Value> running on a separate server, except the values can be strings, lists, hashes, sets, sorted sets, streams, and a few more exotic types. That’s what makes Redis special — it’s not “just” a cache, it’s a data-structure server.

Why we use Redis

Plain databases like PostgreSQL or MySQL live on disk. Disk is slow. Redis lives in memory. Memory is fast — we’re talking sub-millisecond reads. When an app needs to respond in under 50ms and the database takes 200ms, Redis sits in front and absorbs the heat.

The classic use cases

Cache
Store DB query results with TTL. The #1 reason teams adopt Redis.
Session Store
Stateless app servers share login sessions through Redis.
Rate Limiter
INCR + EXPIRE to count requests per user per minute.
Queue
Lists with LPUSH/BRPOP give us a simple job queue.
Leaderboard
Sorted sets give us top-N by score in O(log N).
Real-time Analytics
HyperLogLog and bitmaps count uniques cheaply.

Cache

The classic flow: check Redis first, fall back to the DB on miss, then write back into Redis.

GET product:42
# (nil) — miss, query DB
SET product:42 '{"name":"Shoe","price":99}' EX 3600
GET product:42
# "{\"name\":\"Shoe\",\"price\":99}"

Session store

When we run 10 app servers behind a load balancer, we can’t keep sessions in local memory — a user might hit a different server next request. Redis fixes that.

SET session:abc123 '{"userId":42,"role":"admin"}' EX 1800

Rate limiter

A fixed-window limiter in two commands:

INCR rate:user:42:2026-05-26T10:15
EXPIRE rate:user:42:2026-05-26T10:15 60

If INCR returns > 100, we reject the request.

Queue

LPUSH queue:emails "send:welcome:user42"
BRPOP queue:emails 0    # worker blocks until a job arrives

Leaderboard

ZADD game:leaderboard 4500 "alice" 3200 "bob" 5100 "carol"
ZREVRANGE game:leaderboard 0 9 WITHSCORES   # top 10

What Redis is not

Redis is not a primary database for everything. Anything you absolutely cannot lose still belongs in Postgres. Redis is a speed layer and a purpose-built data structure server — that’s the mental model to walk into the interview with.


2

Why Redis is Fast

intermediate redis performance internals

Redis routinely does 100,000+ ops/sec on a single core. Compare that to Postgres at maybe a few thousand simple queries on the same hardware. So what’s the magic? It boils down to four things.

1. Everything lives in RAM

Disk reads cost milliseconds. RAM reads cost ~100 nanoseconds. That’s a 10,000x gap. Redis just refuses to touch disk on the hot path — even persistence (RDB snapshots, AOF) happens in the background or in a forked child process.

Think of it like the difference between grabbing a book off your desk versus driving to the library.

2. Single-threaded event loop

This sounds like a weakness — “only one core?!” — but it’s a strength. Single-threaded means no locks, no context switches, no cache coherency drama between cores. Every command runs to completion atomically.

Redis Event Loop (single thread)
Client 1
Client 2
Client N
sockets
epoll / kqueue
multiplexer
Command
Executor
one at a time

Redis uses epoll (Linux) or kqueue (BSD/macOS) to watch thousands of sockets at once. The moment a socket has data, Redis parses the command, runs it, writes the reply, moves on. No thread pools needed.

The only difference from Node.js’s event loop is Redis is written in C, so it’s faster still.

3. Hand-tuned data structures

Redis doesn’t use generic hash tables for everything. It picks the right structure per type, and for small collections it uses ultra-compact encodings:

  • Small hashes → listpack (a packed contiguous array, cache-friendly)
  • Larger hashes → real hashtable
  • Sorted sets → skiplist + hashmap combo (O(log N) range + O(1) lookup)
  • Lists → quicklist (linked list of listpacks)

In simple language: Redis swaps encodings under the hood based on size, so small data is tiny and fast, big data is still O(log N) at worst.

4. Simple wire protocol (RESP)

The Redis Serialization Protocol is plain text with length prefixes. No JSON parsing, no XML, no schema validation. A GET foo is literally:

*2\r\n$3\r\nGET\r\n$3\r\nfoo\r\n

The parser is a few hundred lines of C. Zero overhead.

Pipelining makes it faster still

Network round-trips are the bottleneck once Redis is in-memory. Pipelining sends N commands without waiting for replies:

redis-cli <<EOF
SET a 1
SET b 2
SET c 3
EOF

In a client library:

const pipe = redis.pipeline();
for (let i = 0; i < 1000; i++) pipe.set(`key:${i}`, i);
await pipe.exec();   // one round trip, 1000 commands

The catch

Single-threaded means one slow command blocks everyone. KEYS * on a million keys? Everybody waits. That’s why we use SCAN for production iteration, avoid KEYS/FLUSHALL on live data, and watch out for big LRANGE or HGETALL on huge collections.

Redis 6+ added I/O threads for network reads/writes, but command execution itself is still single-threaded. The mental model holds.


3

Redis vs Memcached

intermediate redis memcached comparison

This is the classic Redis interview question. The short answer: Redis is a data-structure server with optional persistence; Memcached is a pure in-memory cache for strings. Both are blisteringly fast. Both speak text protocols. They overlap in the “cache strings” use case and diverge everywhere else.

Side by side

Redis
Strings, lists, hashes, sets, ZSETs, streams, bitmaps, HLL
Single-threaded command exec
Persistence: RDB + AOF
Replication + Cluster + Sentinel
Pub/Sub, Lua scripting, transactions
TTL per key
Value size: 512 MB
Memcached
Strings (and binary blobs) only
Multi-threaded
No persistence — cache lost on restart
No native replication
No pub/sub, no scripting
TTL per key
Value size: 1 MB (default)

When Memcached actually wins

Memcached is multi-threaded. If we’re running a pure cache on a 32-core box and our workload is “millions of GET/SET on plain strings”, Memcached can use every core in parallel. Redis would need clustering or I/O threads to match it.

So: huge servers, simple string caching, no need for anything beyond GET/SET/DEL → Memcached is a fine pick.

When Redis wins (which is most of the time)

The moment we need anything beyond “key → string”, Redis wins. Want to push a job onto a queue? Sorted leaderboard? Atomic counter? Set intersection for “friends in common”? Memcached can’t do any of that.

# Try this in Memcached. You can't.
ZADD leaderboard 100 alice
LPUSH queue "job-1"
SINTER tags:redis tags:nosql
HSET user:42 name "Alice" age 30

Persistence

Memcached is purely volatile. Restart the server, you lose everything. That’s fine for a cache — fall back to the DB and rebuild.

Redis offers two modes that work together:

  • RDB — point-in-time snapshots, low overhead
  • AOF — append-only log of every write, replayed on startup

This means Redis can act as a durable store, not just a cache. Many teams use Redis as the primary store for things like session data, rate-limit counters, and feature flags.

Replication & high availability

Memcached: nothing built-in. You shard client-side with consistent hashing and accept that node loss = cache loss.

Redis: built-in primary-replica replication, Sentinel for automatic failover, Cluster mode for sharding across nodes with automatic resharding. Redis is closer to a real database in this respect.

Eviction policies

Both support LRU eviction when memory fills up. Redis offers more knobs — LFU, TTL-based, random, allkeys vs volatile — and lets us pick per use case via maxmemory-policy.

CONFIG SET maxmemory 2gb
CONFIG SET maxmemory-policy allkeys-lru

The interview soundbite

“Memcached is a focused, multi-threaded string cache — great if that’s all you need. Redis is a single-threaded data-structure server with persistence, replication, and pub/sub. In 2026 the industry has mostly converged on Redis because the extra features pay for themselves the first time you need a queue, leaderboard, or rate limiter.”


4

Keyspace & Key Naming Conventions

beginner redis conventions keyspace

Redis has one flat namespace per logical database. There are no tables, no schemas, no folders. Every key is a string sitting next to every other key. That sounds chaotic, but a simple convention makes it tidy.

The colon convention

We use : to separate logical parts of the key. It’s not enforced by Redis — it’s just what everyone does, and tools like RedisInsight render it as a tree.

SET user:42:name "Alice"
SET user:42:email "alice@example.com"
HSET user:42:profile age 30 city "Mumbai"
SADD user:42:followers 17 23 88
INCR page:home:views
SET session:abc123 "userId=42" EX 1800

The pattern is roughly: <entity>:<id>:<attribute> or <feature>:<scope>:<id>.

Why this matters

We can scan a slice of the keyspace cheaply:

SCAN 0 MATCH "user:42:*" COUNT 100

In simple language: SCAN is like a non-blocking KEYS. It returns keys in chunks so we never freeze the server. Never use KEYS in production — it walks the entire keyspace under the single-threaded loop and stalls everything.

Keep keys short, but readable

Long keys waste memory. Short keys hurt debugging. The sweet spot is short tokens with full meaning:

  • Good: u:42:n (production-tight) or user:42:name (readable)
  • Bad: the_full_user_record_for_user_with_id_42_named_alice

For a million keys, going from 40 bytes to 12 bytes per key saves ~28 MB. Not free, but not a fortune either — readability usually wins until you’re at billions of keys.

Selecting a database

Redis has 16 logical databases by default (0 through 15), selectable with SELECT n. Don’t rely on this for multi-tenancy — it’s a legacy feature. Cluster mode only supports DB 0. Use key prefixes (tenant42:user:7) for real isolation.

Keyspace notifications

Redis can publish events when keys change — handy for cache invalidation, expiration hooks, and audit trails. They’re off by default because they add overhead.

Keyspace Notifications
SET user:42 ...
write
Redis publishes
__keyspace@0__:user:42
event channel
Subscriber
(your app)
listens

Turn them on, then subscribe:

CONFIG SET notify-keyspace-events KEA
# K = keyspace events, E = keyevent events, A = all event classes

SUBSCRIBE __keyevent@0__:expired

Now any time a key expires, we get a message. Classic use case: a user puts an item in their cart with a 30-minute TTL, and when the key expires we publish a “cart abandoned” event.

A few gotchas

  • Cluster mode hashes keys to slots by default. If you want two keys on the same slot (for atomic multi-key ops), wrap the shared part in {}: {user:42}:name and {user:42}:cart will live together.
  • Avoid spaces and binary garbage in keys — they make redis-cli debugging painful.
  • Pick the convention at day zero. Migrating millions of keys later is no fun.

Data Structures

5

Strings

beginner redis strings data-structures

The Redis string isn’t just text. It’s a binary-safe blob that can hold anything — UTF-8, JPEGs, gzipped JSON, protobuf, you name it — up to 512 MB per value. It’s the simplest and most-used Redis type.

In simple language: think of it like the value half of any key-value store, except Redis gives us a bunch of clever operations on top.

The basics

SET user:42:name "Alice"
GET user:42:name
# "Alice"

SET page:home:title "Welcome" EX 3600   # expires in 1 hour
TTL page:home:title
# (integer) 3600

DEL user:42:name

EX sets seconds; PX sets milliseconds. NX makes SET only succeed if the key doesn’t exist (useful for locks); XX only if it does exist.

SET lock:job:99 "worker-1" NX EX 30
# (nil) if someone else already holds it

Atomic counters with INCR

This is one of the most underrated Redis tricks. INCR is atomic — Redis runs it to completion under the single-threaded loop, so no race conditions even with 1000 clients hammering it.

SET page:home:views 0
INCR page:home:views
# (integer) 1
INCRBY page:home:views 5
# (integer) 6
DECR page:home:views
# (integer) 5

Use cases: page view counters, request counters for rate limiting, generating sequential IDs, stock levels in flash sales.

# Cheap unique ID generator
INCR id:invoice
# (integer) 100451

Range operations

SET msg "Hello, Redis world!"
GETRANGE msg 0 4
# "Hello"
GETRANGE msg 7 11
# "Redis"
APPEND msg " — and beyond"
STRLEN msg
# (integer) 32

GETRANGE is O(N) over the slice length, not the full string. Useful for things like storing log buffers or pulling a chunk of a large blob.

Multi-get / multi-set

Batch reads and writes save network round trips.

MSET user:1:name "Alice" user:2:name "Bob" user:3:name "Carol"
MGET user:1:name user:2:name user:3:name
# 1) "Alice"
# 2) "Bob"
# 3) "Carol"

Visualizing it

String Layout
key
user:42:name
value (≤ 512MB, binary-safe)
"Alice"
+ optional TTL, + optional encoding (int / embstr / raw)

Encoding under the hood

Redis stores small integers as actual long values (int encoding) — super compact. Short strings under 44 bytes use embstr (single allocation with the object header). Larger strings use raw. The encoding is invisible to us but matters for memory tuning.

SET counter 42
OBJECT ENCODING counter
# "int"
SET name "Alice"
OBJECT ENCODING name
# "embstr"

Cache pattern in a real app

async function getProduct(id) {
  const cached = await redis.get(`product:${id}`);
  if (cached) return JSON.parse(cached);

  const row = await db.query("SELECT * FROM products WHERE id=$1", [id]);
  await redis.set(`product:${id}`, JSON.stringify(row), "EX", 3600);
  return row;
}

When not to use strings

If we’re storing a JSON object and we’ll only ever update one field, use a hash instead — we’d otherwise have to read the whole blob, parse, mutate, re-serialize, and write back. Strings are best when the value is opaque to Redis or atomic counters/flags.


6

Lists

intermediate redis lists queues data-structures

A Redis list is an ordered collection of strings, implemented internally as a quicklist (linked list of listpacks). Push/pop on either end is O(1). That O(1) at both ends is what makes lists perfect for queues.

Think of it like a JavaScript Array where push, unshift, pop, and shift are all guaranteed fast no matter how many items are inside.

Push and pop

LPUSH tasks "task-1"        # left = head
LPUSH tasks "task-2"
RPUSH tasks "task-3"        # right = tail

LRANGE tasks 0 -1
# 1) "task-2"
# 2) "task-1"
# 3) "task-3"

LPOP tasks
# "task-2"
RPOP tasks
# "task-3"

The mnemonic: L is left/head, R is right/tail. Pushing on the left grows the head; ranging from 0 gives us the most recent first.

Layout

List: tasks
LPUSH ←
task-2
task-1
task-3
→ RPUSH
O(1) push/pop on both ends, O(N) for middle access

The job queue pattern

This is the killer use case. A producer pushes jobs; a worker pops them. BLPOP blocks the worker until something arrives — no busy polling.

# Producer
LPUSH queue:emails "user42@example.com"
LPUSH queue:emails "user43@example.com"

# Worker (blocks up to 0 seconds = forever)
BRPOP queue:emails 0
# 1) "queue:emails"
# 2) "user43@example.com"

LPUSH + BRPOP gives us FIFO (first in, first out). Producer pushes to the head, worker pops from the tail.

Reliable queue: BLMOVE

Naive BRPOP has a flaw — if the worker crashes after popping but before processing, the job is lost. BLMOVE (and the older BRPOPLPUSH) atomically moves the job to a “processing” list:

BLMOVE queue:emails queue:processing RIGHT LEFT 0
# work the job...
LREM queue:processing 1 "user43@example.com"

If the worker crashes, a recovery process can re-queue items stuck in queue:processing.

Capped lists for logs

Want the most recent 100 events and nothing more? LPUSH + LTRIM:

LPUSH events:user:42 "logged_in@2026-05-26T10:15"
LTRIM events:user:42 0 99    # keep only indices 0..99
LLEN events:user:42
# (integer) 100 (max)

LTRIM is O(N) but N is bounded by the trim window. This pattern is how Redis is often used for “last N items” feeds.

Inspect and mutate

LRANGE tasks 0 -1          # all elements
LINDEX tasks 0             # element at index 0
LSET tasks 0 "task-updated"
LLEN tasks
LREM tasks 1 "task-1"      # remove first occurrence of "task-1"

LRANGE 0 -1 is the Redis equivalent of “give me everything” — -1 means the last element. Watch out on huge lists; this is O(N).

Client usage

// BullMQ and similar libraries build on top of this exact pattern.
await redis.lpush("queue:emails", JSON.stringify({ to, subject, body }));

while (true) {
  const [, raw] = await redis.brpop("queue:emails", 0);
  const job = JSON.parse(raw);
  await sendEmail(job);
}

When lists are wrong

If we need uniqueness, use a set. If we need ordering by score (not insertion order), use a sorted set. If we need a Kafka-like log with multiple consumer groups and replay, use streams — they’re purpose-built for that and lists fall apart at scale there.


7

Hashes

beginner redis hashes data-structures

A Redis hash is a map of field → value stored under one key. Think of it like a tiny dict-inside-a-dict, or a database row where the columns are the fields. It’s the most natural way to store objects in Redis.

Why not just stringify a JSON blob and stuff it in a string key? Because with a hash we can read or update individual fields without touching the rest — no parse, mutate, re-serialize dance.

The basics

HSET user:42 name "Alice" age 30 city "Mumbai"
# (integer) 3   — three new fields

HGET user:42 name
# "Alice"

HGETALL user:42
# 1) "name"
# 2) "Alice"
# 3) "age"
# 4) "30"
# 5) "city"
# 6) "Mumbai"

HMGET user:42 name city
# 1) "Alice"
# 2) "Mumbai"

Update one field cheaply

This is the killer move. Compared to “fetch JSON → parse → mutate → write back”:

HSET user:42 city "Bangalore"
HGET user:42 city
# "Bangalore"

One round trip, one operation, no parsing.

Atomic field counters

HINCRBY is to hashes what INCR is to strings — atomic and useful.

HSET stats:user:42 logins 0 page_views 0
HINCRBY stats:user:42 logins 1
# (integer) 1
HINCRBY stats:user:42 page_views 5
# (integer) 5

Real-world: per-user counters, per-product stock levels, per-feature toggles.

Layout

Hash: user:42
field value
nameAlice
age30
cityMumbai
emailalice@example.com
Small hashes are encoded as a packed listpack — extremely memory-efficient.

Memory: hashes are tiny when small

When a hash has fewer than ~128 fields and each value is under 64 bytes (defaults configurable via hash-max-listpack-entries), Redis encodes it as a listpack — a contiguous, cache-friendly byte array. This is dramatically smaller than the same data spread across individual string keys.

Real-world example: storing 1M user profiles as user:{id}:name, user:{id}:age etc. uses ~5x more memory than HSET user:{id} name ... age .... This is why hashes are often called the “object encoding” of Redis.

Inspecting

HKEYS user:42         # all field names
HVALS user:42         # all values
HEXISTS user:42 city  # 1 or 0
HDEL user:42 city
HLEN user:42          # number of fields

Real-world: cached user profile

async function cacheUser(user) {
  await redis.hset(`user:${user.id}`, {
    name: user.name,
    email: user.email,
    plan: user.plan,
    lastSeen: user.lastSeen,
  });
  await redis.expire(`user:${user.id}`, 3600);
}

async function getUserField(id, field) {
  return redis.hget(`user:${id}`, field);   // one round trip, no parsing
}

Gotchas

  • HGETALL is O(N) — fine for small hashes, painful when the hash holds 100k fields. Use HSCAN for large ones.
  • Hash fields don’t have individual TTLs (until Redis 7.4 added per-field TTL). Older versions: the whole key expires together.
  • Field names use memory too — keep them short. n beats name if you have a billion of them; usually not worth it.

8

Sets

intermediate redis sets data-structures

A Redis set is an unordered collection of unique strings. Think of it like a Set<String> in Java or a Python set — adds are idempotent, duplicates are silently ignored, and membership checks are O(1).

What makes Redis sets special isn’t just storage — it’s the set algebra: intersection, union, difference, all running server-side in C.

The basics

SADD tags:post:99 "redis" "nosql" "cache"
# (integer) 3   — three new members

SADD tags:post:99 "redis"   # already there
# (integer) 0

SMEMBERS tags:post:99
# 1) "redis"
# 2) "nosql"
# 3) "cache"

SISMEMBER tags:post:99 "redis"
# (integer) 1
SISMEMBER tags:post:99 "graphql"
# (integer) 0

SCARD tags:post:99           # cardinality (size)
# (integer) 3

SREM tags:post:99 "cache"

The set algebra — where sets shine

This is the part most people don’t realize Redis can do. Suppose we have two sets of user IDs:

SADD users:premium 1 2 3 4 5 6
SADD users:active 4 5 6 7 8 9

SINTER users:premium users:active
# 1) "4"
# 2) "5"
# 3) "6"
# Premium AND active

SUNION users:premium users:active
# 1) "1" 2) "2" ... "9"
# Premium OR active

SDIFF users:premium users:active
# 1) "1" 2) "2" 3) "3"
# Premium but NOT active
Set Algebra
premium
{1,2,3,4,5,6}
SINTER
{4,5,6}
overlap
active
{4,5,6,7,8,9}

Classic use cases

Tags

SADD post:42:tags "redis" "interview" "backend"
SADD tag:redis:posts 42 87 99 101
SINTER tag:redis:posts tag:interview:posts     # posts tagged BOTH

Friends-in-common

SADD friends:alice 17 23 88 92
SADD friends:bob   23 55 88 101
SINTER friends:alice friends:bob
# 1) "23"
# 2) "88"

Unique visitors (small scale)

SADD visitors:2026-05-26 "user:42"
SADD visitors:2026-05-26 "user:43"
SCARD visitors:2026-05-26                       # unique count

For huge cardinalities (millions of unique values), switch to HyperLogLog — SADD storage grows linearly, HLL stays under 12 KB.

Random sampling

SRANDMEMBER tags:post:99 2     # 2 random members, no removal
SPOP queue:names               # 1 random member, removed

Useful for A/B testing, random featured items, lottery picks.

Storing the result

SINTERSTORE / SUNIONSTORE save the result to a new key — handy for caching expensive intersections.

SINTERSTORE active_premium users:premium users:active
EXPIRE active_premium 300

Now SMEMBERS active_premium is O(N) but free until the cache expires.

Iterating safely

Don’t SMEMBERS a set with 10 million entries — it blocks the server. Use SSCAN:

SSCAN big_set 0 COUNT 100

Encoding

Small sets of integers are stored as a sorted intset (compact, sorted array). Larger or mixed sets become a hashtable. Redis swaps automatically.

SADD numbers 1 2 3
OBJECT ENCODING numbers
# "listpack"  (Redis 7) or "intset" (Redis 6)

When sets are wrong

If we need ordering, use a sorted set. If we need counts per item, use a hash with HINCRBY. If cardinality matters but exact membership doesn’t, HyperLogLog is dramatically cheaper at scale.


9

Sorted Sets (ZSET)

intermediate redis sorted-sets leaderboard data-structures

A sorted set (ZSET) is the most powerful Redis data type. It’s a set of unique strings, where each member has a floating-point score, and the set is always kept ordered by score. Adds, removes, and rank lookups are O(log N).

Think of it like a database table with two columns — member and score — that’s always sorted by score and has a unique index on member. Except it runs in microseconds.

Internals: skiplist + hashmap

In simple language, Redis maintains two structures in parallel:

  • A skiplist for ordered traversal and range queries (O(log N))
  • A hashmap from member → score for O(1) score lookups

When we add a member, both structures update atomically.

ZSET Internal Layout
Skiplist (ordered)
alice → 3200
bob → 4500
carol → 5100
dave → 6900
range queries: O(log N)
Hashmap (member→score)
"alice" → 3200
"bob" → 4500
"carol" → 5100
"dave" → 6900
score lookup: O(1)

The basics

ZADD game:leaderboard 4500 "alice" 3200 "bob" 5100 "carol" 6900 "dave"
ZADD game:leaderboard 5500 "alice"     # update alice's score

ZSCORE game:leaderboard "alice"
# "5500"

ZRANK game:leaderboard "bob"
# (integer) 0       — lowest score, rank 0
ZREVRANK game:leaderboard "bob"
# (integer) 3       — from the top, rank 3

ZCARD game:leaderboard
# (integer) 4

Leaderboards (the canonical example)

# Top 10 players, highest first
ZREVRANGE game:leaderboard 0 9 WITHSCORES

# Players 100..119 (pagination)
ZREVRANGE game:leaderboard 100 119 WITHSCORES

# Bump alice by 100 points
ZINCRBY game:leaderboard 100 "alice"

Real-world numbers: a leaderboard with 10 million players still answers “top 100” and “what’s my rank” in well under a millisecond.

Range by score

# Players with score between 4000 and 5000, inclusive
ZRANGEBYSCORE game:leaderboard 4000 5000 WITHSCORES

# Strict bounds: (4000, 5000]
ZRANGEBYSCORE game:leaderboard "(4000" 5000

# All players above 5000
ZRANGEBYSCORE game:leaderboard 5000 +inf

Time-series / event log

Use a timestamp as the score and we get a sorted log of events:

ZADD events:user:42 1716720000 "login"
ZADD events:user:42 1716720130 "click:cart"
ZADD events:user:42 1716720180 "purchase"

# Events in the last hour
ZRANGEBYSCORE events:user:42 1716716400 +inf

Sliding-window rate limiter

This is one of the cleanest patterns in Redis. We use the timestamp as both the score and the member (with a unique suffix), then trim everything outside the window.

# At request time
ZADD rate:user:42 1716720000123 "1716720000123-req1"
ZREMRANGEBYSCORE rate:user:42 0 1716719940123    # drop entries older than 60s
ZCARD rate:user:42                                # count remaining
# If > 100, reject.
EXPIRE rate:user:42 120

This gives a true sliding window (not the buggy fixed-window pattern), and the whole thing is O(log N + M) where M is the items removed.

Lexicographic range (same score)

When all scores are equal, ZSET sorts members lexicographically. Use it for autocomplete or ordered string ranges:

ZADD autocomplete 0 "apple" 0 "appetizer" 0 "application" 0 "banana"
ZRANGEBYLEX autocomplete "[app" "[app\xff"
# 1) "appetizer"
# 2) "apple"
# 3) "application"

Practical client snippet

// Track top trending products by view count this hour
await redis.zincrby("trending:hour", 1, `product:${productId}`);
await redis.expire("trending:hour", 3600);

// Top 10 trending right now
const top = await redis.zrevrange("trending:hour", 0, 9, "WITHSCORES");

When ZSET is wrong

If we don’t need ordering, a set is cheaper. If we just need a queue, a list is cheaper. If we need a Kafka-style log with replay, use streams. ZSET’s superpower is ordered by score with fast updates — use it when that exact shape matters.


10

Bitmaps & HyperLogLog

advanced redis bitmaps hyperloglog analytics

These two types are how Redis handles “count things” at scale. Both are dramatically more memory-efficient than the naive solutions, and both are interview favorites because they show off Redis’s range beyond plain key-value caching.

Bitmaps — a giant array of bits

A bitmap isn’t really its own type — it’s a string treated as an array of bits. Each bit can be set, cleared, or read by offset. If we have 10 million users and want to track “did user X do action Y today?”, a bitmap uses 1 bit per user = 1.25 MB total. A set of user IDs would be ~80 MB. That’s a 64x win.

# Mark user 42 as logged in today
SETBIT logins:2026-05-26 42 1

# Was user 42 logged in?
GETBIT logins:2026-05-26 42
# (integer) 1

# How many users logged in today?
BITCOUNT logins:2026-05-26
# (integer) 1

Daily Active Users — the killer pattern

SETBIT dau:2026-05-26 42 1
SETBIT dau:2026-05-26 17 1
SETBIT dau:2026-05-26 99 1

BITCOUNT dau:2026-05-26
# (integer) 3

Bit operations across days

BITOP runs AND/OR/XOR across multiple bitmaps:

SETBIT dau:2026-05-24 42 1
SETBIT dau:2026-05-25 42 1
SETBIT dau:2026-05-26 42 1

BITOP AND active:3days dau:2026-05-24 dau:2026-05-25 dau:2026-05-26
BITCOUNT active:3days
# Users active on ALL 3 days — built-in retention analysis

In simple language: AND across daily bitmaps gives us a retention cohort with zero application code.

Bitmap: dau:2026-05-26
0
0
1
0
1
0
0
… up to user N …
1
bit offset = user ID, BITCOUNT = active users

Caveats

  • The offset is a uint32 — max ~4 billion bits.
  • Bitmaps work best when IDs are dense and sequential. Sparse IDs (UUIDs as strings → hash to int) waste bits.
  • SETBIT key 1000000 1 on an empty key allocates 125 KB at once. Plan for that.

HyperLogLog — counting uniques in 12KB

HyperLogLog (HLL) is a probabilistic data structure. It estimates the cardinality (number of unique elements) of a stream with ~0.81% standard error, using a fixed 12 KB regardless of input size.

That’s the magic: count uniques across 1 billion items in 12 KB. A set storing those would need gigabytes.

PFADD visitors:2026-05-26 "user:42" "user:17" "user:99"
PFADD visitors:2026-05-26 "user:42"        # already counted

PFCOUNT visitors:2026-05-26
# (integer) 3   — approximate

Combining multiple HLLs

PFADD visitors:2026-05-24 "user:42" "user:50"
PFADD visitors:2026-05-25 "user:42" "user:99"
PFADD visitors:2026-05-26 "user:17"

# Unique visitors across all 3 days
PFCOUNT visitors:2026-05-24 visitors:2026-05-25 visitors:2026-05-26

# Store a merged HLL
PFMERGE visitors:week visitors:2026-05-24 visitors:2026-05-25 visitors:2026-05-26
PFCOUNT visitors:week
HyperLogLog
stream:
1B events
input
→ hash + bucket →
12 KB sketch
(2^14 registers)
constant size
→ PFCOUNT →
~10M uniques
±0.81%
estimate

When to use which

  • Need exact unique count and have known dense IDs (user IDs 1..N)? Use a bitmap.
  • Need approximate unique count over arbitrary strings (IPs, sessions, queries)? Use HyperLogLog.
  • Need exact uniques over sparse data and you have plenty of RAM? Use a set.
  • Need set algebra (union/intersection) with exact precision? Sets or bitmaps (via BITOP).

Both bitmaps and HLLs are the kind of tools that quietly save thousands in infrastructure cost once a product hits real scale.


11

Streams

advanced redis streams event-sourcing consumer-groups

Streams are Redis’s answer to “I want Kafka but I already have Redis.” They’re an append-only log of entries, each with a monotonically increasing ID, supporting consumer groups for distributed processing, message acknowledgment, and replay. Added in Redis 5.0.

In simple language: think of it like a list that remembers everyone who’s read what, lets multiple workers split the work, and lets a new consumer catch up from the beginning whenever it wants.

The basics: XADD and XREAD

Each entry is a set of field-value pairs and gets an ID like 1716720000123-0 (milliseconds-sequence).

XADD events * type "login" userId "42"
# "1716720000123-0"

XADD events * type "purchase" userId "42" amount "99.99"
# "1716720000456-0"

XLEN events
# (integer) 2

XRANGE events - +
# 1) 1) "1716720000123-0"
#    2) 1) "type"
#       2) "login"
#       3) "userId"
#       4) "42"
# 2) ...

The * tells Redis to auto-generate the ID. The - and + in XRANGE mean “from beginning” and “to end.”

Reading new entries

XREAD blocks until new entries arrive, like BLPOP for streams:

XREAD BLOCK 0 STREAMS events $
# $ means "only entries added AFTER this call"

Consumer groups — the killer feature

This is what makes streams Kafka-lite. A consumer group lets multiple workers split the stream, each entry delivered to exactly one consumer in the group, with explicit acknowledgment.

Stream: events + Consumer Group: workers
stream
e1, e2, e3, e4, e5 ...
→ XREADGROUP →
worker-A: e1, e3
worker-B: e2, e5
worker-C: e4
→ XACK
Pending Entries List (PEL) tracks unacked messages per consumer for recovery

Setting up

# Create the group, starting from the very beginning
XGROUP CREATE events workers $ MKSTREAM
# $ = "start from new entries only", 0 = "start from beginning"

# Worker A reads
XREADGROUP GROUP workers worker-A COUNT 10 BLOCK 0 STREAMS events >

# > = "give me entries never delivered to anyone in this group"

# After processing
XACK events workers 1716720000123-0

What if a worker crashes?

Redis remembers every entry delivered but not yet acked — the Pending Entries List (PEL). We can inspect it and reassign stuck entries:

XPENDING events workers
# Summary: how many pending per consumer

XPENDING events workers - + 10 worker-A
# Detail: specific entry IDs that worker-A hasn't acked

XCLAIM events workers worker-B 60000 1716720000123-0
# Reassign that entry from whoever to worker-B if it's been pending > 60s

Newer Redis has XAUTOCLAIM which automates this — sweep up entries pending longer than X and reassign them.

Streams vs Lists vs Kafka

  • Lists (LPUSH/BRPOP): one consumer wins each item, no replay, no ack. Simple work queue.
  • Streams: many consumers per group, explicit ack, replay from any point, multi-group fanout. Real event log.
  • Kafka: same shape as streams, plus partitioning across brokers, infinite retention, exactly-once semantics, mature ecosystem. Use Kafka if you’re already operating it or if you need its scale.

For most apps under ~100k events/sec, Redis streams are plenty.

Real-world: order processing

// Producer (web server)
await redis.xadd("orders", "*",
  "orderId", id, "userId", userId, "amount", amount
);

// Consumer (worker)
while (true) {
  const res = await redis.xreadgroup(
    "GROUP", "fulfillment", "worker-1",
    "COUNT", 10, "BLOCK", 0,
    "STREAMS", "orders", ">"
  );
  for (const [stream, entries] of res) {
    for (const [id, fields] of entries) {
      await processOrder(fields);
      await redis.xack("orders", "fulfillment", id);
    }
  }
}

Trimming

Streams grow forever unless we trim. Use MAXLEN to keep only the most recent N:

XADD events MAXLEN ~ 1000000 * type "login" userId "42"
# ~ means "approximate" — way cheaper than exact trimming

XTRIM events MAXLEN 500000

The interview soundbite

“Streams give Redis a real event log with consumer groups and ack semantics, so we can build fan-out, retry, and replay without dragging in Kafka. The Pending Entries List + XCLAIM is the recovery story — that’s what makes them ‘reliable’ rather than fire-and-forget like lists.”


Persistence

12

RDB Snapshots

intermediate redis persistence rdb snapshots

RDB (Redis Database) is Redis’s snapshot-based persistence. Every so often, Redis dumps the entire dataset to a single binary file called dump.rdb. When Redis restarts, it loads that file back into memory.

In simple language — think of it like saving a video game. You play for a while, hit “save”, and the game writes everything to disk. If your computer crashes, you reload the save file and continue. But any progress between the last save and the crash is lost.

Why use RDB?

  • Compact — single binary file, easy to back up, easy to ship to another machine.
  • Fast restarts — loading an RDB file is faster than replaying a log.
  • Minimal runtime cost — the main process barely does any work for the snapshot itself.

The catch — if Redis crashes between snapshots, you lose everything written after the last snapshot.

How it works — BGSAVE and fork()

When Redis decides to snapshot, it calls BGSAVE. Here’s the trick — Redis uses fork() to create a child process. The child writes the snapshot. The parent keeps serving commands.

fork() uses copy-on-write (COW). The child gets a “copy” of memory, but the OS doesn’t actually duplicate the RAM. It only copies pages that get modified. So if your dataset is 10 GB and writes are slow, the snapshot barely uses any extra memory.

BGSAVE with fork() + Copy-on-Write
Parent (Redis)
keeps serving
GET / SET / DEL
clients never block
fork()→
Child
walks shared memory
writes dump.rdb
exits when done
COW = shared pages until the parent modifies them. Memory usage grows only with write rate during snapshot.

Triggering snapshots

Three ways — automatic (config), manual (commands), or on shutdown.

# redis.conf — save if condition met
save 3600 1       # after 3600s (1h) if at least 1 key changed
save 300 100      # after 300s if at least 100 keys changed
save 60 10000     # after 60s if at least 10000 keys changed

Manual commands:

SAVE       # blocking — main process does the snapshot. Avoid in prod.
BGSAVE     # non-blocking — fork() child writes. Use this.
LASTSAVE   # unix timestamp of last successful save

Tradeoffs

ProCon
Compact single fileData loss between snapshots
Fast restart from snapshotfork() can stall on huge datasets
Great for backups / replicationNo fine-grained recovery point

If your dataset is 50 GB on a memory-pressured box, fork() itself can hiccup because the OS has to set up page tables. That’s a real concern at scale.

When RDB alone is fine

  • You use Redis as a cache — losing the last few minutes is no big deal, you’ll repopulate from the source of truth.
  • You take periodic backups and ship dump.rdb to S3.
  • You want fast restarts and don’t need durability for every write.

If you do need stronger durability, pair RDB with AOF — that’s hybrid persistence, the recommended setup.


13

AOF (Append Only File)

intermediate redis persistence aof durability

AOF (Append Only File) is Redis’s other persistence mode. Every write command — SET, DEL, LPUSH, you name it — gets appended to a log file on disk. To restore, Redis replays the log from scratch.

In simple language — think of it like a bank ledger. RDB takes a photo of your account balance once a day. AOF writes down every single deposit and withdrawal. If something goes wrong, you can replay the ledger and reconstruct the exact balance.

Why AOF?

  • Durability — depending on fsync policy, you can lose at most 1 second of data (sometimes zero).
  • Human-readable — the file is just Redis commands in RESP protocol. You can cat it.
  • Repairable — if the file gets truncated, redis-check-aof --fix can salvage what’s left.

The cost — bigger files, slower restarts (replay every command), and more disk I/O during normal operation.

The fsync policies

Here’s where it gets interesting. Writing to a file goes through the OS buffer cache. To actually flush to disk you need fsync(). AOF lets you pick how often that happens.

fsync policies — durability vs throughput
always
fsync after every write
→ slowest, safest
data loss: ~0
everysec
fsync once per second
→ default, balanced
data loss: ≤ 1s
no
let the OS decide
→ fastest, riskiest
data loss: ~30s

everysec is the sweet spot. You’d only pick always if you absolutely cannot lose a single write (financial logs maybe), and no if Redis is just a cache and you don’t really care.

# redis.conf
appendonly yes
appendfsync everysec   # always | everysec | no

The rewrite problem

Here’s the issue with an append-only log — it grows forever. If you INCR counter a million times, the log has a million lines. But the final state is just counter = 1000000.

Redis fixes this with BGREWRITEAOF. It forks a child process (same COW trick as RDB), walks the current dataset, and writes the minimum set of commands needed to reconstruct it. Then it swaps the new file in.

BGREWRITEAOF       # manual trigger

Auto-rewrite config:

auto-aof-rewrite-percentage 100   # rewrite when AOF is 100% bigger than last rewrite
auto-aof-rewrite-min-size 64mb    # but only if it's at least 64mb

What’s in the file?

Just RESP-encoded commands. Open it up:

*2
$6
SELECT
$1
0
*3
$3
SET
$3
foo
$3
bar

That’s SELECT 0 followed by SET foo bar. Replay this top to bottom and you get the dataset back.

Tradeoffs at a glance

AOF winsAOF loses
Better durabilityLarger file size
Easy to repair / inspectSlower restart (must replay)
Tunable fsyncMore disk I/O during runtime

For most production setups, you’d run AOF alongside RDB — see Hybrid Persistence for the recommended combo.


14

Hybrid Persistence (RDB + AOF)

intermediate redis persistence rdb aof

Hybrid persistence is what most production Redis deployments actually run. You enable both RDB and AOF — but with a twist. The AOF rewrite uses RDB format for the base, then appends new commands on top.

In simple language — think of it like a photo album with sticky notes. The RDB snapshot is the photo (everything compactly captured at one moment). The AOF tail is the sticky notes describing what happened since the photo was taken. Together you get a full picture, fast.

Why hybrid?

Pure RDB → fast restart but you lose data between snapshots. Pure AOF → great durability but slow restart on big datasets (replay every command).

Hybrid → restart loads the RDB chunk fast, then replays only the small AOF tail. Best of both.

RDB vs AOF vs Hybrid
RDB only
Restart: fast
Durability: minutes lost
File: small
AOF only
Restart: slow (replay)
Durability: ≤ 1s lost
File: large
Hybrid
Restart: fast
Durability: ≤ 1s lost
File: medium

How it works

When AOF rewrite runs (BGREWRITEAOF), instead of writing out a giant list of commands, Redis dumps the dataset in RDB binary format as the prefix of the new AOF file. Then any commands that came in during the rewrite get appended as regular RESP commands.

So the file looks like:

[ RDB binary header ]
compact snapshot of all keys at rewrite time
[ RESP commands ]
writes that happened after rewrite

On restart Redis detects the RDB prefix, loads it in one shot, then replays the tail. Quick startup, almost no data loss.

Config

# redis.conf — recommended production setup
appendonly yes
appendfsync everysec
aof-use-rdb-preamble yes      # enables hybrid (default since Redis 5)

# keep RDB too — useful for backups
save 3600 1
save 300 100
save 60 10000

aof-use-rdb-preamble yes is what makes the hybrid magic happen. It’s been the default since Redis 5, so unless you’re on something ancient, you’re already getting this.

When to skip hybrid

  • Pure cache, no value in persistence — disable both. Redis is fast, save the disk I/O.
  • Replica-only durability — you might run AOF off, RDB only, and rely on async replication to a persistent secondary.
  • Memory tight, big dataset — the fork() during snapshots/rewrites can be expensive. Schedule it during low traffic, or use no-appendfsync-on-rewrite yes to dodge fsync during rewrites.

What to choose — decision tree

Is Redis the source of truth for any data?
├── No (just cache)
│   └── Disable persistence. Save disk I/O.
└── Yes
    ├── Can you tolerate 1s of data loss?
    │   ├── Yes → Hybrid (RDB + AOF everysec) ← default for most apps
    │   └── No  → AOF with fsync always (slowest but bulletproof)
    └── Need fast restarts AND backups?
        └── Hybrid. Always hybrid.

For 90% of real-world Redis deployments, hybrid with everysec fsync is the right answer. It’s the default in modern Redis for good reason.


Caching Patterns

15

TTL & Expiry

beginner redis ttl expiry caching

A TTL (time-to-live) is a countdown attached to a Redis key. When the TTL hits zero, the key is gone. This is how Redis behaves like a cache instead of a forever-store.

In simple language — think of it like a self-destructing message. You set the timer, and after that time elapses, Redis throws the key away. You don’t have to remember to delete it.

The basic commands

SET session:abc123 "user_42"
EXPIRE session:abc123 3600        # expires in 3600 seconds (1 hour)

# or do it in one shot
SET session:abc123 "user_42" EX 3600

TTL session:abc123                 # → 3599 (seconds remaining)
PTTL session:abc123                # → 3599042 (milliseconds remaining)

PERSIST session:abc123             # removes the TTL — key now lives forever

TTL return values worth knowing:

 -2  → key does not exist
 -1  → key exists but has no TTL
  N  → key exists, expires in N seconds

You can also set an absolute expiry time (Unix timestamp):

EXPIREAT session:abc123 1735689600
SET token "xyz" EXAT 1735689600

How does Redis actually expire keys?

Here’s the cool part. Redis doesn’t run a timer per key — that would be insanely expensive with millions of keys. Instead it uses a hybrid strategy: lazy expiration plus active expiration.

Two ways a key gets evicted
Lazy
When a client touches the key, Redis checks the TTL. Expired? Delete and return nil.

Cost: O(1) per access
Active
10× per second, Redis samples 20 random keys with TTLs. Expires the dead ones. If >25% were dead, immediately repeat.

Probabilistic sweep

The active algorithm in pseudocode:

every 100ms:
  loop:
    sample 20 keys from the "keys with TTL" set
    delete the expired ones
    if more than 25% were expired:
      continue (don't sleep, do another round)
    else:
      break

This means a key with a TTL might stick around in memory for a brief moment after its expiry — but no client will ever see it as alive, because the lazy check kicks in on access.

Common use cases

  • SessionsSET session:<id> <data> EX 1800 for 30-minute sessions
  • Rate limiting — counters that auto-reset
  • OTP / verification codesSET otp:<phone> 123456 EX 300 (5 min window)
  • Cache entries — keep hot data fresh, let stale data fall away
# rate limit: max 100 reqs/min per user
INCR ratelimit:user:42
EXPIRE ratelimit:user:42 60 NX    # NX = only set TTL if there isn't one

NX on EXPIRE (Redis 7+) is handy — only sets the TTL if no TTL exists. So the first INCR sets the window, subsequent INCRs don’t reset it.

Gotchas

  • Renaming a key with RENAME keeps the TTL. Copying with COPY does too (unless you pass options).
  • SET key value without EX clobbers any existing TTL. Use SET key value KEEPTTL to preserve it.
  • If you only check TTL, you can’t tell “no key” from “no TTL” without combining with EXISTS. That’s why -2 vs -1 matters.

TTLs are how Redis stays small and stays cache-y. When memory pressure exceeds what TTLs can handle, eviction policies take over.


16

Eviction Policies

intermediate redis eviction lru lfu caching

When Redis’s memory usage hits the maxmemory limit, something has to go. The eviction policy decides what. There are eight options — most boil down to “evict by recency, by frequency, by TTL, or randomly”.

In simple language — think of it like a small fridge. When it’s full and you want to put in a new item, you have to throw something out. Do you toss the oldest? The least-eaten? Or just grab whatever’s in front?

Setting it up

CONFIG SET maxmemory 2gb
CONFIG SET maxmemory-policy allkeys-lru

Or in redis.conf:

maxmemory 2gb
maxmemory-policy allkeys-lru

If you don’t set maxmemory, Redis will grow until the OS kills it (OOM). Always set it in production.

The eight policies

Eviction policies grouped by behavior
noeviction
Refuse new writes with OOM error. Reads still work.
allkeys-lru / lfu / random
Evict from any key in the dataset.
volatile-lru / lfu / random / ttl
Only evict from keys that have a TTL set.

The full list:

PolicyWhat it evicts
noevictionNothing — new writes fail when full
allkeys-lruLeast Recently Used, any key
allkeys-lfuLeast Frequently Used, any key
allkeys-randomA random key
volatile-lruLRU, but only keys with TTL
volatile-lfuLFU, but only keys with TTL
volatile-randomRandom key with TTL
volatile-ttlThe key closest to expiring

LRU vs LFU — what’s the difference?

LRU (Least Recently Used) — kicks out the key that hasn’t been touched in the longest time. Good when “recent = important”. A user who hit the site 1 minute ago probably matters more than one who hit it yesterday.

LFU (Least Frequently Used) — kicks out the key that’s been touched the fewest times overall. Good when popularity matters more than recency. A homepage hit 10000 times shouldn’t get evicted just because nothing touched it in the last 5 seconds.

LRU vs LFU on the same access pattern
Access log:  A A A A A A B C D E
                 (A hit 6 times, then B C D E one each)

LRU evicts: A ← A is oldest, even though it’s hottest LFU evicts: B ← B has been touched only once

LRU is an approximation in Redis — it samples a few keys and evicts the least recent. Same for LFU. Not exact, but cheap and good enough. Tune via maxmemory-samples (default 5, higher = more accurate, more CPU).

When to use which

Pure cache, no TTLs, hot-cold access:
→ allkeys-lru   (default-ish good choice)

Pure cache, popularity-driven (e.g. trending content):
→ allkeys-lfu

Mixed dataset: some keys are "real data", some are cached with TTLs:
→ volatile-lru or volatile-lfu
   (protects the no-TTL keys, only evicts the explicitly-cached ones)

Redis as a job queue or session store you can't afford to lose:
→ noeviction
   (let it OOM and alert you — better than silent data loss)

Don't care, just keep it running:
→ allkeys-random  (rarely the right choice, but cheapest)

Inspecting evictions

INFO stats | grep evicted
# evicted_keys:12047
# evicted_clients:0

INFO memory | grep maxmemory
# maxmemory:2147483648
# maxmemory_policy:allkeys-lru

If evicted_keys is climbing fast, your cache is too small for the working set — either bump maxmemory or shrink your data (better compression, shorter TTLs, drop hot-but-useless fields).

A subtle thing — noeviction is the default. If you set maxmemory but forget the policy, writes will start failing once you hit the limit. Always set both explicitly.


17

Cache Strategies

intermediate redis caching patterns architecture

When we add a cache in front of a database, we have to decide who talks to whom. Does the app go to the cache first, or to the DB? Who writes to the cache — the app, or the cache itself? These choices shape the four classic caching strategies.

In simple language — think of the cache as a fast intern and the DB as a slow expert. Do you ask the intern first, then go to the expert if they don’t know? Or do you have the intern handle everything and quietly check with the expert? That’s the difference.

1. Cache-Aside (Lazy Loading)

The most common pattern. The app manages everything — it checks the cache first, falls back to the DB on miss, and populates the cache.

Cache-Aside read flow
App ──GET key──▶ Cache
                   │
       ◀─── hit ───┤
                   │
       ◀── miss ───┘
       │
       ├── SELECT ──▶ DB ──▶ data
       │
       └── SET key ──▶ Cache  (so next read is a hit)
def get_user(user_id):
    key = f"user:{user_id}"
    cached = redis.get(key)
    if cached:
        return json.loads(cached)
    user = db.query("SELECT * FROM users WHERE id = %s", user_id)
    redis.set(key, json.dumps(user), ex=300)
    return user

Pros: simple, only caches what’s actually used, cache failure doesn’t take down the app. Cons: first read is always a miss (cold cache penalty), stale data unless you invalidate on writes.

This is what 80% of real-world apps use. It’s defensive and predictable.

2. Read-Through

The cache itself talks to the DB. The app only ever talks to the cache.

Read-Through
App ──GET key──▶ Cache ──miss──▶ DB
                   ◀───── data ──────┘
       ◀── data ───┘

This needs a cache layer smart enough to fetch from the DB on miss. Redis on its own doesn’t do this — you’d build it into a client library or use a system like RedisGears, or simply implement it as a wrapper around your cache calls. From the app’s perspective, the cache is the only thing it sees.

Pros: clean app code, cache logic centralized. Cons: first read still slow, requires cache to know your DB schema.

In practice, “cache-aside” wrapped in a helper function ends up looking like read-through anyway.

3. Write-Through

On every write, the app updates both the cache and the DB synchronously. The cache is always fresh.

Write-Through
App ──write──▶ Cache ──▶ DB
                          ◀── ok ──┘
              ◀── ok ────┘
   ◀── ok ───┘
def update_user(user_id, data):
    db.execute("UPDATE users SET ... WHERE id = %s", user_id)
    redis.set(f"user:{user_id}", json.dumps(data), ex=300)

Pros: cache and DB always in sync, no stale reads. Cons: writes are slower (two hops), and you may cache data that’s never read (write to a key no one ever GETs).

Use this when reads vastly outnumber writes and freshness matters.

4. Write-Behind (Write-Back)

The app writes to the cache only. The cache asynchronously batches writes to the DB later.

Write-Behind
App ──write──▶ Cache  (instant ack)
                  │
                  └─ batch ─▶ DB  (async, later)

Pros: fastest writes possible from the app’s view, batching reduces DB load. Cons: if the cache dies before flushing, you lose writes. Complex to get right.

You’d use this for high-write workloads where you can tolerate small data loss — analytics counters, event logs, view counts. Don’t use it for orders, payments, anything that must survive a crash.

Side-by-side

Cache-Aside
write: DB only
read: cache → DB
simple, default
Read-Through
write: DB only
read: cache (cache fetches)
centralized
Write-Through
write: cache + DB sync
read: cache
always fresh
Write-Behind
write: cache only (async to DB)
read: cache
fastest, riskiest

Invalidation — the hard part

For cache-aside, you have two options when data changes:

# option 1: delete the cache key (next read repopulates)
db.execute("UPDATE users SET ... WHERE id = %s", uid)
redis.delete(f"user:{uid}")

# option 2: update the cache key in place
db.execute("UPDATE users SET ... WHERE id = %s", uid)
redis.set(f"user:{uid}", json.dumps(new_data), ex=300)

Option 1 (delete-on-write) is safer. If the DB write succeeds but the cache update races with a concurrent read, you can end up with stale data. Deleting forces the next reader to refetch.

There’s a classic two famous things — “There are only two hard things in computer science: cache invalidation and naming things.” This is exactly the cache invalidation part. There’s no perfect answer; pick the strategy that matches your read/write ratio and freshness needs.

For most apps: cache-aside with delete-on-write + a TTL safety net. The TTL catches anything you forget to invalidate.


18

Cache Stampede / Thundering Herd

advanced redis caching stampede concurrency

A cache stampede (also called thundering herd or dogpile) happens when a hot cache key expires and a flood of concurrent requests all miss the cache at the same instant. Every one of them races to the DB to rebuild the entry. Your DB melts.

In simple language — imagine 10,000 people at a closed store waiting for it to open. The doors unlock and they all stampede inside at once. The shelves get cleared, the staff gets overwhelmed, chaos. That’s your DB when a hot key expires.

The problem

Stampede on key expiry
t=0    cache key "homepage" expires
t=0.01 req1 misses → SELECT * FROM ...
t=0.02 req2 misses → SELECT * FROM ...
t=0.03 req3 misses → SELECT * FROM ...
       ...
t=0.05 req1000 misses → SELECT * FROM ...

DB receives 1000 identical queries in 50ms. Connection pool exhausted. Latency spikes. App goes down.

The pathological case — the query takes 2 seconds. While the first request is still computing, 100,000 more requests arrive, all of them miss, all of them queue up to do the exact same expensive query. Now your DB has to do it 100,000 times instead of once.

Three solid solutions. Let’s walk through each.

Fix 1: Mutex / Lock

Only one request is allowed to rebuild the cache at a time. Everyone else either waits or serves stale.

def get_homepage():
    data = redis.get("homepage")
    if data:
        return data

    # try to acquire a short-lived lock
    lock_acquired = redis.set("lock:homepage", "1", nx=True, ex=10)

    if lock_acquired:
        try:
            data = db.query("...")             # expensive
            redis.set("homepage", data, ex=60)
            return data
        finally:
            redis.delete("lock:homepage")
    else:
        # someone else is rebuilding — wait briefly or serve stale
        time.sleep(0.05)
        return redis.get("homepage") or fallback

SET key value NX EX 10 is atomic — only one client gets the lock. The 10-second TTL on the lock is a safety net in case the rebuilder crashes.

Pros: simple, dramatically reduces DB load. Cons: other clients block or get nothing. If the lock holder crashes, you wait for the TTL to release.

Fix 2: Probabilistic Early Expiry (XFetch)

The clever one. Instead of waiting for the key to actually expire, each reader rolls a dice — the closer the key is to expiry, the more likely the roller decides to refresh early. By the time the key truly expires, it’s usually already been refreshed by some lucky reader.

The XFetch algorithm:

import random, math, time

def get_with_xfetch(key, ttl, beta=1.0):
    value, computed_at, delta = redis.hmget(key, "value", "computed_at", "delta")
    now = time.time()
    expiry = computed_at + ttl

    # delta = how long the recompute took last time
    # we randomly trigger early refresh near expiry
    if now - delta * beta * math.log(random.random()) >= expiry:
        # we volunteered to refresh
        start = time.time()
        value = db.query("...")
        new_delta = time.time() - start
        redis.hset(key, mapping={
            "value": value,
            "computed_at": now,
            "delta": new_delta,
        })
        redis.expire(key, ttl)

    return value

The math — -delta * beta * log(random()) produces a small random offset. When now + offset >= expiry, the request refreshes. The closer to expiry, the more likely the offset wins the comparison. So refreshes happen just before expiry, spread out over many requests.

Pros: no locks, no blocking, naturally spreads refresh load. Cons: more complex, you do slightly more refresh work overall.

Fix 3: Request Coalescing (Single-Flight)

If 1000 requests for the same key arrive at the same process at the same time, only let one of them actually hit the DB. The others wait on the in-flight result and share it.

Single-flight coalescing
req1 ─┐
req2 ─┤
req3 ─┼──▶  ONE actual DB call  ──▶ shared result
req4 ─┤
req5 ─┘
// Node.js single-flight using a Map of in-flight promises
const inFlight = new Map();

async function getCached(key) {
  const cached = await redis.get(key);
  if (cached) return cached;

  if (inFlight.has(key)) return inFlight.get(key);   // share the promise

  const promise = (async () => {
    try {
      const value = await db.query(/* ... */);
      await redis.set(key, value, "EX", 60);
      return value;
    } finally {
      inFlight.delete(key);
    }
  })();

  inFlight.set(key, promise);
  return promise;
}

Go’s singleflight package does exactly this. It only helps within one process — across many app servers, you’d still need a distributed lock (Fix 1).

Pros: zero-cost, per-process, instant. Cons: doesn’t help cross-process. Pair with a lock for full coverage.

Bonus fixes

  • Stale-while-revalidate — serve the expired value to clients while a background job refreshes it. Users see slightly stale data instead of waiting.
  • Pre-warm before expiry — a cron job rebuilds hot keys before they expire. Predictable load.
  • Jittered TTLs — if many keys were populated together (after a deploy), they’ll all expire together. Add randomness: ex=60 + random(0, 30).

Which to pick?

Single hot key, single app server:
→ Single-flight (Fix 3)

Single hot key, many app servers:
→ Distributed lock (Fix 1) — plus single-flight per-process for free

Many hot keys, want minimum complexity:
→ Probabilistic early expiry (Fix 2) or jittered TTLs

Read-heavy site where staleness is fine:
→ Stale-while-revalidate

In real systems you’d often combine — single-flight in-process, distributed lock cross-process, and jittered TTLs as a baseline defense. Stampedes are a “you don’t notice until production” kind of bug. Worth designing for upfront.


Advanced

19

Pub/Sub

intermediate redis pubsub messaging

Pub/Sub is Redis’s built-in messaging system. Publishers shout into a channel, subscribers listening on that channel receive the message. No queue, no persistence — if you’re not listening when a message is published, you miss it forever.

In simple language: it’s like a radio broadcast. The DJ plays a song. If your radio is on, you hear it. If it’s off, tough luck — there’s no replay.

Why use it?

Real-time fanout where missing a message is acceptable:

  • Cache invalidation across app servers (“user:123 changed, drop your cached copy”)
  • Live notifications (someone liked your post)
  • Chat rooms with no history requirement
  • Triggering refreshes on a dashboard

Pub/Sub trades durability for simplicity and speed.

The commands

# Terminal 1 - subscriber
SUBSCRIBE news.tech news.sports

# Terminal 2 - publisher
PUBLISH news.tech "Redis 8 released"
# returns the number of subscribers that received it

Pattern subscription with PSUBSCRIBE uses glob-style wildcards:

PSUBSCRIBE news.*       # matches news.tech, news.sports, anything
PSUBSCRIBE order.*.paid # matches order.123.paid

Fanout in one picture

Publisher
PUBLISH news.tech "..." →
Channel: news.tech
↓ fanout to all live subscribers ↓
Subscriber A
Subscriber B
Subscriber C
Subscriber D (offline) — misses the message forever

The catch: fire-and-forget

Redis Pub/Sub has at-most-once delivery:

  • No persistence — messages live only in memory during delivery
  • No acknowledgments — Redis doesn’t care if you actually processed it
  • No replay — disconnected subscribers can’t catch up
  • Slow subscribers get disconnected (Redis won’t buffer indefinitely)

If your subscriber crashes mid-message, that message is gone.

Pub/Sub vs Streams

Streams (XADD/XREAD) were added precisely because Pub/Sub couldn’t handle durable messaging.

FeaturePub/SubStreams
PersistenceNoYes (in-memory + AOF/RDB)
ReplayNoYes, by ID
Consumer groupsNoYes (Kafka-like)
AcknowledgmentsNoYes (XACK)
DeliveryAt-most-onceAt-least-once
Use caseLive fanoutEvent sourcing, work queues

Rule of thumb: if missing a message is a bug, use Streams. If missing a message is fine, Pub/Sub is simpler and faster.

Real-world example: cache invalidation

// Publisher (after DB write)
await db.update("users", { id: 123, name: "Manish" });
await redis.publish("cache.invalidate", "user:123");

// Subscriber (every app server)
sub.subscribe("cache.invalidate");
sub.on("message", (channel, key) => {
  localCache.delete(key);
});

Every app server clears its in-memory cache the moment the DB changes. If a server happens to be restarting and misses the message, no big deal — its cache is empty anyway.

Gotchas

  • Subscribed clients can’t issue normal commands (the connection is in subscribe mode). Use a separate connection for publishing.
  • PSUBSCRIBE is slower than SUBSCRIBE — Redis runs glob matching per channel per pattern.
  • In Cluster mode, regular Pub/Sub broadcasts cluster-wide. Sharded Pub/Sub (SSUBSCRIBE, Redis 7+) scopes channels to shards for better scalability.

20

Transactions

intermediate redis transactions concurrency

Redis transactions are nothing like SQL transactions. In simple language: a Redis “transaction” is just a way to queue up commands and run them back-to-back without anyone else’s commands sneaking in between. That’s it. No rollback. No real isolation levels. No ACID in the textbook sense.

Once you accept that, they’re actually useful.

The four commands

  • MULTI — start queuing commands
  • EXEC — run all queued commands as one atomic batch
  • DISCARD — throw away the queue
  • WATCH — optimistic lock on keys (abort if they change)

How it works

MULTI
SET balance:alice 100
INCRBY balance:bob 50
DECRBY balance:alice 50
EXEC

Between MULTI and EXEC, commands aren’t executed — they’re queued and Redis replies QUEUED. When EXEC fires, Redis runs the whole batch in one shot. No other client can interleave a command in the middle.

What “atomic” actually means here

It means nothing else runs in between. It does NOT mean “if one fails, all roll back”. Watch this:

MULTI
SET foo "bar"
INCR foo          # foo isn't a number — this WILL fail
SET baz "qux"
EXEC
# foo is "bar", baz is "qux", INCR errored but the others ran

There’s no rollback. If a command errors at runtime, the other commands in the batch still executed and stay committed. This is the biggest gotcha and a favorite interview trap.

MULTI/EXEC vs Lua

MULTI / EXEC
• Static command list
• No conditionals/loops
• No rollback on error
• Use WATCH for CAS
Lua (EVAL)
• Full scripting (if/loops)
• Read-then-write logic
• Atomic from outside
• Preferred for complex ops

Optimistic locking with WATCH

WATCH is Redis’s compare-and-swap. We watch one or more keys; if anyone modifies them before we call EXEC, the transaction aborts and returns nil. Then we retry.

Classic example: decrement inventory only if it’s positive.

WATCH inventory:sku-42
val = GET inventory:sku-42
# decide in client code
if val > 0:
  MULTI
  DECR inventory:sku-42
  EXEC   # nil if someone else changed inventory:sku-42 first
else:
  UNWATCH

In code:

async function buy(sku) {
  while (true) {
    await redis.watch(`inventory:${sku}`);
    const stock = parseInt(await redis.get(`inventory:${sku}`));
    if (stock <= 0) {
      await redis.unwatch();
      return false;
    }
    const result = await redis.multi().decr(`inventory:${sku}`).exec();
    if (result !== null) return true; // committed
    // result === null means WATCH fired — retry
  }
}

This is optimistic concurrency: we assume no conflict, detect it cheaply, retry on collision. Works great when contention is low.

Why not just use Lua?

Honestly, for anything beyond a static command list, Lua scripts are usually cleaner than MULTI/WATCH/retry loops. Lua runs atomically too, with full branching and looping. We cover EVAL in the next note. MULTI/EXEC is the older, simpler tool — still fine for grouping a known sequence of writes.

Quick recap

  • Transactions queue commands and run them in a single atomic batch.
  • No rollback. Runtime errors don’t undo prior commands.
  • WATCH gives us optimistic locking — retry loop on conflict.
  • For complex read-then-write logic, prefer Lua.

21

Lua Scripting & Pipelining

advanced redis lua pipelining performance

These two get lumped together in interviews and they shouldn’t. They solve completely different problems.

  • Lua scripting = atomicity. Run multi-step logic on the server with no other client interleaving.
  • Pipelining = throughput. Send many independent commands without waiting for replies one by one.

In simple language: Lua is “I need these 5 operations to act like one”. Pipelining is “I have 1000 unrelated SETs and I don’t want to pay 1000 network round-trips”.

Pipelining: cutting round-trips

Every Redis command normally goes: client sends → wait → server processes → server replies → client reads. The bottleneck is the network round-trip time (RTT), not Redis itself.

Pipelining batches commands into one send and reads all replies at the end.

Without pipelining (3 commands, 3 RTTs)
C → SET a 1 → S
C ← OK      ← S
C → SET b 2 → S
C ← OK      ← S
C → SET c 3 → S
C ← OK      ← S
With pipelining (3 commands, 1 RTT)
C → SET a 1 \n SET b 2 \n SET c 3 → S
C ← OK \n OK \n OK           ← S

In code:

// Without pipelining - 1000 round trips
for (let i = 0; i < 1000; i++) await redis.set(`k:${i}`, i);

// With pipelining - 1 round trip
const p = redis.pipeline();
for (let i = 0; i < 1000; i++) p.set(`k:${i}`, i);
await p.exec();

The difference can be 50-100x on a 1ms-RTT link.

Pipelining is NOT atomic

Other clients’ commands can still interleave between yours. Pipelining is purely a transport optimization. If you need atomicity, that’s MULTI/EXEC or Lua.

Lua scripting: atomic server-side logic

EVAL runs a Lua script on the Redis server. While the script runs, nothing else runs. Single-threaded Redis means a Lua script blocks everything else, so the script is atomic from every client’s perspective.

EVAL "return redis.call('GET', KEYS[1])" 1 mykey

Real-world: a rate limiter that checks and increments in one shot.

-- KEYS[1] = "ratelimit:user:42"
-- ARGV[1] = limit (e.g. 100)
-- ARGV[2] = window seconds (e.g. 60)

local current = redis.call("INCR", KEYS[1])
if current == 1 then
  redis.call("EXPIRE", KEYS[1], ARGV[2])
end
if current > tonumber(ARGV[1]) then
  return 0  -- rejected
end
return 1    -- allowed

Call it from the client:

const allowed = await redis.eval(
  rateLimitScript,
  1,                       // number of keys
  `ratelimit:user:${id}`,  // KEYS[1]
  "100", "60"              // ARGV
);

The classic alternative — GET, check, INCR, EXPIRE in client code — is a race waiting to happen. Lua collapses it into one atomic operation.

EVAL vs EVALSHA

Sending the full script over the wire every call wastes bandwidth. SCRIPT LOAD caches the script on the server and returns a SHA1. Then call it by hash:

SCRIPT LOAD "return redis.call('GET', KEYS[1])"
# "6b1bf486c81ceb7151f807e35e08a8df1e0a3a1f"

EVALSHA 6b1bf486c81ceb7151f807e35e08a8df1e0a3a1f 1 mykey

Most client libraries handle this transparently — they try EVALSHA first, fall back to EVAL with NOSCRIPT error, cache the hash.

When NOT to use Lua

  • Long-running scripts. Anything > a few ms blocks all other clients. Default kill threshold is 5s (lua-time-limit).
  • Network I/O or sleeps — not allowed and bad ideas.
  • Anything that calls many keys you can’t predict — Cluster mode requires all keys to live on the same slot.

Combining both

For batching many independent atomic operations, pipeline a bunch of EVALSHA calls. Best of both worlds:

const p = redis.pipeline();
for (const userId of userIds) {
  p.evalsha(sha, 1, `ratelimit:user:${userId}`, "100", "60");
}
const results = await p.exec();

Quick recap

  • Pipelining = transport-level batching, no atomicity.
  • Lua = server-side atomic execution, blocks everything while running.
  • Keep Lua scripts short. Cache them with SCRIPT LOAD + EVALSHA.
  • They compose: pipeline a stream of EVALSHA calls.

22

Distributed Locks (Redlock)

advanced redis locks distributed-systems concurrency

You have a job that must run on exactly one server at a time. Or you’re decrementing inventory and don’t want two processes selling the last item. You need a distributed lock — a flag in a shared place that says “I’m in, you wait”.

Redis is the obvious tool for this. It’s fast, it’s already in your stack, and the pattern looks deceptively simple. In simple language: we set a key with NX (only if it doesn’t exist) and a TTL. The key being there means “locked”. When done, we delete it.

But this topic is famous for one reason: the Martin Kleppmann critique of Redlock. Knowing this debate signals senior-level understanding. We’ll cover the simple pattern, the Redlock algorithm, and why it’s controversial.

The basic single-instance lock

# Acquire
SET lock:order:123 "owner-uuid" NX EX 30
# OK if acquired, nil if someone else holds it

# Do work...

# Release - DON'T just DEL, see below

Three critical pieces:

  • NX — only set if not exists (atomic check-and-set)
  • EX 30 — auto-expire so a crashed holder doesn’t deadlock the system
  • A unique value (UUID) — so we only delete our own lock

Why the unique value matters

Imagine: you acquire the lock with 30s TTL. Your work takes 35s (GC pause, slow query, whatever). The lock expired at 30s. Someone else acquired it at 31s. At 35s you finish and call DEL lock:order:123 — you just deleted their lock. Now a third client can acquire it. Chaos.

Fix: only delete if the value is still ours. This needs Lua because GET-then-DEL isn’t atomic:

-- KEYS[1] = lock key, ARGV[1] = our UUID
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
end
return 0
const token = crypto.randomUUID();
const acquired = await redis.set(`lock:order:${id}`, token, "NX", "EX", 30);
if (!acquired) return false;

try {
  await doCriticalWork();
} finally {
  await redis.eval(safeReleaseScript, 1, `lock:order:${id}`, token);
}

This pattern is good enough for most real-world use cases — leader election, cron deduplication, cache rebuild coordination.

Redlock: the multi-instance algorithm

A single Redis is a single point of failure. If it dies, no one can lock. Replicas don’t fully help because replication is asynchronous — a master can ack a lock then die before replicating it, and a promoted replica won’t know.

Antirez (Redis’s creator) proposed Redlock: acquire the lock on a majority of N independent Redis masters (typically 5).

Redlock acquire (need majority = 3/5)
R1 OK
R2 OK
R3 OK
R4 FAIL
R5 FAIL
3 of 5 = majority → lock granted

Steps:

  1. Get current time T1.
  2. Try SET NX EX on all N masters, with a per-instance timeout (~5-50ms).
  3. Lock acquired only if a majority succeeded AND total time elapsed < TTL.
  4. Effective lock validity = original TTL - elapsed time - clock drift.
  5. If failed, release on all instances (even ones that failed — they might have succeeded silently).

The Kleppmann critique

In 2016, distributed systems researcher Martin Kleppmann published “How to do distributed locking” arguing Redlock is unsafe for any use case requiring correctness. The key arguments:

1. Process pauses break it. A client acquires the lock, gets paused (GC, swapping, VM migration) for longer than the TTL, then resumes and continues work — thinking it still holds the lock. Meanwhile someone else grabbed it. Now two clients act as lock holders.

2. Clock drift breaks it. Redlock assumes bounded clock skew. If one Redis server’s clock jumps forward (NTP correction, VM clock weirdness), its key expires early and the algorithm’s safety property collapses.

3. Network delays break it. Similar to pauses — packets delayed past TTL boundary.

Kleppmann’s prescription: locks for correctness need a fencing token — a monotonically increasing number returned with the lock. Every downstream operation must include the token; the storage system rejects writes with stale tokens. Redis Redlock doesn’t provide this.

His summary: use Redis locks for efficiency (avoiding duplicate work), use something like Zookeeper or etcd for correctness (preventing concurrent access to shared resources).

Antirez pushed back, arguing the assumptions Kleppmann attacked aren’t unique to Redlock and apply to any system without fencing tokens — and that real-world Redlock with sane clocks is fine. Worth reading both posts.

Practical guidance

For a typical interview answer:

  • For most app-level use (cron deduplication, cache rebuild, queue worker coordination), the simple SET NX EX + UUID + Lua-release pattern is fine.
  • For multi-instance HA, Redlock works but understand its assumptions.
  • When correctness is critical (financial transactions, exclusive access to a resource), use fencing tokens at the resource layer or pick a CP system like etcd / Zookeeper.
  • Always set a TTL. Always release with a token check. Always have a plan for what happens when the lock expires mid-work.

Quick recap

  • Single-instance: SET key uuid NX EX ttl + Lua release. Good enough most of the time.
  • Redlock: majority acquire across N masters. Better availability, complex assumptions.
  • Kleppmann’s point: without fencing tokens, no distributed lock is safe against process pauses. Use Redis for efficiency, stronger systems for correctness.
  • TTL + unique token + Lua release. Always.

Scaling & HA

23

Replication & Sentinel

intermediate redis replication sentinel ha

Redis is fast, but a single instance is a single point of failure. Replication gives us read scaling and a hot standby. Sentinel automates failover when the master dies. Together they’re the simplest path to high availability — short of going full Cluster.

In simple language: one Redis is the boss (master), others are clones (replicas) that copy everything the boss does. If the boss dies, Sentinel — a group of watchers — agrees on which clone gets promoted.

How replication works

Replicas connect to the master with REPLICAOF host port (or in config). The flow:

  1. Replica connects, master sends a full RDB snapshot of current data.
  2. Replica loads the snapshot.
  3. Master then streams every write command from a replication backlog buffer.
  4. Replica applies them in order.

If the replica disconnects briefly, it can do a partial resync using a replication offset and backlog — no full snapshot needed. If it’s gone too long, it falls back to a full resync.

# On replica
replicaof 10.0.0.5 6379
replica-read-only yes
INFO replication
# role:master
# connected_slaves:2
# slave0:ip=10.0.0.6,port=6379,state=online,offset=...

Async = trade-off

Redis replication is asynchronous. The master acks the write to the client BEFORE replicas have it. If the master crashes before replication, those writes are lost.

You can soften this with WAIT numreplicas timeout — a command that blocks until N replicas have acked, but it’s a best-effort guarantee, not durability:

SET foo bar
WAIT 2 100   # wait up to 100ms for 2 replicas to confirm

For strict durability you also need AOF with fsync always. Most setups accept the small data-loss window in exchange for speed.

Sentinel: automated failover

Replication alone doesn’t help if the master dies — clients are still pointed at a dead instance. Sentinel is a separate process (you run several, usually 3 or 5) that:

  • Monitors masters and replicas
  • Detects failures via PING
  • Coordinates failover by quorum
  • Tells clients the new master’s address
Master
→ replicates →
Replica 1
Replica 2
Sentinels (watching everyone)
Sentinel A
Sentinel B
Sentinel C
Quorum = 2/3 must agree master is down → vote to promote replica

Failure detection: SDOWN and ODOWN

  • SDOWN (Subjectively Down): one Sentinel can’t reach the master.
  • ODOWN (Objectively Down): a quorum of Sentinels confirm SDOWN.

Only ODOWN triggers failover. This prevents one flaky network link from causing a split brain.

The failover dance

  1. Sentinels reach ODOWN consensus.
  2. They elect a leader Sentinel (Raft-ish).
  3. Leader picks the best replica (lowest priority number, longest replication offset, lowest run ID).
  4. Issues REPLICAOF NO ONE on the chosen replica — it becomes master.
  5. Reconfigures the other replicas to follow the new master.
  6. Updates monitoring config so clients ask Sentinel for the new master.

Client integration

Clients connect to Sentinels first, not Redis directly:

const Redis = require("ioredis");
const redis = new Redis({
  sentinels: [
    { host: "sentinel-1", port: 26379 },
    { host: "sentinel-2", port: 26379 },
    { host: "sentinel-3", port: 26379 },
  ],
  name: "mymaster",   // master name configured in sentinel.conf
});

The client library handles asking Sentinel for the current master and reconnecting on failover.

Sentinel config essentials

# sentinel.conf
sentinel monitor mymaster 10.0.0.5 6379 2
# name             host       port quorum (min sentinels to agree)
sentinel down-after-milliseconds mymaster 5000
sentinel failover-timeout mymaster 60000
sentinel parallel-syncs mymaster 1

parallel-syncs = how many replicas resync from the new master at once. Higher = faster recovery but bigger bandwidth spike.

Limits

  • Replication is async — possible data loss on master crash.
  • Failover takes seconds (detection + election + promotion). Clients see errors during this window.
  • Doesn’t shard data. One master holds the whole dataset. When the dataset outgrows one machine, you need Cluster.

Quick recap

  • Master-replica = read scaling + hot standby. Async, so writes can be lost.
  • Sentinel = quorum-based watchers that automate failover.
  • Run an odd number of Sentinels (3 or 5) for quorum, on separate hosts.
  • Clients talk to Sentinels to discover the current master.
  • For sharding too, use Redis Cluster (next note).

24

Redis Cluster

advanced redis cluster sharding ha

When your data outgrows one machine’s RAM, or your write load saturates one master, you need horizontal sharding. Redis Cluster is the built-in answer. It splits the keyspace across multiple masters, each with its own replicas, and handles routing automatically.

In simple language: imagine 16,384 numbered buckets. Every key hashes into exactly one bucket. We split the buckets across our N nodes — each node owns a range. When a client wants user:42, the client computes the bucket, knows which node owns it, and talks directly to that node.

The 16384 hash slots

Redis hashes the key with CRC16 and mod-16384 to assign a slot:

slot = CRC16(key) mod 16384

Slots, not keys, are the unit of distribution. With 3 masters you might have:

16384 slots across 3 master nodes
Node A
slots 0 — 5460
+ replica A'
Node B
slots 5461 — 10922
+ replica B'
Node C
slots 10923 — 16383
+ replica C'

Adding a node = rebalance slots from existing nodes to the new one (live, no downtime). Removing a node = migrate its slots away first.

Client routing: MOVED and ASK

A naive client doesn’t know which node owns which slot. It connects to any node, sends a command, and Redis replies with one of:

  • MOVED 3999 10.0.0.7:6379 — “that slot lives on this other node, go there and update your map”
  • ASK 3999 10.0.0.7:6379 — “that slot is currently being migrated, try the destination once for THIS command”

Smart clients (ioredis, lettuce, jedis with cluster mode) build and cache the slot map by calling CLUSTER SLOTS on connect, then route directly. They only need MOVED to refresh their map after a topology change.

const Redis = require("ioredis");
const cluster = new Redis.Cluster([
  { host: "10.0.0.5", port: 6379 },
  { host: "10.0.0.6", port: 6379 },
  { host: "10.0.0.7", port: 6379 },
]);
await cluster.set("user:42", "manish");  // client routes to correct node

Gossip: how nodes find each other

Cluster nodes talk to each other over a separate “cluster bus” port (6379 + 10000 by default) using a gossip protocol. Each node periodically pings a few random peers carrying:

  • Known node states (alive/PFAIL/FAIL)
  • Slot ownership
  • Epoch (version) numbers

If enough masters agree a node is down (PFAIL → FAIL), they coordinate to promote one of its replicas. Failover is automatic — no Sentinel needed in Cluster mode.

Multi-key operations: the catch

Commands touching multiple keys only work if all those keys live on the same node, i.e., the same slot. Otherwise you get CROSSSLOT errors.

MSET a 1 b 2 c 3   # likely CROSSSLOT - a, b, c hash to different slots

Hash tags to the rescue

To force keys onto the same slot, wrap a portion of the key in {}. Only that part is hashed:

SET {user:42}:profile "..."
SET {user:42}:cart    "..."
SET {user:42}:orders  "..."
# all hash to the same slot - same node
MULTI; HSET {user:42}:profile name "M"; SADD {user:42}:cart sku-1; EXEC

The trade-off: too aggressive tagging concentrates load on one node. Use tags only for keys that genuinely need atomic multi-key access.

Replication and failover

Each master typically has 1+ replicas. Replication is async (same as standalone). On master failure:

  1. Other masters detect FAIL via gossip.
  2. One of the dead master’s replicas runs an election among masters.
  3. Winner takes over the slots and starts serving them.

If a master has no replica and dies, those slots are unavailable — and Cluster goes into a degraded state. By default, the whole cluster refuses writes if any slot is unreachable (cluster-require-full-coverage yes).

Limits and gotchas

  • Lua and transactions must touch keys on a single slot (use hash tags).
  • Pub/Sub — classic Pub/Sub broadcasts across the whole cluster (chatty). Use Sharded Pub/Sub (SSUBSCRIBE, Redis 7+) for per-shard channels.
  • SELECT db isn’t supported. Cluster only uses db 0.
  • Big multi-key commands (KEYS *, FLUSHDB) need to be issued to every node, not just one.
  • Resharding is live but takes time and pushes load during migration.

When to choose Cluster vs Sentinel

NeedPick
Dataset fits one machine, just want HASentinel
Need to scale writes or RAM beyond one nodeCluster
Heavy multi-key transactions across many keysSentinel (or carefully designed hash tags)
Operational simplicitySentinel

Quick recap

  • 16384 hash slots, distributed across masters.
  • Smart clients cache slot maps and route directly.
  • Multi-key ops need same slot — use hash tags {...}.
  • Gossip protocol handles membership + failure detection.
  • Automatic failover via replicas, no Sentinel required.
  • It’s eventually consistent, async-replicated, with the same data-loss-on-crash trade-off.

Production Concerns

25

Memory Management

advanced redis memory performance ops

Redis lives in RAM. When RAM runs out, bad things happen — either Redis stops accepting writes or the OS kills the process. Understanding memory management is the difference between a stable production deployment and 3 AM pages.

In simple language: you tell Redis how much memory it can use, what to do when full, and then watch for the keys that are eating disproportionate space or CPU. That’s the job.

maxmemory and eviction policies

maxmemory caps Redis’s memory usage. When hit, the configured policy decides what happens:

maxmemory 4gb
maxmemory-policy allkeys-lru

Policies fall into three families:

PolicyWhat it does
noevictionReject writes with OOM error (default). Reads still work.
allkeys-lruEvict least recently used across all keys
allkeys-lfuEvict least frequently used across all keys
volatile-lruLRU but only among keys with a TTL
volatile-lfuLFU but only among keys with a TTL
volatile-ttlEvict keys closest to expiring
allkeys-random / volatile-randomRandom eviction

For pure caches: allkeys-lru or allkeys-lfu. For mixed cache + persistent data: volatile-* so the data without TTL is never evicted.

Note: Redis’s LRU/LFU are approximate. It samples 5 keys (configurable via maxmemory-samples) and evicts the worst — way faster than tracking everything globally.

What happens at OOM

Memory hits maxmemory
noeviction
Writes → OOM error
Reads → fine
Risk: app errors flood
allkeys-lru/lfu
Old keys evicted on demand
Writes succeed
Risk: silent cache miss
No maxmemory set + RAM exhausted → OS OOM killer terminates redis-server.

If you don’t set maxmemory, Redis will happily consume everything until the Linux OOM killer steps in and SIGKILLs the process. Always set maxmemory in production.

Big keys: the silent killer

A “big key” is a single key holding a huge value or collection — multi-MB strings, lists with millions of items, hashes with tens of thousands of fields. They cause:

  • Slow DEL (O(N) free, blocks Redis). Use UNLINK instead — async free.
  • Slow migrations during cluster resharding.
  • Long blocking commands like HGETALL, SMEMBERS, LRANGE 0 -1.
  • Lopsided cluster shard load.

Find them:

# Sampling - safe in production
redis-cli --bigkeys

# Or per key
MEMORY USAGE user:42
DEBUG OBJECT user:42

Fix by sharding the value yourself: instead of one giant hash posts:all, use posts:bucket:0, posts:bucket:1 etc., or break by user/time.

Hot keys: the other silent killer

A “hot key” is one that gets disproportionately many requests. In single-instance Redis it just causes contention. In Cluster mode, the whole shard owning it becomes a bottleneck — and you can’t scale around it just by adding nodes.

Find them:

redis-cli --hotkeys     # requires LFU policy
MONITOR | head -1000    # CAUTION: huge performance hit, never long-term

Fixes:

  • Client-side cache the hot key (Redis 6+ supports server-assisted client caching).
  • Replicate reads to replicas if it’s read-heavy.
  • Shard the key if writes are heavy — split into counter:1, counter:2, etc., aggregate on read.

OBJECT ENCODING — how Redis stores stuff

Internally Redis picks compact encodings for small data structures, then switches to general ones when they grow.

SET small "hello"
OBJECT ENCODING small      # "embstr" - compact embedded string

SET big "long string > 44 chars..."
OBJECT ENCODING big        # "raw"

HSET user:1 name "M" age 30
OBJECT ENCODING user:1     # "listpack" (small hash)

# Add many fields...
OBJECT ENCODING user:1     # "hashtable" once it crosses thresholds

Thresholds are configurable:

hash-max-listpack-entries 128
hash-max-listpack-value 64
list-max-listpack-size -2
set-max-listpack-entries 128
zset-max-listpack-entries 128

Why care? Small hashes/lists/sets/zsets are dramatically more memory-efficient. A “hash of small hashes” pattern lets you store millions of items in a fraction of the RAM compared to flat keys.

Classic example: instead of user:1:name, user:1:age, …, store everything under user:1 as a hash. Or shard further: users:bucket:42 holds 100 user hashes — each lookup is HGET users:bucket:42 1.

Memory analysis tools

INFO memory                 # used_memory, peak, fragmentation ratio
MEMORY STATS                # detailed breakdown
MEMORY USAGE key            # bytes for one key
MEMORY DOCTOR               # heuristic advice
redis-cli --memkeys         # sample biggest keys by memory

mem_fragmentation_ratio > 1.5 suggests fragmentation. activedefrag yes enables online defrag (with CPU cost).

Production checklist

  • Always set maxmemory and a sane eviction policy.
  • Run --bigkeys regularly; alert on any single key over a threshold.
  • Monitor hot keys; design schema to avoid concentrating writes.
  • Use UNLINK for large deletes, never DEL on suspect keys.
  • Tune hash-max-listpack-* and friends for your data shape.
  • Watch fragmentation ratio; enable active defrag if needed.

Quick recap

  • maxmemory + eviction policy = OOM protection.
  • LRU/LFU are sampled approximations.
  • Big keys block; hot keys bottleneck shards.
  • OBJECT ENCODING reveals internal layout. Small structures use compact encodings.
  • UNLINK > DEL for large values.

26

Monitoring & Slowlog

intermediate redis monitoring ops performance

When Redis acts up in production, you need fast ways to figure out what’s wrong without making it worse. Redis ships with four main introspection tools: INFO, MONITOR, SLOWLOG, and LATENCY. Each has a sweet spot — and some have nasty footguns.

INFO: the dashboard

INFO dumps server stats by section. Don’t run it without a section filter in scripts — the full output is large.

INFO server        # version, uptime, mode, OS
INFO clients       # connected clients, blocked clients
INFO memory        # used_memory, peak, fragmentation, evicted_keys
INFO stats         # ops/sec, total commands, keyspace hits/misses
INFO replication   # role, master link, lag
INFO commandstats  # per-command call count + latency
INFO keyspace      # key count + expires per db

Key fields to alert on:

FieldWhy it matters
used_memory_rss vs used_memoryFragmentation ratio. > 1.5 is concerning.
evicted_keysIf non-zero with non-cache workload — bad.
instantaneous_ops_per_secThroughput baseline.
keyspace_misses / (hits + misses)Cache miss rate.
connected_clientsConnection leak detection.
master_link_statusReplica health.
mem_fragmentation_ratioMemory health.
# Useful one-liner
redis-cli INFO stats | grep -E "ops_per_sec|hits|misses|evicted"

MONITOR: powerful and dangerous

MONITOR streams every command Redis executes. Great for debugging “what is this app actually sending?”. Terrible for sustained use.

MONITOR
# 1716700000.123456 [0 127.0.0.1:54321] "GET" "user:42"
# 1716700000.124001 [0 127.0.0.1:54322] "SET" "session:abc" "..."

The catch: MONITOR is expensive. Redis has to serialize every command for every subscriber. Throughput can drop by 50% or more while it’s active. Never leave it running, never wire it into normal monitoring. It’s a “during incident only, briefly” tool.

For production-grade observability use INFO commandstats (per-command rates and latencies, sampled).

SLOWLOG: catching slow commands

SLOWLOG records commands that exceed a configured execution time, without the throughput hit of MONITOR.

slowlog-log-slower-than 10000   # microseconds (10ms)
slowlog-max-len 128             # ring buffer size
SLOWLOG GET 10        # 10 most recent slow entries
SLOWLOG LEN
SLOWLOG RESET

Each entry has: id, timestamp, duration in microseconds, the command + args, client info. Set slowlog-log-slower-than to something like 10ms (10000) in production and review the log when latency spikes.

What ends up here in real life:

  • KEYS * on big keyspaces (please never)
  • HGETALL / SMEMBERS / LRANGE 0 -1 on big keys
  • Long Lua scripts
  • DEL on a multi-MB value (use UNLINK)

LATENCY: end-to-end measurements

SLOWLOG only times the command itself — not blocking inside the event loop. LATENCY measures latency spikes from the inside of Redis, including things like fork pauses, AOF fsync, slow system calls.

Enable it:

latency-monitor-threshold 100   # ms, 0 disables
LATENCY LATEST       # most recent spike per event
LATENCY HISTORY fork # recent spike history for "fork"
LATENCY DOCTOR       # human-readable analysis with suggestions
LATENCY RESET

Common events you’ll see:

  • fork — RDB or AOF rewrite forking. On huge datasets this can pause Redis for seconds.
  • aof-fsync-always — fsync-blocking writes.
  • expire-cycle — active expiration scanning.
  • command — a slow command (correlate with SLOWLOG).

Putting it together: a triage flow

Redis latency complaint — what to check
1. INFO
memory, clients, evictions, ops/sec, hit rate
2. SLOWLOG GET
find expensive commands
3. LATENCY DOCTOR
spot fork/fsync/expire pauses
4. --bigkeys/--hotkeys
find data-shape culprits
5. MONITOR (briefly!)
confirm app behavior, then stop

Other useful tools

CLIENT LIST              # all connected clients, idle time, last command
CLIENT KILL ID <id>      # kill a misbehaving client
DEBUG SLEEP 5            # simulate a slow command (dev only)
redis-cli --latency      # continuous latency sampling from outside
redis-cli --latency-history -i 1
redis-cli --stat         # ops/sec, memory, clients summary

redis-cli --latency is great for tracking down network problems vs server problems — it measures end-to-end ping from the client side.

Production setup

  • Prometheus + redis_exporter scraping INFO is the de facto standard.
  • Alert on: evictions, master_link_down, fragmentation > 1.5, slowlog growth rate, hit ratio drops, connection count climbs.
  • Set latency-monitor-threshold and slowlog-log-slower-than in your base config.
  • Have runbooks for the common incidents: big-key DEL, fork pause during BGSAVE, replica lag.

Quick recap

  • INFO for stats. Always filter by section. Wire into Prometheus.
  • MONITOR for live debugging. Costly. Use briefly.
  • SLOWLOG for commands above N microseconds. Free, sampled into a ring buffer.
  • LATENCY for Redis-internal pauses (fork, fsync). Configure threshold.
  • redis-cli --latency for outside-in measurements.