# Redis, From Zero

> The in-memory data store that does ten jobs: cache, session store, queue, rate limiter, and lock - with data structures, TTLs, and the persistence tradeoff.


---

# Redis, From Zero

You added Redis because someone said "put a cache in front of it," and now there's a box in your architecture diagram that holds important state in RAM and you're not totally sure what happens when it restarts. Or you keep seeing Redis used for caching, queues, sessions, locks, leaderboards - and you can't tell if it's one tool or five wearing a trench coat. This guide gives you the single mental model that makes all of those uses click, so you reach for the right data structure instead of cargo-culting commands off a blog post.

## How to read this

Read it in order - each phase builds the model the next one assumes. Phase 1 is the "what it actually is" that everything else hangs off; don't skip it even if you've typed `SET` before. Type the commands into a real `redis-cli` as you go; Redis is fast to poke at, and ten minutes at the prompt beats an hour of reading. By the end you'll know which data type fits a problem, how TTLs turn Redis into a cache, and what the persistence settings really promise.

## The phases

1. [The mental model: one RAM-speed dictionary](01-the-mental-model.md) - why Redis is fast, why single-threaded is a feature, and the data types as the whole point.
2. [The everyday core: caching, TTLs, and the patterns](02-the-everyday-core.md) - cache-aside, expiry, eviction, pub/sub and streams, and the day-to-day commands.
3. [Production reality: persistence, locks, and the sharp edges](03-production-reality.md) - RDB vs AOF, what you lose on a crash, distributed locks and their caveats.


---

# The mental model: one RAM-speed dictionary

Here's the thing that trips people up: Redis looks like a database, so you assume it works like one - rows, tables, queries, a disk you trust. It doesn't. The fastest way to understand Redis is to stop comparing it to Postgres and start comparing it to a Python dict or a Java HashMap. It's a giant key-value dictionary that lives in RAM, that one process owns, that happens to be reachable over the network.

Once that clicks, everything else - why it's fast, why it's single-threaded, why caching is its natural job - falls out of it.

## A dictionary that lives in memory and answers over the network

A normal database keeps your data on disk and pulls it into memory when you ask. Redis flips that: your data lives in RAM all the time, and disk is only a backup copy (more on that in phase 3). Reading from RAM is roughly a hundred thousand times faster than a random read from a spinning disk and still far faster than an SSD. That single fact - data is already in memory - is most of why Redis is fast.

The other part is that Redis does almost no work per request. A `GET` is a hash lookup. There's no query planner, no join, no scanning rows. You hand it a key, it hands you a value.

```text
redis-cli
127.0.0.1:6379> SET user:42:name "Ada"
OK
127.0.0.1:6379> GET user:42:name
"Ada"
127.0.0.1:6379> EXISTS user:42:name
(integer) 1
127.0.0.1:6379> DEL user:42:name
(integer) 1
```

*What just happened:* you stored a value under a key, read it back, asked whether it exists (1 = yes), and deleted it. The colons in `user:42:name` are pure convention - Redis has no namespaces or tables, but `type:id:field` keys keep a flat keyspace readable, and most tooling assumes that style.

## Single-threaded is a feature, not a flaw

The first time someone hears Redis runs your commands on a single thread, it sounds like a bottleneck. In a world of 32-core machines, one thread? But it's the source of two properties you'd otherwise pay dearly for.

First, **speed through simplicity.** No locks, no mutexes, no coordinating threads fighting over the same dictionary. A command runs start to finish with nothing else touching the data. The CPU spends its time on your work, not on synchronization.

Second, and this is the one people underuse: **every single command is atomic.** Because only one command runs at a time, there's no "halfway" state another client can observe. `INCR` reads, adds one, and writes back as one indivisible step. Two clients hammering the same counter can't lose an update.

```text
127.0.0.1:6379> SET page:views 0
OK
127.0.0.1:6379> INCR page:views
(integer) 1
127.0.0.1:6379> INCR page:views
(integer) 2
127.0.0.1:6379> INCRBY page:views 10
(integer) 12
```

*What just happened:* `INCR` did read-add-write as one atomic step. In application code you'd have a race - two threads read 1, both write 2, you lost a view. Redis can't race here because no two commands overlap. This is why Redis is a natural fit for counters, rate limiters, and ID generators.

> The flip side: one slow command blocks everything. A `KEYS *` on a million-key database, or a huge `SORT`, freezes every other client until it finishes. The single thread that gives you atomicity also gives you one shared lane - phase 3 covers the commands that abuse it.

## The data types are the whole point

If Redis were only a string-to-string dictionary, it'd be a faster Memcached and not much more. What makes it Redis is that the *value* can be a real data structure, and each command operates on that structure server-side. You don't fetch a list, modify it in your app, and write it back. You push onto the list with one command, and Redis does the work next to the data.

Here are the five you'll use constantly, and the shape of problem each one is for.

```text
String   "Ada"                       a value, a counter, a cached JSON blob, a flag
Hash     {name: "Ada", age: 36}      an object with fields - a user, a session
List     [a, b, c]                   ordered, push/pop both ends - a queue or stack
Set      {a, b, c}                   unique members, no order - tags, unique visitors
Sorted   {a:1.0, b:2.5, c:9.0}       members each with a score - leaderboards, ranges
  Set
```

*What just happened:* same key-value model, but the value carries structure. The skill of using Redis well is mostly matching a problem to the right type - a leaderboard is a sorted set, a session is a hash, a job queue is a list.

A hash is worth seeing concretely, because storing an object as one hash beats jamming JSON into a string when you want to read or update single fields:

```text
127.0.0.1:6379> HSET user:42 name "Ada" age 36 plan "pro"
(integer) 3
127.0.0.1:6379> HGET user:42 plan
"pro"
127.0.0.1:6379> HINCRBY user:42 age 1
(integer) 37
127.0.0.1:6379> HGETALL user:42
1) "name"
2) "Ada"
3) "age"
4) "37"
5) "plan"
6) "pro"
```

*What just happened:* you stored a user as a hash, read one field without fetching the rest, and bumped `age` atomically in place. With a JSON-in-a-string approach you'd have to read the whole blob, parse it, change one number, and write it all back - three round trips and a race condition where a hash gives you one atomic command.

And a sorted set, the type people are most surprised Redis has built in - a leaderboard in four commands:

```text
127.0.0.1:6379> ZADD scores 100 "ada" 250 "linus" 175 "grace"
(integer) 3
127.0.0.1:6379> ZADD scores 300 "ada"
(integer) 0
127.0.0.1:6379> ZREVRANGE scores 0 2 WITHSCORES
1) "ada"
2) "300"
3) "linus"
4) "250"
5) "grace"
6) "175"
```

*What just happened:* each member carries a score; Redis keeps them sorted for you. `ZADD scores 300 "ada"` returned `0` because ada already existed - it updated her score rather than adding a new member. `ZREVRANGE 0 2` pulled the top three highest-first. Building this yourself means re-sorting on every write; Redis maintains the order as a side effect of insertion.

## Where this fits

This same dictionary, with these same types, is what people mean by all those different uses. A cache is strings (or hashes) with an expiry. A session store is a hash per user. A job queue is a list you push to and pop from. A rate limiter is a counter with a TTL. A leaderboard is a sorted set. There is no separate "Redis cache product" versus "Redis queue product" - it's one in-memory dictionary, and the use case is which type you pick and what you do with it.

That's the whole mental model. Phase 2 turns it into the patterns you'll actually write: caching with TTLs, the cache-aside flow, and how Redis decides what to evict when RAM fills up.

> **For builders:** this is the same idea behind any cache layer - see [Caching Explained](/guides/caching-explained) for the why-and-when of caching in general, then come back here for the how with Redis specifically.

```quiz
[
  {
    "q": "Why is Redis fast, in one sentence?",
    "choices": [
      "It uses a smarter query planner than relational databases",
      "Data already lives in RAM and each command does almost no work - typically a hash lookup",
      "It runs every command on a separate CPU core in parallel",
      "It compresses data so disk reads are smaller"
    ],
    "answer": 1,
    "explain": "Data is in memory all the time and commands like GET are simple lookups - no planning, no joins, no disk read on the hot path."
  },
  {
    "q": "What does Redis being single-threaded give you, besides simplicity?",
    "choices": [
      "Automatic sharding across cores",
      "Every command is atomic - no two commands overlap, so counters can't race",
      "Unlimited memory because threads share the heap",
      "Faster disk persistence"
    ],
    "answer": 1,
    "explain": "Only one command runs at a time, so operations like INCR are indivisible and two clients can't lose an update. The cost is that one slow command blocks all others."
  },
  {
    "q": "You need a leaderboard that stays sorted by score. Which data type fits?",
    "choices": [
      "A list, pushing each new score",
      "A hash, with player names as fields",
      "A sorted set, with the score as each member's score",
      "A plain string holding sorted JSON"
    ],
    "answer": 2,
    "explain": "A sorted set keeps members ordered by score as a side effect of insertion, so ZREVRANGE gives you the top N without re-sorting."
  }
]
```


---

# The everyday core: caching, TTLs, and the patterns

Most of the Redis you'll write in your life is one pattern: put a copy of something slow in front of the slow thing, with an expiry on it. This phase is that pattern, plus the two things that make it safe - TTLs and eviction - and the day-to-day moves around them. By the end you'll be able to write a cache that doesn't go stale forever and doesn't fall over when memory fills up.

## TTL: the feature that turns a dictionary into a cache

A cache that never forgets is a memory leak. The thing that makes Redis a cache rather than a second database is that keys can expire on their own. You set a key with a time-to-live, and Redis deletes it for you when the clock runs out.

```text
127.0.0.1:6379> SET session:abc "user42" EX 3600
OK
127.0.0.1:6379> TTL session:abc
(integer) 3598
127.0.0.1:6379> SET session:abc "user42" EX 3600
OK
127.0.0.1:6379> EXPIRE session:abc 60
(integer) 1
127.0.0.1:6379> TTL session:abc
(integer) 60
127.0.0.1:6379> PERSIST session:abc
(integer) 1
127.0.0.1:6379> TTL session:abc
(integer) -1
```

*What just happened:* `EX 3600` set the value with a one-hour life. `TTL` shows seconds remaining. `EXPIRE` reset the countdown to 60 seconds, and `PERSIST` removed it entirely - `TTL -1` means "exists, no expiry" (and `-2` means "key doesn't exist"). That `-1` vs `-2` distinction trips everyone up once; remember `-1` is immortal, `-2` is gone.

One subtle trap: a plain `SET key value` (with no `EX`) *clears any existing TTL*. If you cache a value with an hour TTL and later overwrite it with a bare `SET`, the new value lives forever. Always re-set the expiry when you re-set the value, or use `SET key value KEEPTTL` to preserve it.

## Cache-aside: the pattern you'll write a hundred times

The default caching pattern is **cache-aside** (also called lazy loading), and it's worth burning into muscle memory because it's almost always what you want. The application - not Redis - owns the logic. Redis is a dumb fast box; your code decides what goes in it.

The flow is three steps:

```text
1. Read from cache.   GET product:99
   HIT  → return it. Done. (the fast path, ~99% of reads)
   MISS → continue.

2. Read from the real source (the database).
   SELECT * FROM products WHERE id = 99

3. Write the result into the cache with a TTL, then return it.
   SET product:99 <json> EX 300
```

*What just happened:* on a miss, you pay the database cost once, then the next few minutes of reads for that product come from RAM. The TTL is your staleness budget: 300 seconds means a product edit can take up to five minutes to show up. Shorter TTL = fresher data, more database load. That dial is the whole tradeoff.

In code it's the same shape in any language:

```python runnable
# A tiny in-process stand-in for Redis so this runs anywhere.
cache = {}
db = {99: {"id": 99, "name": "Keyboard", "price": 80}}

def get_product(pid):
    key = f"product:{pid}"
    cached = cache.get(key)          # 1. GET product:99
    if cached is not None:
        return ("HIT", cached)
    row = db[pid]                    # 2. miss → read the real source
    cache[key] = row                 # 3. SET product:99 ... EX 300
    return ("MISS", row)

print(get_product(99))   # cold: reads the db, fills the cache
print(get_product(99))   # warm: served from the cache
```

*What just happened:* the first call is a MISS and fills the cache; the second is a HIT served from memory. Swap the `dict` for a real Redis client and add `EX 300` to the set, and this is production cache-aside. The logic lives in your app - that's the defining trait of the pattern.

> The classic cache-aside failure is the **stale write**: you update the database but forget to invalidate or update the cache, so reads serve the old value until the TTL expires. The lazy fix is a short TTL so staleness self-heals; the precise fix is to `DEL product:99` whenever you write to that row. Most teams do both.

## Eviction: what happens when RAM fills up

TTLs handle keys you *expect* to expire. But what about when you've set no TTL, or set them too long, and Redis hits its memory ceiling? That's eviction, controlled by `maxmemory` and `maxmemory-policy`.

```text
# redis.conf (or via CONFIG SET at runtime)
maxmemory 2gb
maxmemory-policy allkeys-lru
```

*What just happened:* you capped Redis at 2 GB and told it that when full, evict the least-recently-used key from the whole keyspace to make room. Without `maxmemory` set, Redis keeps accepting writes until the OS kills it - set a ceiling on any cache.

The policy names look cryptic but decode cleanly. The prefix is *which keys are eligible*; the suffix is *how to pick among them*:

```text
allkeys-*    every key is a candidate          → use this for a pure cache
volatile-*   only keys that have a TTL set      → use when Redis mixes cache + durable data
*-lru        evict least recently USED
*-lfu        evict least frequently USED        → better when some keys are perennially hot
*-random     evict a random candidate
noeviction   evict nothing; reject writes with an error  (the default)
```

*What just happened:* `allkeys-lru` is the standard choice for a dedicated cache - anything can go, oldest-touched first. The default `noeviction` will start returning errors on writes when full, which surprises people who assumed Redis would quietly drop old keys. If Redis is a cache, change the policy; if it's holding data you can't lose, `noeviction` with monitoring is the safer setting.

## When you outgrow request/response: pub/sub and streams

Sometimes you don't want a value, you want a flow of messages between processes. Redis has two tools for that, and they're easy to confuse.

**Pub/sub** is fire-and-forget broadcast. A publisher sends to a channel; whoever is subscribed *right now* gets it. Nobody listening? The message is gone. No history, no replay.

```text
# terminal 1
127.0.0.1:6379> SUBSCRIBE news
Reading messages... (press Ctrl-C to quit)

# terminal 2
127.0.0.1:6379> PUBLISH news "deploy finished"
(integer) 1

# back in terminal 1, it appears:
1) "message"
2) "news"
3) "deploy finished"
```

*What just happened:* terminal 2's `PUBLISH` returned `1` - the number of subscribers that received it. If terminal 1 hadn't been subscribed at that instant, `PUBLISH` would return `0` and the message would vanish. That's the defining trait: pub/sub has no memory.

**Streams** (`XADD` / `XREAD`) are the opposite - an append-only log that persists messages, lets late consumers replay from any point, and supports consumer groups for work distribution. Reach for a stream when losing a message matters (a job that must run, an event you must process). Reach for pub/sub when it's a live notification and a missed one is fine (a "someone is typing" indicator).

```text
127.0.0.1:6379> XADD orders * item "book" qty 2
"1719750000000-0"
127.0.0.1:6379> XADD orders * item "pen" qty 5
"1719750000001-0"
127.0.0.1:6379> XLEN orders
(integer) 2
```

*What just happened:* `XADD ... *` appended an event and let Redis assign the ID (a timestamp plus a sequence number). Unlike pub/sub, these two events are stored - a consumer that connects later can read them with `XREAD`. That durability is the whole reason to pick a stream over pub/sub.

> **In the wild:** a huge amount of "we need a message queue" is satisfied by a Redis list (`LPUSH` to enqueue, `BRPOP` to block-and-pop) or a stream, and teams reach for Kafka or RabbitMQ before they need to. Start with what's already in your stack; graduate when you actually hit its limits.

Phase 3 is the part people skip and then learn the hard way: what Redis actually promises about your data surviving a restart, and why distributed locks are trickier than the blog posts admit.

```quiz
[
  {
    "q": "You cache a value with `SET k v EX 600`, then later overwrite it with a plain `SET k v2`. What's the TTL now?",
    "choices": [
      "Still 600 seconds, counting down from the original set",
      "Reset to 600 seconds",
      "No expiry - the bare SET cleared the TTL, so v2 lives forever",
      "The key is deleted because SET conflicts with the existing TTL"
    ],
    "answer": 2,
    "explain": "A plain SET replaces the value AND clears any existing TTL. Use SET ... KEEPTTL or re-apply EX to preserve expiry."
  },
  {
    "q": "In the cache-aside pattern, on a cache MISS the application should:",
    "choices": [
      "Return an error and let the client retry",
      "Read from the real source, write the result into the cache with a TTL, then return it",
      "Ask Redis to fetch from the database automatically",
      "Block until another request populates the key"
    ],
    "answer": 1,
    "explain": "Cache-aside puts the logic in the app: on a miss, load from the source, populate the cache with a TTL, and return the value. Redis doesn't know about your database."
  },
  {
    "q": "What is the key difference between Redis pub/sub and streams?",
    "choices": [
      "Pub/sub is faster; streams are slower but use less memory",
      "Streams broadcast to all subscribers; pub/sub sends to one consumer",
      "Pub/sub is fire-and-forget with no history; streams persist messages so late consumers can replay them",
      "Pub/sub supports TTLs; streams do not"
    ],
    "answer": 2,
    "explain": "Pub/sub only reaches subscribers connected at publish time and keeps no history. Streams are an append-only log you can replay and consume in groups."
  }
]
```


---

# Production reality: persistence, locks, and the sharp edges

The honeymoon ends the first time a Redis node restarts and you find out - in production - exactly how much data it was actually keeping safe. Everything in phase 1 lives in RAM, and RAM is gone when the process dies. This phase is the grown-up conversation: what Redis really promises about durability, why distributed locks are harder than they look, and the handful of commands that will freeze your server if you let them.

## The persistence tradeoff: RDB vs AOF

Redis can write your in-memory data to disk so it survives a restart. There are two mechanisms, and the difference is *snapshot* versus *log*.

**RDB (snapshot):** every so often, Redis forks and dumps the entire dataset to a single compact file (`dump.rdb`). Fast to load, small on disk, near-zero overhead while running. The catch is right there in "every so often" - if you snapshot every five minutes and crash at minute four, you lose four minutes of writes.

**AOF (append-only file):** Redis logs every write command to a file as it happens. On restart it replays the log to rebuild state. Far less data loss, but a larger file and some ongoing write overhead.

```text
# redis.conf

# RDB: snapshot if 1+ key changed in the last 900s, or 100+ in 60s, etc.
save 900 1
save 300 100

# AOF: log every write; fsync to disk once per second
appendonly yes
appendfsync everysec
```

*What just happened:* this is the common production setup - both enabled. RDB gives you a fast-loading backup; AOF caps your worst-case loss at roughly one second (`appendfsync everysec`). You *can* set `appendfsync always` for near-zero loss, but it fsyncs on every write and tanks throughput. The practical default most teams run is `everysec`: lose at most a second, keep the speed.

The decode table:

```text
                 RDB snapshot        AOF log
data loss        minutes (last snap) ~1s with everysec
restart speed    fast                slower (replays the log)
file size        small               larger
runtime cost     near zero           small, continuous
```

*What just happened:* RDB optimizes for restart speed and disk size; AOF optimizes for durability. Running both gets you AOF's durability with RDB as a fast-loading fallback.

> **The line to internalize:** even with AOF, Redis is not a database you bet irreplaceable data on. It's a fast store with *configurable* durability, and the fast settings lose data on a crash. If a record absolutely cannot be lost, the source of truth belongs in a real database - Redis holds a copy. Treat persistence as "warm restart" insurance, not as a durability guarantee.

## Distributed locks: the sharp edge everyone cuts themselves on

You'll eventually want to make sure only one process does something at a time across your fleet - run a cron once, not five times; charge a card once, not twice. Redis is the usual reach, and the *naive* version is genuinely dangerous, so let's build up to the safe one.

The wrong way you'll see first:

```text
127.0.0.1:6379> SETNX lock:job1 "worker-A"
(integer) 1
# ... do the work ...
127.0.0.1:6379> DEL lock:job1
```

*What just happened:* `SETNX` ("set if not exists") returned 1, so worker-A "got the lock." The bug: if worker-A crashes before the `DEL`, the lock is held *forever* and the job never runs again. A lock with no expiry is a deadlock waiting for a crash.

The correct primitive sets the value and the TTL atomically, so a crash can't leave the lock stuck:

```text
127.0.0.1:6379> SET lock:job1 "worker-A-uuid-123" NX EX 30
OK
# ... do the work (must finish well under 30s) ...
# release ONLY if we still own it - checked atomically in Lua:
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:job1 worker-A-uuid-123
(integer) 1
```

*What just happened:* `SET ... NX EX 30` acquired the lock *and* gave it a 30-second self-destruct in one atomic command - a crash releases it automatically. The release uses a unique token (`worker-A-uuid-123`) and a Lua script so the check-and-delete is atomic. Why the token matters: if worker-A stalled past 30s, the lock expired and worker-B took it; a blind `DEL` from A would then delete *B's* lock. The token check stops you from releasing a lock you no longer own.

But here's the part the tutorials skip: **even this is not a perfect lock.** If worker-A pauses (a long GC, a network hiccup) for longer than the TTL, the lock expires, worker-B starts the job, and now *both* are running - A doesn't know its lock died. On a single Redis node this is the best you get, and it's fine for "mostly once" jobs. For genuine correctness-critical mutual exclusion across nodes, people reach for the Redlock algorithm - and even Redlock is debated by distributed-systems experts.

> The lazy, straight answer: if double-execution would be catastrophic (double-charging a card), don't rely on a Redis lock alone - make the operation **idempotent** (a unique key the database rejects on the second insert) so running it twice is harmless. A Redis lock is a cheap way to *reduce* contention, not a guarantee of exactly-once.

## The commands that freeze your server

Remember from phase 1: one thread, one lane. A slow command blocks every other client. A few are infamous for it.

```text
KEYS *                 scans the ENTIRE keyspace, blocking - never in production
FLUSHALL               wipes everything, synchronously by default
SMEMBERS huge:set      returns a million-element set in one blocking call
HGETALL huge:hash      same problem on a large hash
```

*What just happened:* each of these can stall Redis for seconds on a large dataset while every other request waits. The fixes: use `SCAN` (cursor-based, incremental) instead of `KEYS`; use `SSCAN` / `HSCAN` for large collections; and run `FLUSHALL ASYNC` if you must flush. `SCAN` is the one to wire into your reflexes - `KEYS` in a code review should always get a comment.

```text
127.0.0.1:6379> SCAN 0 MATCH user:* COUNT 100
1) "176"                 # the cursor for the next call (0 means done)
2) 1) "user:42"
   2) "user:7"
```

*What just happened:* `SCAN` returned a cursor (`176`) and a batch of matches. You call it again with that cursor, repeating until it returns `0`. It never blocks the server for long because it walks the keyspace in small chunks - the boring, safe way to iterate keys.

## The mental model, completed

You started with "Redis is a RAM-speed dictionary." Now you know the full shape: a single-threaded, in-memory key-value store whose values are real data structures, that expires keys for caching, evicts under memory pressure, and offers *configurable* - not absolute - durability and locking. Use it as the fast layer in front of your real database, lean on its data types instead of doing the work in your app, set a `maxmemory` and an eviction policy, pick your persistence by how much loss you can tolerate, and never trust a single Redis lock for something that must happen exactly once.

> **For builders:** when Redis itself becomes the bottleneck - too much data for one node, or too many writes - the path is replicas and sharding (Redis Cluster), which is the same scaling story databases face. [Scaling a Database](/guides/scaling-a-database) covers the read-replica and sharding patterns that apply here too.

```quiz
[
  {
    "q": "With `appendfsync everysec` (AOF) and the standard RDB snapshots, what's the realistic worst-case data loss on a crash?",
    "choices": [
      "Zero - AOF guarantees no loss",
      "About one second of writes",
      "Everything since the last RDB snapshot, possibly minutes",
      "All in-memory data, since persistence only runs on shutdown"
    ],
    "answer": 1,
    "explain": "everysec fsyncs the AOF roughly once a second, so a crash loses at most about a second of writes. always is near-zero but much slower; RDB alone could lose minutes."
  },
  {
    "q": "Why does a safe Redis lock release use a unique token checked in a Lua script, instead of a plain DEL?",
    "choices": [
      "DEL is too slow on large keys",
      "So that if your lock already expired and another worker acquired it, you don't delete their lock",
      "Lua scripts run on a separate thread for speed",
      "Because SETNX cannot be combined with EXPIRE"
    ],
    "answer": 1,
    "explain": "If your TTL expired and another worker took the lock, a blind DEL would release THEIR lock. The token check (atomic via Lua) ensures you only delete a lock you still own."
  },
  {
    "q": "Which command should replace `KEYS *` in production, and why?",
    "choices": [
      "SCAN, because it iterates the keyspace incrementally without blocking the single thread for long",
      "FLUSHALL, because it's faster",
      "HGETALL, because it returns everything at once",
      "SUBSCRIBE, because it streams keys"
    ],
    "answer": 0,
    "explain": "KEYS * scans the whole keyspace in one blocking call, freezing every other client. SCAN walks it in small cursor-based batches, so the server stays responsive."
  }
]
```
