NoSQL · 02

Redis In Depth

Redis is a data structure server that lives in memory. This page builds the model from the ground up: the single command thread and why it matters, each data type with real commands and complexity, expiry and eviction, what RDB and AOF can and cannot save, replication, Sentinel and Cluster, transactions and scripts, streams versus Pub/Sub, the lock and rate-limiter patterns, memory and performance, the licence and Valkey situation, Spring Data Redis with Lettuce, and a hands-on session in Docker.

13
Chapters
7.4 / 8.10
Versions run
6
Diagrams
Valkey
Compared
01
Chapter One - What It Is

An In-Memory Data Structure Server

Redis (REmote DIctionary Server) is a server that keeps data in memory and exposes it as data structures - strings, hashes, lists, sets, sorted sets, streams and more - through a simple command protocol (RESP). That is the key difference from a plain key-value cache: the server understands the structure of the value, so ZINCRBY leaderboard 10 asha or HINCRBY user:42 logins 1 is a single atomic server-side operation instead of a read-modify-write across the network.

Where Redis time goes: one command thread, helpers around it
Clients (thousands of sockets) Redis server process Command thread parse, execute, reply Optional I/O threads (io-threads) Data in RAM (dicts, skiplists) commands Background threads lazy free, fsync, close fork() child RDB / AOF rewrite Disk dump.rdb, appendonlydir Because commands run one at a time, each command is atomic: no locks, no torn reads. The price: one slow command (KEYS, a huge SMEMBERS, a long Lua script) delays EVERY other client. Fast because: data is in RAM, structures are simple, no locking, an event loop (epoll) multiplexes sockets, and most requests are network-bound: a round trip costs far more than the command itself. Command execution has been single-threaded by design; threads are for I/O and housekeeping around it.

The execution model explains most of Redis's behaviour. A single thread executes commands one after another, so every command (and every MULTI/EXEC block or Lua script) is atomic with respect to the others. Since Redis 4.0 some work runs in background threads (for example freeing large objects with UNLINK, closing files and fsync), and since Redis 6 you can enable I/O threads (io-threads) that read and write sockets in parallel while command execution stays on the main thread. In the Redis 7.4.12 and 8.10.3 Docker images used for this page CONFIG GET io-threads returned 1, which means no extra I/O threads unless you configure them. Valkey 9.1 advertises a redesigned I/O threading model on valkey.io; check the docs of your version before tuning this.

QuestionRedis answerConsequence
Where is the data?In RAM; disk only for persistenceDataset must fit in memory (or you evict). Latency is microseconds on the server side; the network dominates
What is a value?A typed data structure with its own commandsPush work to the server: increments, set operations, range queries, rankings
How are commands executed?One at a time on one threadAtomic by construction. One slow command blocks everyone
How do I scale reads / data size?Replicas for reads and failover; Cluster to shard dataChapter 5
Is it durable?Optionally: RDB snapshots, AOF log, or bothDefault is snapshots only; you can lose recent writes (chapter 4)
What is it good at?Caches, sessions, counters, rate limits, leaderboards, queues and streams, locksAnything small, hot and structurally simple
What is it bad at?Large cold datasets, ad hoc queries, strong multi-key consistencyUse a database for the system of record; Redis in front of it
Which Redis? Licences and the Valkey fork

"Redis" now names more than one thing, and the licence matters if you ship or host it. Everything below was read from redis.io (the 2024-03-20 licensing blog post, the 2025-05-01 AGPLv3 post and the legal/licenses page) and valkey.io on 2026-10-31.

WhenWhat happenedSource
Redis up to 7.2.xBSD 3-clause open source licenceredis.io licenses page: "7.2.x and earlier remain subject to the BSD3 license"
2024-03-20Redis announced that, starting with Redis 7.4, it is dual-licensed under RSALv2 and SSPLv1 (source-available, not OSI open source) and no longer BSDredis.io blog "Redis Adopts Dual Source-Available Licensing"
March 2024Valkey, a Linux Foundation project, was started as a BSD fork "right before the transition" (the GitHub repository was created on 2024-03-22)Valkey README; GitHub repository metadata
2025-05-01Redis announced AGPLv3 as an additional option starting with Redis 8 (OSI-approved), and integrated JSON, Time Series, probabilistic types and the Query Engine into core Redis 8redis.io blog "Redis is now available under the AGPLv3 open source license"
Now (2026-10-31)Redis 8 is offered under a tri-licence (RSALv2, SSPLv1, AGPLv3). Valkey stays BSD; its latest release was 9.1.3 (2026-10-26)redis.io licenses page; valkey.io

What this means in practice: the wire protocol and the commands in this page work the same on Redis and on Valkey (the examples were run against redis:7 = 7.4.12, redis:8 = 8.10.3 and valkey/valkey:8/valkey/valkey:9). Managed services (ElastiCache, Memorystore, Azure) have their own offerings and their own version lists. New commands diverge: DELEX and SET ... IFEQ exist in Redis 8.4 and later (chapter 8), and each project adds features the other does not. Pin your client library, test on the engine you really deploy, and ask your legal team which licence terms apply if you redistribute or offer Redis as a service.

⚠ Treating "Redis" as one product

A tutorial written for Redis 8 (with JSON, search and vector sets built in) will not necessarily work on Redis 7.2, on Valkey, or on a cloud service pinned to an older engine. Check INFO server (redis_version, redis_mode) and MODULE LIST before assuming a feature exists.

02
Chapter Two - Data Types

Structures, Commands and What They Cost

A Redis key maps to a value of one type, and the type decides which commands work (WRONGTYPE Operation against a key holding the wrong kind of value is what you get otherwise). Because commands are executed one at a time on a single thread, the cost of a command is the cost everyone pays: know the complexity of what you call, especially the O(N) ones.

Choosing the structure: which command shape do you need?
String GET / SET / INCR counter, token, cached blob Hash HSET / HGET / HINCRBY object with fields List RPUSH / LPOP / BLPOP simple queue, recent items Set SADD / SISMEMBER / SINTER unique members, tags Sorted set ZADD / ZRANGE / ZRANK ranking, delay queue, windows Stream XADD / XREADGROUP / XACK durable log, consumer groups Bitmap SETBIT / BITCOUNT daily active flags HyperLogLog PFADD / PFCOUNT approx unique count, 12 KB Geo GEOADD / GEOSEARCH nearby places (sorted set inside) Rule of thumb: pick the structure whose commands do your work on the server, in one round trip.
TypeCore commandsTypical complexityGotcha
String (binary safe, up to 512 MB)SET GET INCR INCRBY APPEND SETEX MGETGET SET INCR O(1)A 100 MB string is allowed and is a latency spike each time it moves. Counters are strings holding integers
HashHSET HGET HMGET HINCRBY HGETALL HDELHGET HSET O(1); HGETALL O(N)EXPIRE covers the whole hash. Per-field expiry (HEXPIRE) exists from Redis 7.4 (worked on the 7.4.12 image); older engines lack it
ListLPUSH RPUSH LPOP RPOP LRANGE BLPOP LTRIMPush/pop at the ends O(1); LRANGE O(S+N); LINDEX O(N)Middle access is linear. LTRIM after LPUSH gives a capped "latest N" list
SetSADD SREM SISMEMBER SMEMBERS SINTER SUNION SPOPSADD SISMEMBER O(1); SMEMBERS O(N); SINTER O(N*M) worst caseSMEMBERS on a million members blocks the server; use SSCAN
Sorted setZADD ZINCRBY ZRANGE ZRANK ZREVRANGE ZREMRANGEBYSCORE ZCARDZADD ZRANK O(log N); ZRANGE O(log N + M)Ordered by score, then lexicographically by member. Scores are doubles: integers above 2^53 lose precision
StreamXADD XREAD XGROUP XREADGROUP XACK XAUTOCLAIM XTRIMXADD O(1) without trimming; reads O(N) in entries returnedGrows until you XTRIM or use MAXLEN on XADD (chapter 7)
Bitmap (string)SETBIT GETBIT BITCOUNT BITOP BITFIELDSETBIT O(1); BITCOUNT O(N)Offset 4 billion allocates 512 MB for one key. Keep offsets dense
HyperLogLogPFADD PFCOUNT PFMERGEO(1) per add; PFCOUNT O(1) on one keyUses up to 12 KB and has a 0.81 % standard error. Cannot list members or delete one
Geo (sorted set)GEOADD GEODIST GEOSEARCHGEOADD O(log N) per item; GEOSEARCH O(N+log M)Longitude comes before latitude in every command

Redis picks a compact internal encoding for small values and switches to a general one as they grow. You can see it with OBJECT ENCODING. This is why a hash with five fields costs a few hundred bytes while a million of them still fits: small hashes, sets and sorted sets use a listpack, a flat array scanned linearly, and convert to a hash table or skiplist past configurable limits.

redis-cli - a real session on redis:7 (Redis 7.4.12), abridged to the interesting lines
127.0.0.1:6379> HSET user:42 name Asha plan pro logins 3 (integer) 3 127.0.0.1:6379> HINCRBY user:42 logins 1 (integer) 4 127.0.0.1:6379> ZADD board 1500 asha 900 ravi 2100 mei (integer) 3 127.0.0.1:6379> ZINCRBY board 700 ravi "1600" 127.0.0.1:6379> ZREVRANGE board 0 1 WITHSCORES 1) "mei" 2) "2100" 3) "asha" 4) "1500" 127.0.0.1:6379> ZREVRANK board ravi (integer) 1 127.0.0.1:6379> PFADD visitors u1 u2 u3 u1 (integer) 1 127.0.0.1:6379> PFCOUNT visitors (integer) 3 127.0.0.1:6379> GEOADD shops 77.5946 12.9716 blr-central 77.6408 12.9784 indiranagar (integer) 2 127.0.0.1:6379> GEODIST shops blr-central indiranagar km "5.0647" 127.0.0.1:6379> OBJECT ENCODING board "listpack" 127.0.0.1:6379> OBJECT ENCODING page:views "int"

JSON, search and more. Redis 8 integrates JSON, Time Series, probabilistic data types (Bloom filter and friends) and the Query Engine into core Redis; MODULE LIST on the 8.10.3 image showed search, bf and timeseries loaded. On Redis 7.x or Valkey these come from separate modules or are absent. Because that depends on the engine, this page sticks to the core types.

Hash versus JSON string for objects
  • A hash lets you read or increment one field (HINCRBY user:42 logins 1) without touching the rest, and is compact for flat objects. A JSON string (SET user:42 {...}) is the simplest for nested objects you always read and write whole. Choose by access pattern, not fashion.
03
Chapter Three - Keys, Expiry, Eviction

Naming Keys and Keeping Memory Bounded

Designing keys

Keys are binary-safe strings, but treat them like an API. A good key is readable, namespaced, bounded and versioned.

GuidelineExampleWhy
Colon-separated namespacesorder:1042:itemsConvention, easy to grep and to scan with MATCH order:*
Put the entity id in the key, not the valuesession:7f3a... not a hash of all sessionsMany small keys can expire and be sharded independently; one giant key cannot
Prefix with the service or environmentcheckout:prod:cart:42Several apps often share one Redis; collisions are silent
Add a schema versionshop:v2:price:42Bump the version instead of deleting millions of keys when the value format changes
Keep keys short but not crypticu:42:p is false economyKey bytes are memory, but 20-40 byte keys are fine; readability saves incidents
Put a hash tag only where you need co-location{user:42}:cart, {user:42}:profileIn Cluster, keys sharing the tag land in one slot (chapter 5)
Never build keys from unbounded user inputsearch: + raw queryAttackers fill memory; hash or length-limit the input
Expiry

EXPIRE key seconds (or SET key v EX seconds, PEXPIRE for milliseconds, EXPIREAT for a timestamp) attaches a time to live. TTL returns the remaining seconds, -1 if the key has no expiry and -2 if the key does not exist. PERSIST removes the TTL. **Overwriting a key with SET clears its TTL** unless you pass KEEPTTL.

redis-cli - real output
127.0.0.1:6379> SET s1 v EX 100 OK 127.0.0.1:6379> TTL s1 (integer) 100 127.0.0.1:6379> PERSIST s1 (integer) 1 127.0.0.1:6379> TTL s1 (integer) -1 127.0.0.1:6379> TTL nokey (integer) -2

Expiry is lazy plus active. Lazy: when a command touches an expired key Redis deletes it and behaves as if it never existed. Active: Redis periodically tests a few keys at random among those with a TTL and deletes the expired ones, so memory is reclaimed even for keys nobody reads. A consequence worth knowing: INFO keyspace shows keys=17,expires=2,avg_ttl=..., and a key whose time has passed can still occupy memory for a short while until a cycle or an access removes it. Replicas do not expire keys themselves: they wait for the DEL from the primary.

Eviction policies (maxmemory-policy)

With maxmemory 0 (the default) Redis uses all memory the OS will give it. In production set maxmemory below the machine or container limit (leave room for replication buffers, fork copy-on-write and fragmentation) and pick a policy. All eight names below were accepted by CONFIG SET maxmemory-policy on the Redis 7.4.12 image (an invalid name returns the list), and the default is noeviction.

PolicyEvictsUse when
noeviction (default)Nothing; writes that need memory fail with an OOM error, reads still workRedis is a database or queue: losing data is worse than rejecting writes
allkeys-lruLeast recently used key among all keysPure cache with recency-biased access. The usual first choice
allkeys-lfuLeast frequently used key among all keysPure cache where a few keys are very hot for a long time
allkeys-randomA random keyAll keys equally likely to be reused
volatile-lruLRU among keys that have a TTLMixed use: cache keys carry a TTL, durable keys do not
volatile-lfuLFU among keys that have a TTLSame, frequency based
volatile-randomRandom among keys with a TTLRare
volatile-ttlKeys with the shortest remaining TTLYou encode importance in the TTL

The volatile-* policies behave like noeviction when no key has a TTL, a classic way to get OOM errors in a "cache" where someone forgot to set expiries. **Newer Redis 8.x lines add two more names, allkeys-lrm and volatile-lrm (least recently modified)**: they were accepted by Redis 8.10.3 and rejected by 7.4.12 in the test for this page, and appear on the current eviction docs page. Do not rely on them if you run 7.x or Valkey.

LRU and LFU are approximated: Redis samples maxmemory-samples keys (default 5) and evicts the best candidate among them, which is cheap and close to exact LRU. Raising the sample size is more precise and costs CPU.

redis-cli - real eviction run (server started with --maxmemory 3mb --maxmemory-policy allkeys-lru)
$ docker run -d --name re redis:7 redis-server --maxmemory 3mb --maxmemory-policy allkeys-lru # 4000 x SET key:N <1000 bytes> $ docker exec re redis-cli INFO stats | grep evicted_keys evicted_keys:2040 $ docker exec re redis-cli DBSIZE (integer) 1937 $ docker exec re redis-cli CONFIG SET maxmemory-policy noeviction OK $ docker exec re sh -c 'echo "SET another <1000 bytes>" | redis-cli' OOM command not allowed when used memory > 'maxmemory'.
Expiry and eviction are two different mechanisms
Expiry: the key has a TTL and its time is up SET session:9 ... EX 1800 Lazy: a command touches the key, Redis sees it is expired, deletes it Active: periodically (rate set by hz, default 10) Redis samples keys with a TTL and deletes expired ones and Eviction: memory is full (maxmemory) and a write arrives used_memory reaches maxmemory maxmemory-policy = noeviction? writes fail: OOM command not allowed other policy: sample keys (maxmemory-samples) and evict the best candidate until under the limit else Policy name = scope + rule. allkeys-* may evict any key; volatile-* only keys that have a TTL. Rule: lru (least recently used), lfu (least frequently used), random, ttl (shortest remaining life, volatile only). Real run (maxmemory 3mb, allkeys-lru, 4000 writes of 1000 bytes): evicted_keys = 2040, DBSIZE = 1937. Same server switched to noeviction: SET fails with OOM command not allowed when used memory > maxmemory. LRU/LFU are approximations: Redis samples a few keys (maxmemory-samples, default 5) instead of tracking all.
⚠ A cache without a TTL or a policy
  • Symptom: used_memory climbs for days, then either the OOM killer ends the process (no maxmemory) or every write fails with OOM command not allowed (noeviction).
  • Fix: always set maxmemory and either a TTL on every cache key or an allkeys-* policy. Watch evicted_keys and expired_keys in INFO stats.
04
Chapter Four - Persistence

RDB Snapshots, the AOF and What You Can Lose

Redis is memory first, but it can write to disk so that a restart does not start from empty. There are two mechanisms, usable alone or together, and none of them makes Redis a fully ACID database.

RDB snapshotAOF (append-only file)
What is storedCompact point-in-time copy of the dataset (dump.rdb)The write commands, replayed on restart. Since Redis 7.0 a multi-part AOF: a base file plus incremental files in a directory, tracked by a manifest
How it is writtenfork(): a child writes the file while the parent keeps serving (copy-on-write)Parent appends each write to the log; a background rewrite compacts it
Loss window after a crashEverything since the last snapshot (docs: "be prepared to lose the latest minutes of data")With appendfsync everysec: at most about one second of writes
Restart speedFaster for big datasetsSlower (replay), mitigated by the RDB preamble
File sizeSmallerLarger, until rewritten
CostA fork per snapshot can pause the server for milliseconds, up to about a second on a very large instancefsync policy trades speed for safety; rewrite also forks
Best forBackups, fast restarts, caches you can rebuildWhen you want to lose at most about a second
Defaults (checked on redis:7 = 7.4.12 with CONFIG GET)
SettingDefault observedMeaning
save3600 1 300 100 60 10000Snapshot after 1 hour if >=1 change, after 5 min if >=100 changes, after 1 min if >=10000 changes
appendonlynoAOF is off by default. Out of the box Redis is snapshot-only
appendfsynceverysecUsed when AOF is turned on; the docs call this the suggested and default policy
aof-use-rdb-preambleyesAOF rewrite starts the base file as an RDB, so restarts load faster
auto-aof-rewrite-percentage / -min-size100 / 67108864 (64 MB)Rewrite when the AOF doubles and is at least 64 MB
Persistence: what survives a crash
RDB: snapshot at intervals snapshot snapshot writes here live only in memory + replicas crash: lose everything since the last snapshot AOF: log every write command SET a 1 INCR n HSET u f v ... (appended in order) fsync policy always : fsync before reply everysec : fsync once a second (default) no : the OS decides Loss window on a hard crash: RDB: minutes (since last snapshot) AOF everysec: about 1 second AOF always: ~ the commands not yet acknowledged And replicas are asynchronous: a failover can lose acknowledged writes even with perfect disks (chapter 5).

The three appendfsync values: **always fsyncs before replying (very safe, much slower; concurrent writes in a pipeline or from several clients are grouped into one fsync), everysec fsyncs from a background thread once a second, and no leaves it to the OS (Linux typically flushes every 30 seconds, kernel dependent). The Redis docs recommend using both RDB and AOF** if you want data safety "comparable to what PostgreSQL can provide", RDB alone if you can accept minutes of loss, and no persistence at all for a pure cache.

⚠ "We enabled persistence, so Redis is our database"
  • Even with appendfsync always, a Redis primary that fails over to an asynchronous replica can lose writes the client saw acknowledged. Persistence protects against restart; it does not give you synchronous multi-node durability.
  • A fork on a big instance needs copy-on-write memory. If the machine has no spare memory, the snapshot or rewrite can fail or push the box into swap. Leave headroom (chapter 9).
  • Turning AOF on by editing the config and restarting can lose data (the server then loads an empty AOF). Follow the documented procedure: enable it live with CONFIG SET appendonly yes, wait for the rewrite to finish, then persist the setting.
Choosing quickly
  • Pure cache: no persistence (or RDB only, to restart warm). Session store or rate limits you can afford to lose a second of: AOF everysec. Primary store (rare): AOF everysec or always plus RDB backups, replicas, and an honest statement of the loss you accept.
05
Chapter Five - Replication and Scaling

Replicas, Sentinel and Cluster

Replication

A replica connects to a primary, receives a snapshot, and then a continuous stream of write commands. Replication is asynchronous by default: the primary acknowledges your write to you without waiting for any replica. That keeps latency low and means a primary crash can lose the last writes that never reached a replica.

  • REPLICAOF host port makes a node a replica (in redis.conf as replicaof). Replicas are read-only by default; reading from them can return stale data (replication lag).
  • WAIT numreplicas timeout blocks the calling connection until the replicas have acknowledged the preceding writes of that connection, or the timeout (milliseconds) passes. It returns how many replicas acknowledged. It narrows the loss window; it does not turn Redis into a strongly consistent store, because a failover can still elect a replica that missed something.
  • A replica that reconnects after a short outage can often do a partial resynchronisation from the primary's replication backlog instead of a full copy.
Two ways to survive failures: Sentinel and Cluster
Redis SentinelRedis Cluster
GoalHigh availability for a single dataset: monitoring, failure detection, automatic failover, configuration providerSharding across primaries plus failover
Data sizeLimited to one primary's memorySum of the primaries
Topology1 primary + replicas, watched by 3 or more Sentinel processes3 or more primaries, each ideally with a replica
How clients find the primaryAsk Sentinel (SENTINEL get-master-addr-by-name), the client library does itCluster-aware client keeps a slot map; follows MOVED
Failover decisionA quorum of Sentinels agrees the primary is unreachable; a majority authorises the failoverCluster nodes vote; a replica of the failed primary is promoted
Multi-key operationsUnrestrictedOnly within one slot (CROSSSLOT otherwise); hash tags co-locate keys
DatabasesNumbered databases workOnly database 0
Pick it whenThe dataset fits one machine and you want automatic failoverThe dataset or throughput outgrows one machine
Redis Cluster: 16384 hash slots spread over primaries
slot = CRC16(key) mod 16384 (only the text inside {...} is hashed when a hash tag is present) Primary A slots 0-5460 replica Primary B slots 5461-10922 replica Primary C slots 10923-16383 replica Client GET foo 1. sends to B B does not own slot 12182 replies -MOVED 12182 C:6379 2. retry on C C answers; client caches the slot map Real CLUSTER KEYSLOT results: foo = 12182 user:1 = 10778 {user:1}:cart = 10778 {user:1}:profile = 10778 -> same slot, so MSET / MULTI / Lua across them work. MSET user:1:name a user:2:name b -> CROSSSLOT Keys in request do not hash to the same slot While a slot is migrating the owner may answer -ASK (one-off redirect, send ASKING first). Cluster supports only database 0; SELECT 1 returns: ERR SELECT is not allowed in cluster mode (real output).

Hash slots. The key space is divided into 16384 slots; a key's slot is CRC16(key) mod 16384. Each primary owns a range of slots. When a client sends a command to a node that does not own the key's slot, the node replies **MOVED slot host:port (a permanent redirect: update your slot map). While a slot is being migrated, the node can reply ASK**, a one-time redirect to the target, which the client must precede with ASKING. Good clients (Lettuce, Jedis, redis-py cluster mode) do all of this for you.

Hash tags. If a key contains {...} with at least one character inside, only that part is hashed. {user:1}:cart and {user:1}:profile land in the same slot, so MULTI/EXEC, Lua scripts and multi-key commands over them work. The trade-off: if you tag everything with the same value you have built one hot shard.

⚠ Cluster surprises
  • Multi-key commands across slots fail (CROSSSLOT). MGET, SUNION, RENAME, Lua scripts and transactions all need their keys in one slot.
  • **No SELECT.** Code that uses several logical databases must be changed.
  • Hot keys and big keys do not shard. One key lives on one primary no matter how large the cluster is.
  • By default the cluster stops accepting writes if any slot has no owner (cluster-require-full-coverage yes was the observed default). Decide deliberately whether you prefer partial availability.
06
Chapter Six - Transactions and Scripts

MULTI/EXEC, WATCH, Lua and Functions

Single commands are atomic. When you need several to run as one unit, Redis offers three tools, each with a different guarantee.

ToolWhat you getWhat you do not get
MULTI ... EXECCommands are queued, then executed sequentially without any other client's command in between (isolated). If the connection drops before EXEC, none runNo rollback. If one command fails at run time the others still execute
WATCH key + MULTI/EXECOptimistic locking (check-and-set): EXEC aborts and returns nil if a watched key changed since WATCHYou must retry in a loop; heavy contention means many retries
Lua script (EVAL / EVALSHA)Reads, decisions and writes in one atomic server-side step, one round tripA slow script blocks the whole server. In Cluster all keys must be passed in KEYS and share a slot
Functions (FUNCTION LOAD, FCALL)Server-side code that is part of the dataset (persisted and replicated) rather than sent by each clientSame blocking caveat as scripts
redis-cli - real output: MULTI/EXEC has no rollback
127.0.0.1:6379> MULTI OK 127.0.0.1:6379(TX)> INCR a QUEUED 127.0.0.1:6379(TX)> LPUSH a x QUEUED 127.0.0.1:6379(TX)> INCR b QUEUED 127.0.0.1:6379(TX)> EXEC 1) (integer) 1 2) (error) WRONGTYPE Operation against a key holding the wrong kind of value 3) (integer) 1 127.0.0.1:6379> GET a "1" 127.0.0.1:6379> GET b "1"

LPUSH a x failed at execution time because a is a string (the first INCR created it), yet the commands before and after it took effect. The docs state plainly that Redis does not support rollbacks because it would hurt simplicity and performance. A command with a syntax error is rejected at queue time and the whole transaction is discarded; a run-time error is not rolled back.

redis-cli - optimistic locking with WATCH (sketch, not a real run)
WATCH balance:42 GET balance:42 # read the current value in the client MULTI SET balance:42 <new value computed in the client> EXEC # returns (nil) if balance:42 changed after WATCH: retry from the top

For anything with logic (read, decide, write) a Lua script is usually simpler than WATCH retries. The sliding-window rate limiter in chapter 8 is exactly that. Keep scripts short: while one runs, nothing else does.

Transactions are not what a relational database means by the word
  • Redis transactions give isolation (no interleaving) and all-or-nothing queueing, not rollback and not durability beyond your persistence settings. If you need "debit A, credit B, or neither", model it with idempotent steps or use a database.
07
Chapter Seven - Messaging in Redis

Pub/Sub Versus Streams

Redis offers three message-like tools. Choosing the wrong one is a common source of lost messages.

Pub/SubList (LPUSH + BRPOP)Stream (consumer groups)
Persistence of messagesNone. Fire and forgetUntil poppedUntil trimmed; survives restarts if persistence is on
Delivery semanticsAt most once (the docs: "if at all")At most once once popped (a crashed worker loses the item)At least once with XACK and redelivery
Many consumers each get every message?Yes (fan-out)No, competing consumersYes, one group per consumer service; inside a group they compete
Replay historyNoNoYes: read from any id
Track who has not finishedNoNoYes: the pending entries list (XPENDING)
Typical useCache invalidation hints, presence, live dashboards where loss is fineVery simple job queueEvent pipelines, task queues that must not lose work
Streams with a consumer group: durable, tracked, redeliverable
Stream orders 1-0 2-0 3-0 4-0 5-0 XADD appends. Entries stay until XTRIM / MAXLEN. Group billing last-delivered-id = 3-0 PEL (pending entries list) 2-0 -> worker-1 3-0 -> worker-2 worker-1 worker-2 worker-3 (new) XREADGROUP > XACK billing orders 2-0 removes it from the PEL (processed). If worker-2 dies, 3-0 sits in the PEL: another consumer takes it over with XAUTOCLAIM after an idle time. Each group has its own cursor and PEL: billing and shipping read the same stream independently. XPENDING shows who holds what and for how long. Delivery is at-least-once: make handlers idempotent. Real run: after XREADGROUP COUNT 1, XPENDING orders billing returned 1, first id, last id, worker-1 -> 1. Pub/Sub, by contrast, keeps nothing: a subscriber that is offline or slow misses the message for good.
redis-cli - real stream session
127.0.0.1:6379> XADD orders * orderId o-1 total 10 "1793468895794-0" 127.0.0.1:6379> XADD orders * orderId o-2 total 25 "1793468896199-0" 127.0.0.1:6379> XGROUP CREATE orders billing 0 OK 127.0.0.1:6379> XREADGROUP GROUP billing worker-1 COUNT 1 STREAMS orders > 1) 1) "orders" 2) 1) 1) "1793468895794-0" 2) 1) "orderId" 2) "o-1" 3) "total" 4) "10" 127.0.0.1:6379> XPENDING orders billing 1) (integer) 1 2) "1793468895794-0" 3) "1793468895794-0" 4) 1) 1) "worker-1" 2) "1"

The workflow is XGROUP CREATE once, then each worker loops XREADGROUP GROUP g worker COUNT n BLOCK ms STREAMS key >, processes, and calls XACK. Entries read but never acknowledged stay pending; XAUTOCLAIM key group consumer min-idle-time start moves entries idle longer than the threshold to a live consumer. Cap growth with XADD key MAXLEN ~ 100000 * ... or XTRIM.

Compared with Kafka: a Redis stream is a log with consumer groups, like a Kafka partition, but it lives in one node's memory, has no partitions of its own (shard by using several keys), and retention is whatever fits in RAM. It is excellent for moderate event volumes, task queues and "I already run Redis". For very large retained logs, many partitions, long replay, a connector ecosystem and stream processing, use a log platform. See Kafka architecture and the Messaging map to decide.
⚠ Using Pub/Sub for work that must happen
  • A subscriber that restarts, lags beyond its output buffer limit, or is not yet subscribed misses the message. Symptom: "the email was sometimes never sent". Use a stream with a consumer group and idempotent handlers.
08
Chapter Eight - Patterns

Cache, Sessions, Rate Limits, Locks, Leaderboards, Idempotency

Cache-aside and session store

The default caching pattern: the application reads the cache, on a miss reads the database, then writes the cache with a TTL (SET product:42 <json> EX 600). All the difficult parts - stampedes, penetration, invalidation races, sizing - are the subject of page 06, Caching Patterns; the Spring annotation side is in Spring: Caching and Resilience.

A session store is the same idea with a TTL that slides: SET session:<id> <data> EX 1800, and EXPIRE session:<id> 1800 on each request (or GETEX session:<id> EX 1800 to read and extend in one command). Because Redis is shared, the app servers stay stateless. Decide what a lost session costs: a forced login is usually acceptable, which is why a snapshot-only Redis is a common choice.

Rate limiter: fixed window

The simplest limiter counts requests per user per window. The first INCR creates the key, so that is when you set the expiry. The weakness is the boundary: a client can send the limit at 11:59:59 and again at 12:00:00, doubling the rate for a moment.

Lua - fixed window (run with EVAL; atomic, so INCR and EXPIRE cannot be separated by a crash between them)
local c = redis.call('INCR', KEYS[1]) if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end return c
Rate limiter: sliding window log with a sorted set

Keep one sorted set per user, members are request ids and scores are timestamps. For each request: remove entries older than the window, count what is left, add this request if under the limit. This is exact, at the cost of memory proportional to the limit. The script below was run for real (limit 3, window 60 s, timestamps 1100 to 1400 ms, then 70000 ms).

Lua - sliding window log (sw.lua, tested on redis:7)
-- KEYS[1] = zset key, ARGV[1] = now (ms), ARGV[2] = window (ms), ARGV[3] = limit, ARGV[4] = unique member redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2]) if redis.call('ZCARD', KEYS[1]) < tonumber(ARGV[3]) then redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4]) redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end return 0
Shell - real output, four requests inside the window and one after it
$ docker cp sw.lua r7:/sw.lua $ docker exec r7 redis-cli --eval /sw.lua rl:user:42 , 1100 60000 3 req-1 (integer) 1 $ docker exec r7 redis-cli --eval /sw.lua rl:user:42 , 1200 60000 3 req-2 (integer) 1 $ docker exec r7 redis-cli --eval /sw.lua rl:user:42 , 1300 60000 3 req-3 (integer) 1 $ docker exec r7 redis-cli --eval /sw.lua rl:user:42 , 1400 60000 3 req-4 (integer) 0 $ docker exec r7 redis-cli --eval /sw.lua rl:user:42 , 70000 60000 3 req-5 (integer) 1

The script takes now as an argument instead of calling TIME inside, which keeps the example deterministic; in production pass the client clock or call redis.call('TIME'). In Cluster the single key rl:user:42 is fine. A cheaper approximation is the sliding window counter (two fixed-window counters weighted by overlap), and a smoother alternative is a token bucket kept in a hash with a script. Pick by how exact the limit must be.

Distributed lock

A lock in Redis is a key that exists only while someone holds it: SET lock:order:42 <random token> NX PX 30000. NX means set only if absent, PX gives it a lifetime so a crashed owner cannot hold it forever. The token identifies the owner, so that release is conditional: delete only if the value still equals my token. A plain DEL could delete somebody else's lock after yours expired.

redis-cli - real output of acquire, failed acquire, wrong-owner release, release
127.0.0.1:6379> SET lock:order:42 tokenA NX PX 30000 OK 127.0.0.1:6379> SET lock:order:42 tokenB NX PX 30000 (nil) 127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order:42 tokenB (integer) 0 127.0.0.1:6379> EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order:42 tokenA (integer) 1

On Redis 8.4 and later the compare-and-delete script is built in: DELEX key IFEQ value deletes the key only if its value equals the argument. It returned 0 for a wrong token and 1 for the right one on the 8.10.3 image, and SET key value IFEQ expected (conditional overwrite, same version) also worked. On 7.x and Valkey use the Lua script.

A lock that expires while its owner is paused (why fencing tokens exist)
Client A Redis lock key Client B Storage SET NX PX 10s lock = A, token 33 GC pause / stall ... TTL expires SET NX ok, token 34 write(token 34) A wakes, writes write(token 33) Storage remembers the highest token seen (34) and rejects 33. Without it, both writes land.
What this lock does and does not guarantee
  • Efficiency locks ("avoid doing the same job twice"): fine. If the lock occasionally fails, the cost is duplicate work.
  • Correctness locks ("two writers must never both modify this"): a TTL lock cannot guarantee it alone. The owner can be paused (garbage collection, a slow network call, a suspended VM) past the TTL; the lock expires; a second client acquires it; the first wakes up and still believes it holds the lock, exactly as in the diagram. Clocks and failovers add further ways to break mutual exclusion: Redis replication is asynchronous, so a primary can crash after granting a lock and before the replica sees it, and the promoted replica grants the same lock again (the Redis docs make this very point).
  • Fencing tokens fix the dangerous case: the lock service hands out an increasing number with each grant, the client sends it with every write, and the resource rejects writes with a token lower than one it has already seen. This needs support in the protected resource (a database column, a conditional update such as WHERE version = ?). Redis can generate tokens with INCR, but the check must happen where the data lives.
Redlock, stated fairly

Redlock is the algorithm in the Redis documentation for locking across several independent Redis primaries (typically five): acquire on a majority within a time limit shorter than the lock validity, and treat the lock as held for the remaining validity. On 8 February 2016 Martin Kleppmann published an analysis ("How to do distributed locking") arguing that Redlock is unsafe for correctness locks because it depends on timing assumptions (bounded pauses, bounded clock drift) and has no fencing token; Salvatore Sanfilippo (antirez) answered the next day ("Is Redlock safe?"), defending the design and arguing the assumptions are reasonable. The Redis lock documentation itself links both articles, tells you that if you care about correctness you should implement fencing tokens, and presents Redlock as the algorithm to "model" its guarantees. The honest summary: Redlock is reasonable for efficiency locks; for correctness, rely on fencing or on a system built for consensus (for example a lock or lease in ZooKeeper, etcd or your database). Read both articles and decide for your risk.

⚠ Common lock bugs
  • Using a fixed value ("locked") instead of a unique token, then releasing someone else's lock.
  • Doing SETNX then EXPIRE as two commands: a crash between them leaves a lock that never expires. Use one SET ... NX PX.
  • A TTL shorter than the work, with no renewal or fencing. Symptom: rare duplicate processing under load or after a GC pause.
  • Treating "I got the lock" as proof of exclusivity after a long pause.
Leaderboard and idempotency keys

A leaderboard is a sorted set: ZINCRBY lb:weekly 10 asha on each score event, ZREVRANGE lb:weekly 0 9 WITHSCORES for the top ten, ZREVRANK lb:weekly asha for one player's position (all O(log N) or O(log N + M)). The session in chapter 2 shows real output. Reset a period by writing to a new key (lb:2026-W44) and letting the old one expire.

An idempotency key makes a retried request safe: SET idem:<client key> <result or "in-progress"> NX EX 86400. If SET returns OK you are the first and you process the request; if it returns nil, the request is a duplicate and you return the stored result or a "being processed" answer. Store the final response under the key once finished. Redis alone is not enough if the business effect is in another database: make the effect itself idempotent as well (see Messaging fundamentals).

09
Chapter Nine - Memory and Performance

What Makes Redis Slow, and What To Watch

Memory
TopicWhat to knowHow to look
Per-key overheadEvery key has bookkeeping; millions of tiny keys cost more than their payload. Small hashes use compact encodingsMEMORY USAGE key (real: 96 bytes for a 3-field hash, 56 for an integer string)
Big keysA hash, list or set with millions of entries (or a string of hundreds of MB) makes DEL, replication and failover slow, and in Cluster cannot be splitredis-cli --bigkeys (it samples with SCAN), MEMORY USAGE key SAMPLES 0
Deleting big keysDEL frees memory in the command thread; UNLINK hands the freeing to a background threadPrefer UNLINK for large keys
FragmentationThe allocator (jemalloc) cannot always return freed pages; RSS exceeds used_memoryINFO memory: mem_fragmentation_ratio. Well above 1.5 deserves a look; below 1 means swapping
Fork headroomSnapshots and rewrites rely on copy-on-write; a write-heavy instance can need extra memory while the child runsKeep spare memory; check INFO persistence and latency around BGSAVE
BuffersClient output buffers, replication backlog and AOF buffers count against the machine, and some count against maxmemoryINFO clients, client-output-buffer-limit
Shell - real --bigkeys output on the eviction test server (abridged)
$ docker exec re redis-cli --bigkeys Sampled 1915 keys in the keyspace! Total key length in bytes is 15294 (avg len 7.99) Biggest string found "key:3635" has 1000 bytes 0 lists with 0 items (00.00% of keys, avg size 0.00) 0 hashs with 0 fields (00.00% of keys, avg size 0.00) 1915 strings with 1915000 bytes (100.00% of keys, avg size 1000.00)
Performance rules
RuleWhyInstead
Do not use KEYS pattern in productionO(N) over the whole keyspace in the command thread: every client waitsSCAN cursor MATCH pattern COUNT n, O(1) per call, incremental. It may return a key more than once and does not see a consistent snapshot, so de-duplicate
Avoid O(N) commands on large collectionsHGETALL, SMEMBERS, LRANGE 0 -1, ZRANGE 0 -1 on big keys block everyoneHSCAN, SSCAN, ZSCAN, pagination with LRANGE/ZRANGE windows
Pipeline independent commandsA round trip costs far more than executing a command; N commands in one batch pay the latency onceClient pipelining (Lettuce does it automatically for concurrent async calls; executePipelined in Spring)
Use MGET/HMGET/MSETFewer round tripsBut in Cluster the keys must share a slot
Keep scripts shortA script blocks the server for its whole runMove loops and heavy logic out
Watch for hot keysOne key = one thread = one shardLocal (in-process) L1 cache in front, key replication with suffixes (page 06)
Set client timeouts and pool sizesA stalled Redis plus no timeout exhausts application threadsTimeouts on connect and command, circuit breaker around Redis calls

Tools to look: SLOWLOG GET 10 (commands slower than slowlog-log-slower-than, default 10000 microseconds), LATENCY DOCTOR, INFO commandstats, redis-cli --latency, and MONITOR (only briefly: it slows the server).

⚠ Common mistakes, summarised
  • KEYS * in a health check or admin script. One run on a large instance caused every client call to time out.
  • Caching without TTL or maxmemory: slow memory leak until OOM.
  • Serialising with the JDK default and storing class names: unreadable keys and values, breaks across deployments (chapter 10).
  • One giant hash or set "for convenience": big-key latency, impossible to shard.
  • Assuming a replica read is current; assuming WAIT makes writes durable.
  • Putting Redis in the request path with no timeout, no fallback and no circuit breaker.
  • Storing the system of record in a cache-configured Redis (allkeys-lru, no AOF).
10
Chapter Ten - Spring Data Redis

Lettuce, Templates, Serializers and RedisCacheManager

Spring Boot's spring-boot-starter-data-redis brings Spring Data Redis and the Lettuce client by default (Netty based, thread-safe, one shared connection serves many threads, Jedis is the alternative). The code below was compiled and run against a real redis:7 container with Spring Boot 3.5.16 (Spring Data Redis 3.5.x, Java 17). Property names are the Boot 3 ones: spring.data.redis.host, spring.data.redis.port, spring.data.redis.password, spring.data.redis.timeout.

application.yml
spring: data: redis: host: localhost port: 6379 timeout: 2s # command timeout; never leave a remote call unbounded cache: type: redis # only needed if several cache providers are on the classpath
ClassUse it forSerialization
StringRedisTemplateKeys and values that are strings: counters, locks, tokens, flags, Lua scripts returning numbersUTF-8 strings. Readable in redis-cli
RedisTemplate<K, V>Objects. You configure serializers for key, value, hash key and hash valueDefault is JDK serialization (binary, class-bound). Replace it
RedisCacheManagerBacks @Cacheable / @CacheEvict with Redis (see Spring: Caching and Resilience)RedisCacheConfiguration per cache: TTL, prefix, serializers
opsForValue() opsForHash() opsForList() opsForSet() opsForZSet() opsForStream()Typed views of each data typeUse the template's serializers
Java - RedisConfig.java: cache manager with TTLs and JSON values, and a RedisTemplate with readable keys
package demo; import java.time.Duration; import org.springframework.cache.annotation.EnableCaching; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.cache.RedisCacheConfiguration; import org.springframework.data.redis.cache.RedisCacheManager; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @EnableCaching public class RedisConfig { /** @Cacheable backed by Redis: JSON values, a TTL on every entry, a versioned key prefix. */ @Bean RedisCacheManager cacheManager(RedisConnectionFactory cf) { RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) // without a TTL, entries live until evicted .disableCachingNullValues() // a null result is not stored (see chapter 8 of page 06 for negative caching) .prefixCacheNameWith("shop:v1:") // bump v1 -> v2 to invalidate everything .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(cf) .cacheDefaults(defaults) .withCacheConfiguration("prices", defaults.entryTtl(Duration.ofSeconds(60))) .build(); } /** A RedisTemplate with readable keys and JSON values (the default is JDK serialization). */ @Bean RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory cf) { RedisTemplate<String, Object> t = new RedisTemplate<>(); t.setConnectionFactory(cf); t.setKeySerializer(new StringRedisSerializer()); t.setHashKeySerializer(new StringRedisSerializer()); t.setValueSerializer(new GenericJackson2JsonRedisSerializer()); t.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); return t; } }

GenericJackson2JsonRedisSerializer writes a @class property so it can rebuild the type. The real value stored for product(42) was {"@class":"demo.RedisPatterns$Product","id":42,"name":"Desk lamp","priceCents":1999} with a TTL of about 60 seconds (the prices cache) under the key shop:v1:prices::42. Spring Data Redis 4.x (Spring Boot 4) adds GenericJacksonJsonRedisSerializer next to the older GenericJackson2JsonRedisSerializer (both classes exist in 4.1.2); check the reference docs of the version you use.

Java - RedisPatterns.java: cache-aside, lock, fixed-window limiter, leaderboard, idempotency, SCAN
package demo; import java.time.Duration; import java.util.List; import java.util.UUID; import org.springframework.cache.annotation.Cacheable; import org.springframework.data.redis.core.Cursor; import org.springframework.data.redis.core.ScanOptions; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.data.redis.core.script.RedisScript; import org.springframework.stereotype.Service; @Service public class RedisPatterns { public record Product(long id, String name, long priceCents) {} private final StringRedisTemplate redis; public RedisPatterns(StringRedisTemplate redis) { this.redis = redis; } // 1. Cache-aside via annotation: the key becomes shop:v1:prices::42 @Cacheable(cacheNames = "prices", key = "#id") public Product product(long id) { return loadFromDatabase(id); } private Product loadFromDatabase(long id) { return new Product(id, "Desk lamp", 1999L); } // 2. Lock: SET key token NX PX ttl, released only if we still own it (Lua compare-and-delete) private static final RedisScript<Long> RELEASE = RedisScript.of( "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end", Long.class); public String tryLock(String name, Duration ttl) { String token = UUID.randomUUID().toString(); Boolean ok = redis.opsForValue().setIfAbsent("lock:" + name, token, ttl); // SET ... NX PX return Boolean.TRUE.equals(ok) ? token : null; } public boolean unlock(String name, String token) { Long deleted = redis.execute(RELEASE, List.of("lock:" + name), token); return deleted != null && deleted == 1L; } // 3. Fixed-window rate limit: INCR, set the expiry on the first hit public boolean allow(String user, int limitPerMinute) { String key = "rl:" + user + ":" + (System.currentTimeMillis() / 60_000); Long count = redis.opsForValue().increment(key); if (count != null && count == 1L) redis.expire(key, Duration.ofSeconds(60)); return count != null && count <= limitPerMinute; } // 4. Leaderboard on a sorted set public void addScore(String board, String player, double delta) { redis.opsForZSet().incrementScore("lb:" + board, player, delta); } public List<String> top(String board, int n) { var set = redis.opsForZSet().reverseRange("lb:" + board, 0, n - 1); return set == null ? List.of() : List.copyOf(set); } // 5. Idempotency key: first caller wins for 24 h public boolean firstTime(String idempotencyKey) { return Boolean.TRUE.equals(redis.opsForValue().setIfAbsent("idem:" + idempotencyKey, "1", Duration.ofHours(24))); } // 6. SCAN instead of KEYS (cursor must be closed) public long countKeys(String pattern) { long n = 0; try (Cursor<String> c = redis.scan(ScanOptions.scanOptions().match(pattern).count(500).build())) { while (c.hasNext()) { c.next(); n++; } } return n; } }
Shell - real output of running these methods (abridged: price lines, lock, limiter, leaderboard, idempotency, scan)
price=Product[id=42, name=Desk lamp, priceCents=1999] again=Product[id=42, name=Desk lamp, priceCents=1999] lock1=true lock2=false unlock wrong=false right=true allow 1=true allow 2=true allow 3=true allow 4=false top=[ravi, asha] idem first=true second=false scan count=4
⚠ Real bug found while preparing this page: caching a Long with the generic JSON serializer

A first version cached a Long (@Cacheable method returning Long). The first call worked; the second call, served from Redis, threw java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.Long, because JSON has one number type and the value 1999 came back as Integer. Cache a typed object (a record or class) instead of a bare number, or use a serializer configured with the exact type. Always test the second call, the one that hits the cache.

Spring and Redis checklist
  • Set spring.data.redis.timeout. Give every @Cacheable cache a TTL (entryTtl). Replace JDK serialization. Prefix keys with the application and a version. Use scan, never keys, and close the Cursor. Remember @Cacheable is proxy based: calling it from inside the same class bypasses the cache.
11
Chapter Eleven - Hands-On

Run Redis and Valkey in Docker and Poke at Everything

You need Docker and nothing else. The tags below were pulled on 2026-10-31: redis:7 resolved to 7.4.12, redis:8 to 8.10.3, valkey/valkey:8 to 8.1.11 and valkey/valkey:9 to 9.1.3 (read from each image with redis-server --version / valkey-server --version). Tags move; re-check.

Shell - start a server and open a CLI
$ docker run -d --name redis7 -p 6379:6379 redis:7 $ docker exec -it redis7 redis-cli 127.0.0.1:6379> PING PONG 127.0.0.1:6379> INFO server
Shell - the same commands on Valkey (the image contains both valkey-cli and redis-cli)
$ docker run -d --name valkey -p 6380:6379 valkey/valkey:8 $ docker exec -it valkey valkey-cli PING PONG $ docker run --rm valkey/valkey:9 valkey-server --version
Exercises
  • Types. Build a user hash, a tags set and a leaderboard sorted set. Run TYPE and OBJECT ENCODING on each, then add 600 members to the set and check the encoding again.
  • Expiry. SET k v PX 200, wait, GET k. Then SET k v EX 100, SET k v2, TTL k - what happened to the TTL? Retry with KEEPTTL.
  • Eviction. Start a server with --maxmemory 3mb --maxmemory-policy allkeys-lru, load 4000 keys of 1 KB, read INFO stats for evicted_keys. Switch to noeviction with CONFIG SET and try one more write.
  • Persistence. Start with --appendonly yes, write a key, docker restart, read it back. Look inside the appendonlydir volume. Repeat with persistence off and see the key vanish.
  • Cluster slots. Start with --cluster-enabled yes, CLUSTER ADDSLOTSRANGE 0 16383 and run CLUSTER KEYSLOT on keys with and without hash tags.
  • Lock. Reproduce the lock session of chapter 8, then let the TTL expire between two acquires.
  • Stream. Start two consumers in a group, kill one without XACK, and recover its entry with XAUTOCLAIM.
Cleanup
  • docker rm -f redis7 valkey removes the containers. Nothing here writes outside them.
12
Chapter Twelve - Interview

Redis Questions with Crisp Answers

  • Q: Why is Redis fast if it is single-threaded?
    Data is in memory, structures are simple, there is no locking, an event loop multiplexes many sockets, and most latency is the network. Command execution is one thread; I/O threads and background threads exist around it. Single-threaded execution also makes each command atomic.
  • Q: What is the difference between Redis and Memcached?
    Redis stores typed structures, supports persistence, replication, Lua, streams and Cluster. Memcached is a simpler, multi-threaded string cache with no persistence. Pick Memcached only for a plain cache where you want that simplicity.
  • Q: Explain expiry versus eviction.
    Expiry removes keys whose TTL elapsed, lazily on access and actively by sampling. Eviction removes keys when maxmemory is reached, chosen by maxmemory-policy. They are independent mechanisms.
  • Q: Name the eviction policies.
    noeviction (default), allkeys-lru, allkeys-lfu, allkeys-random, volatile-lru, volatile-lfu, volatile-random, volatile-ttl; newer Redis 8.x lines add allkeys-lrm and volatile-lrm. volatile-* only considers keys with a TTL.
  • Q: RDB versus AOF?
    RDB: periodic compact snapshots, fast restart, may lose minutes. AOF: a log of writes, with everysec losing about a second, bigger and slower to replay. Using both is the documented recommendation for safety. The default config ran snapshots only (appendonly no).
  • Q: Can Redis lose acknowledged writes?
    Yes. With async replication a failover can promote a replica that missed writes, and with everysec a crash loses about a second. WAIT narrows but does not eliminate the window.
  • Q: Sentinel or Cluster?
    Sentinel gives failover for one dataset that fits on one primary. Cluster shards data over 16384 slots and also fails over, at the cost of multi-key and database restrictions.
  • Q: What happens when a Cluster client hits the wrong node?
    The node replies MOVED slot host:port; the client updates its slot map and retries. During migration the node may reply ASK for a one-off redirect with ASKING.
  • Q: What is a hash tag and why use it?
    The part of the key inside {} is the only part hashed, so related keys land in one slot and multi-key operations work. Overuse creates hot shards.
  • Q: Does MULTI/EXEC roll back?
    No. Commands are queued and run sequentially without interleaving; a run-time failure leaves the others applied. WATCH adds optimistic check-and-set; Lua gives atomic logic.
  • Q: Pub/Sub versus Streams?
    Pub/Sub is at-most-once with no storage. Streams persist entries, support consumer groups, acknowledgements and redelivery (at-least-once). Use streams for anything that must not be lost.
  • Q: How do you implement a safe distributed lock?
    SET key token NX PX ttl, release only if the token matches (Lua, or DELEX IFEQ on Redis 8.4+). For correctness, add fencing tokens checked by the protected resource; a TTL lock alone can overlap after a pause or failover.
  • Q: Is Redlock safe?
    Debated. Kleppmann argued it relies on timing assumptions and lacks fencing; antirez replied it is adequate under stated assumptions. Treat it as suitable for efficiency locks and use fencing or a consensus system when correctness is critical.
  • Q: Why avoid KEYS in production?
    It is O(N) over the whole keyspace on the command thread and blocks every client. Use SCAN, which is incremental but may return duplicates.
  • Q: How would you find what is eating memory?
    INFO memory, redis-cli --bigkeys, MEMORY USAGE key, SCAN with a type filter, and INFO keyspace for key counts and expiries.
  • Q: How do you build a rate limiter?
    Fixed window: INCR plus EXPIRE in a script (boundary burst). Sliding window: sorted set of timestamps, trim, count, add, in a Lua script. Token bucket for smooth limits.
  • Q: What changed with Redis licensing?
    Redis 7.4 (2024-03-20 announcement) moved from BSD to RSALv2/SSPLv1; Redis 8 added AGPLv3 as an option (2025-05-01). Valkey is the BSD-licensed Linux Foundation fork started in March 2024.
  • Q: Why can @Cacheable return a ClassCastException from Redis?
    The JSON serializer loses numeric type information (a Long returns as Integer). Cache typed objects and test the cache-hit path.
  • Q: Scenario: your Redis memory is at 100 % and writes fail.
    Check maxmemory-policy (noeviction?), whether keys have TTLs, big keys via --bigkeys, evicted_keys. Fix: add TTLs, choose an allkeys-* policy if it is a cache, shard or scale up if it is real data.
13
Chapter Thirteen - Summary

Cheat Sheet and Recap

Cheat sheet
TopicRememberValue / default (checked)
ExecutionCommands run one at a time; each is atomicio-threads = 1 on 7.4.12 and 8.10.3
Evictionmaxmemory + maxmemory-policyDefault noeviction, maxmemory 0; sample size 5
ExpiryLazy plus active; SET clears TTL, KEEPTTL keeps itTTL -1 no expiry, -2 no key
RDBSnapshots by fork, lose minutessave 3600 1 300 100 60 10000
AOFWrite log; everysec loses about 1 sappendonly no, appendfsync everysec
ReplicationAsynchronous; WAIT narrows the loss windowReplicas read-only
ClusterCRC16 mod 16384; MOVED, ASK, CROSSSLOT; hash tags {}Database 0 only
TransactionsIsolated, no rollback; WATCH for CASLua for atomic logic
Pub/Sub vs StreamsAt-most-once vs at-least-once with groupsXACK, XAUTOCLAIM
LockSET k token NX PX ttl + compare-and-delete + fencingDELEX IFEQ on Redis 8.4+
Never in prodKEYS, unbounded SMEMBERS/HGETALLUse SCAN, pagination
SpringStringRedisTemplate, RedisCacheManager, JSON serializer, TTL per cacheBoot 3.5.16, Spring Data Redis 3.5.x
∑ Redis - what to remember
  • An in-memory data structure server: pick the structure whose commands do your work on the server.
  • One command at a time gives atomicity and a blocking risk: avoid O(N) commands on big keys.
  • **Set maxmemory and a policy**, and a TTL on every cache key. Expiry and eviction are different.
  • Persistence reduces loss, it does not remove it: RDB loses minutes, AOF everysec about a second, failover can lose more.
  • Sentinel for failover, Cluster for sharding; Cluster costs multi-key operations and SELECT.
  • Streams for reliable messaging, Pub/Sub for fire and forget.
  • Locks: a token, a TTL, a conditional release, and fencing where correctness matters.
  • Know which Redis you run: licence (BSD to 7.2, RSALv2/SSPLv1 from 7.4, AGPLv3 option from 8) and the Valkey fork.
Version note

Checked on 2026-10-31: command behaviour, configuration defaults, eviction policy names and DELEX/IFEQ by running redis:7 (7.4.12), redis:8 (8.10.3) and valkey images; licence facts against redis.io (licensing blog 2024-03-20, AGPLv3 blog 2025-05-01, licenses page) and valkey.io; persistence, replication, cluster, Pub/Sub, transaction and lock statements against redis.io/docs/latest; Spring signatures with javap on spring-data-redis 3.5.13 and 4.1.2 and a compile-and-run on Spring Boot 3.5.16. Not verified: exact release dates of Redis 8.x minors, Valkey-specific persistence defaults, managed-service behaviour (ElastiCache and others).

Next in this section