Skip to content

7.6 — Caching in the Data Layer: Redis in Depth

A product page takes 240 ms because it runs eleven queries. Someone puts the rendered result in Redis with a 60-second expiry, and the page takes 3 ms.

Then a price changes and customers see the old one for a minute. Then a deploy clears the cache and the database receives eleven queries from every request at once and falls over. Then someone stores a session in the same Redis and a restart logs everyone out.

Every one of those is a known failure with a known fix. Chapter 10.14 covers cache strategies — cache-aside versus write-through, invalidation, the stampede, CDN behaviour — as distributed-systems design. This page covers the thing you actually operate: what Redis is, what its data structures are for, how it can lose your data, and how it fails.

1. Why Redis is fast, and what that costs

Redis keeps everything in memory and is single-threaded for command execution. Both facts matter.

In memory means a lookup is a hash table probe — no page reads, no buffer pool, no disk. Hundreds of thousands of operations per second per instance is ordinary.

Single-threaded sounds like a limitation and is mostly a feature. There are no locks, no race conditions between commands, and every individual command is atomic by construction. INCR cannot lose an update, because no two commands run at once. That is what makes Redis a correct counter and a correct lock manager without you doing anything.

Redis 6 added threaded I/O for reading and writing sockets, and Redis 7 kept command execution single-threaded. The execution model has not changed — one command at a time, in arrival order.

The consequence you must design around: one slow command stops everything.

KEYS *              -- scans every key. On 10 million keys, seconds of total blockage.
FLUSHALL            -- synchronous by default
SMEMBERS huge_set   -- returns a million members in one blocking call

KEYS in production is the classic Redis outage. Use SCAN, which returns a cursor and a small batch, so other commands interleave. The same applies to HSCAN, SSCAN and ZSCAN, and to UNLINK instead of DEL for large values — UNLINK frees the memory in a background thread.

This also decides how you scale. More cores do not make one Redis instance faster. You run more instances — several on one machine, or a cluster — which is why the standard answer to "Redis is at 100% CPU" is sharding rather than a bigger box.

2. The data structures, and what each one is actually for

Redis is often called a key-value store, which undersells it. The values have types with operations, and choosing the right one turns application code into a single command.

Strings — bytes, up to 512 MB. Caching serialised objects, and counters via INCR/INCRBY, which are atomic.

Hashes — a map inside a key. HSET user:42 name Ana email ana@x.com. The reason to use one instead of separate keys is that you can read or write one field without fetching the rest, and small hashes are stored in a compact encoding that uses far less memory than the equivalent separate keys.

Lists — a linked list with push and pop at both ends. LPUSH + BRPOP is a blocking work queue in two commands. Honest limit: a list-based queue has no acknowledgement, so a worker that crashes mid-job loses it. Streams (below) fix that.

Sets — unordered unique members, with SINTER, SUNION, SDIFF computed server-side. "Which of these tags do these two users share" is one command, not a round trip per element.

Sorted sets — the most useful structure and the least known. Every member has a score, and members are kept ordered by it.

ZADD leaderboard 4200 "ana"          -- add or update a score
ZREVRANGE leaderboard 0 9 WITHSCORES -- top ten, already sorted
ZRANK leaderboard "ana"              -- this player's position — O(log n)
ZRANGEBYSCORE due 0 1754131200       -- everything due before a timestamp

A leaderboard, a priority queue, a rate limiter and a delayed-job scheduler are all sorted sets. Use the timestamp as the score and ZRANGEBYSCORE becomes "what is due now". Underneath it is a skip list plus a hash map (Chapter 4.13.2), which is why rank and range are logarithmic rather than linear.

Bitmaps — bit operations on a string. SETBIT active:2026-08-02 42 1 marks user 42 active today; BITCOUNT counts them. Ten million users of daily-active tracking costs 1.25 MB per day.

HyperLogLog — approximate distinct counts in 12 KB regardless of cardinality, with about 0.81% error, and mergeable so weekly uniques are the union of seven daily sketches (Chapter 4.29).

Streams — an append-only log with consumer groups, acknowledgements and a pending-entries list. This is the structure to use for a work queue that must not lose messages: a consumer claims an entry, acknowledges it when done, and unacknowledged entries can be reclaimed after a timeout. It is a genuine step up from list-based queues, and a genuine step below Kafka (Chapter 10.8.2) on retention and replay.

GeospatialGEOADD/GEOSEARCH, built on sorted sets with a geohash score. Good enough for "shops within 5 km" without adding PostGIS.

The general lesson: reach past strings. Most Redis misuse is storing a JSON blob and doing in the application what a sorted set or hash would do in one atomic command.

3. Expiry, and how it actually happens

SET session:abc "{…}" EX 3600      -- expires in an hour
TTL session:abc                    -- seconds remaining
PERSIST session:abc                -- remove the expiry

Redis expires keys two ways, and both matter.

Lazily — when a key is accessed, if it has expired it is deleted and treated as missing.

Actively — a background cycle samples 20 random keys with expiries, deletes the expired ones, and repeats if more than 25% were expired.

So an expired key that nobody touches can occupy memory for a while. For most caches that is fine. It matters when you set expiries on millions of keys that are never read again — memory does not fall the moment they expire, and a sudden mass expiry produces a burst of background work.

One trap worth knowing: in older Redis, a write command that did not specify an expiry removed the existing one. Modern SET key value KEEPTTL makes the intent explicit. If a cache entry mysteriously stops expiring, this is usually why.

4. Eviction: what happens when memory runs out

Set maxmemory, and then maxmemory-policy decides what Redis does when it is reached.

PolicyBehaviour
noevictionWrites fail with an error. Reads still work
allkeys-lruEvict the least recently used key, expiry or not
allkeys-lfuEvict the least frequently used
volatile-lruEvict LRU only among keys with an expiry
volatile-ttlEvict the key expiring soonest
allkeys-randomEvict at random

The choice follows directly from what you are using Redis for.

Pure cacheallkeys-lru or allkeys-lfu. Losing anything is acceptable, because the database is the source of truth. allkeys-lfu is better when a small set of keys is hot and a long tail is scanned occasionally, because LRU lets one big scan evict your hot keys.

Sessions or anything you cannot losenoeviction, and monitor memory. Silently evicting a session logs a user out with no error anywhere.

Mixedvolatile-lru, so only things you marked as expendable can be evicted. But mixing cache and durable data in one instance is the mistake underneath most of these incidents. Two instances cost almost nothing and make the policy question trivial.

Redis's LRU is approximate, sampling a handful of candidates rather than maintaining a perfect ordering, because exact LRU would cost more memory and time than it is worth. maxmemory-samples tunes the accuracy. This is the same reasoning as the buffer pool in Chapter 7.3.1 — real systems approximate LRU on purpose.

5. Persistence: two mechanisms, neither of them a database

RDB is a point-in-time snapshot. Periodically, Redis forks and the child writes the whole dataset to a compact file. Fast to load, small on disk, good for backups.

The costs are specific. You lose everything since the last snapshot — minutes, typically. And the fork uses copy-on-write memory (Chapter 2.2), so a write-heavy instance can approach double its memory during a save, which is a real cause of a Redis being killed by the out-of-memory killer.

AOF is an append-only log of every write command, replayed on restart. This is the same write-ahead idea as Chapter 7.3.3.

appendfsync decides durability:

  • always — flush per command. Safest, and much slower.
  • everysec — flush once a second. The default, losing at most one second.
  • no — let the operating system decide.

The AOF is rewritten periodically to stop it growing without bound. Modern Redis uses a hybrid: the rewrite writes an RDB snapshot as the base, then appends commands after it — fast loading with second-level durability.

The honest framing: Redis with AOF is durable-ish, not a database. everysec loses up to a second of writes on a hard failure, and replication is asynchronous, so a failover loses whatever had not reached the replica. If losing it would be a correctness problem, it belongs in PostgreSQL. Use Redis for things that can be rebuilt or re-derived.

6. Atomicity: transactions, Lua and functions

Single commands are atomic. For several commands there are three tools, and the differences matter.

MULTI/EXEC queues commands and runs them together with nothing interleaved. It is not a transaction in the ACID sense — there is no rollback. If one command fails at runtime the others still apply. It gives isolation, not atomicity of outcome.

WATCH adds optimistic concurrency. WATCH key before reading, and if that key changed before EXEC, the whole block aborts and you retry. This is exactly the version-column pattern from Chapter 7.4.1, spelled in Redis.

Lua scripts are the strong tool. A script runs atomically, and it can read a value and branch on it — which MULTI cannot.

lua
-- decrement stock only if there is enough. KEYS[1]=stock key, ARGV[1]=qty
local current = tonumber(redis.call('GET', KEYS[1]) or '0')   -- (1)
if current >= tonumber(ARGV[1]) then                          -- (2)
  return redis.call('DECRBY', KEYS[1], ARGV[1])               -- (3)
end
return -1                                                      -- (4)

(1) Read inside the script, so nothing can change between the read and the write. (2) The check that a client-side read-then-write could not do safely. (3) Decrement and return the new value. (4) A sentinel meaning "not enough", distinguishable from a real result because stock cannot be negative. The whole script occupies the single thread, so keep it short — a slow script blocks every other client, which is the same rule as section 1.

Redis 7's Functions are the same idea stored on the server with a name and version, so clients call FCALL instead of shipping script text.

7. Distributed locks, honestly

Redis is frequently used as a lock manager, and this is where the most confident wrong answers live.

The single-instance lock:

SET lock:job42 <random-token> NX PX 30000

NX means set only if absent; PX sets a 30-second expiry so a crashed holder does not hold it forever. The random token is not optional — releasing must be a Lua script that deletes the key only if the value still matches your token. Otherwise this happens: your work overruns the expiry, the lock auto-releases, another process takes it, and your DEL deletes their lock.

And the deeper problem does not go away. If your process pauses — a garbage-collection pause, a hypervisor stall, a network partition — the lock can expire while you still believe you hold it. Two processes then act as the holder simultaneously.

The Redlock algorithm acquires the lock on a majority of independent Redis instances. Martin Kleppmann's 2016 critique argued it does not provide the safety people assume, because it relies on bounded clock drift and bounded pauses, neither of which is guaranteed; Salvatore Sanfilippo replied. The useful conclusion from that exchange is the one both sides agree on:

  • For efficiency — "don't run this expensive job twice, usually" — a Redis lock is fine, and Redlock is more than most systems need.
  • For correctness — "this must never happen twice" — a lock alone is never enough. You need a fencing token: a monotonically increasing number issued with the lock, which the protected resource checks and rejects if it is lower than one it has already seen. Chapter 10.4 develops fencing properly.

The pragmatic rule: if the operation is idempotent or has a conditional write behind it (Chapter 7.4.1's WHERE stock >= 1), the lock is an optimisation and losing it is harmless. If correctness depends on the lock alone, the design is wrong.

8. Replication, Sentinel and Cluster

Replication is asynchronous. A replica receives a stream of writes and applies them. WAIT numreplicas timeout blocks until that many replicas acknowledge, which reduces but does not eliminate the risk of a failover losing writes.

Sentinel monitors a primary, agrees with other sentinels that it is down, promotes a replica and tells clients. Cluster shards keys across nodes by hashing into 16,384 hash slots, and handles both sharding and failover.

Two Cluster facts that decide application code:

Multi-key commands only work when the keys are in the same slot. MGET user:1 user:2 may fail with CROSSSLOT. The fix is a hash tag: only the part inside braces is hashed, so {user:42}:profile and {user:42}:cart land in the same slot deliberately.

Not everything survives sharding. Lua scripts must touch keys in one slot, and pub/sub semantics differ. If your code uses multi-key operations freely, adding Cluster is a rewrite rather than a configuration change.

9. Pub/sub, and where it ends

PUBLISH/SUBSCRIBE is fire-and-forget: a subscriber that is not connected at the moment of publishing never sees the message. There is no persistence, no acknowledgement, no replay.

That makes it right for exactly one class of problem — live fan-out where a missed message is acceptable: presence, typing indicators, telling every application instance to drop a cache key.

It is wrong for anything that must happen. If a job must run, use Streams with consumer groups, or a real broker. This is the single most common Redis misuse after KEYS *.

10. Operating one

Separate instances by role. Cache, sessions and queues want different eviction policies, different persistence and different blast radii. One instance for all three means one incident takes out all three.

Watch four things: used_memory against maxmemory, evicted_keys (non-zero on a session store is a bug), keyspace_hits/keyspace_misses for hit rate, and latency percentiles, because the single-threaded model makes p99 the number that reveals a slow command.

Namespace keysapp:env:entity:id — so SCAN MATCH can find a family and two systems cannot collide.

Never expose Redis to the internet. It has no meaningful authentication by default beyond a password, and unprotected instances are scanned for and compromised constantly. Bind to a private network, require a password, and enable TLS.

Rename or disable dangerous commands in production: KEYS, FLUSHALL, FLUSHDB, CONFIG.

What the interviewer will push on

"Why is Redis fast, and what does that cost?" In memory, and single-threaded command execution — which means no locks and every command atomic by construction. The cost is that one slow command blocks everything, so KEYS * is an outage, SCAN is the fix, and you scale by adding instances rather than cores.

"Which data structure would you use for a leaderboard?" A sorted set: ZADD to update, ZREVRANGE for the top N already ordered, ZRANK for a player's position in logarithmic time. Then generalise — priority queues, delayed jobs and rate limiters are all sorted sets with a timestamp as the score.

"How does Redis persist data, and can you trust it?" RDB snapshots (fast to load, lose everything since the last one, and the fork can nearly double memory) and AOF (everysec by default, losing up to a second). Then the honest line: replication is asynchronous, so a failover can lose writes, and anything whose loss is a correctness problem belongs in PostgreSQL.

"How would you implement a distributed lock?" SET key token NX PX ttl, release with a Lua script that checks the token — otherwise you delete someone else's lock after your work overruns. Then state the limit: if a pause makes the lock expire while you still think you hold it, two holders exist, so correctness needs a fencing token checked by the resource. A lock alone is an efficiency optimisation.

"What eviction policy would you set?" It depends on what is stored. allkeys-lru or allkeys-lfu for a pure cache, noeviction for sessions, volatile-* for a mix — and then the better answer: do not mix, because silently evicting a session logs a user out with no error anywhere.

"When is Redis pub/sub the wrong tool?" Whenever the message must arrive. It is fire-and-forget with no persistence, acknowledgement or replay, so a disconnected subscriber misses it permanently. Use Streams with consumer groups, or a broker.

One thing to volunteer: mention hash tags in Redis Cluster — {user:42}:profile and {user:42}:cart hash to the same slot so multi-key commands still work. It is the detail that decides whether adding Cluster later is a configuration change or a rewrite, and almost nobody raises it unprompted.

Recall

  • Redis is in memory and single-threaded for command execution: no locks, every command atomic, and one slow command blocks everything. KEYS * is an outage; use SCAN, and UNLINK for big values. Scale by instances, not cores.
  • Reach past strings. Sorted sets are the workhorse — leaderboards, priority queues, delayed jobs and rate limiters are all a sorted set with a timestamp score. Hashes let you touch one field; bitmaps and HyperLogLog answer counting questions in kilobytes.
  • Streams give acknowledgements and consumer groups; lists do not, so a crashed worker loses the job.
  • Expiry is lazy plus sampled-active, so memory does not drop the instant keys expire. KEEPTTL stops a write clearing an expiry.
  • Eviction policy follows the contents: allkeys-lru/lfu for cache, noeviction for sessions, volatile-* for mixed — and separate instances instead of mixing, because an evicted session logs a user out silently.
  • RDB snapshots lose everything since the last one and can nearly double memory during the fork; AOF everysec loses up to a second. Replication is asynchronous. Anything whose loss is a correctness problem belongs in PostgreSQL.
  • MULTI/EXEC gives isolation with no rollback; WATCH adds optimistic concurrency; Lua scripts are the tool when you must read, branch and write atomically — and must stay short.
  • A Redis lock is SET … NX PX released by a token-checking Lua script. It is an efficiency tool; correctness needs a fencing token. In Cluster, use hash tags {user:42}:… or multi-key commands fail with CROSSSLOT.

Self-test: Why does one slow command matter more in Redis than in PostgreSQL? · Which structure gives a leaderboard, a delayed queue and a rate limiter? · What exactly do you lose with appendfsync everysec plus async replication? · Why must a lock release check a token? · When is pub/sub the wrong choice? · What do hash tags fix?

Next: 7.7 covers the other structure every application eventually needs — the inverted index behind search, why LIKE '%term%' is not search, and how relevance is actually scored.