Redis is the speed layer of this curriculum. It’s an in-memory data store with rich types — strings,
hashes, lists, sets, sorted sets, streams — plus pub/sub, TTL expiry, and atomic operations. It’s almost
always additive: a cache, a queue, a leaderboard, a presence tracker sitting next to PostgreSQL or
MongoDB, not replacing them. This track moves from SET/GET to sorted-set leaderboards, atomic rate
limiting, Lua scripts, and persistence trade-offs — tagged by level so you can read only as deep as you need.
Run Redis and open redis-cli
BeginnerStart a Redis server with Docker, then connect to it with redis-cli and run PING.
Why Docker, and what PING proves
You don’t need a system install to learn Redis — one container gives you a clean, throwaway server on the
default port 6379. redis-cli is the interactive client that ships with Redis; PING is the liveness
check that returns PONG when the server is reachable. Everything in this track runs against this one
local server.
Start a server and connect
# Start Redis 7 in the background, mapping the default port
docker run -d --name redis -p 6379:6379 redis:7
# Connect with the CLI that ships inside the same image
docker exec -it redis redis-cli
# at the redis> prompt:
PING # -> PONGSet and get a string, then make it expire
BeginnerStore a value with SET, read it back with GET, and give a key a time-to-live with EX.
Strings, and why TTL is the headline feature
The string is Redis’s most basic type — a key mapped to a value (text or binary, up to 512 MB). What makes
Redis a cache rather than a hashmap is expiry: SET key value EX 60 stores the value and tells Redis to
delete it automatically after 60 seconds. TTL key shows the seconds remaining (-1 means no expiry,
-2 means the key is already gone). Expiry is what keeps a cache from growing without bound.
String basics with expiry
SET session:42 "alice" # -> OK
GET session:42 # -> "alice"
SET session:42 "alice" EX 60 # same value, expires in 60s
TTL session:42 # -> (integer) 60 (counts down)
EXPIRE session:42 120 # reset the TTL to 120s
GET nope # -> (nil) missing keyCount atomically with INCR
BeginnerUse INCR to increment a numeric counter in a single atomic step, no read-modify-write race.
Why INCR beats GET-then-SET
Counting page views or API hits with GET then SET key value+1 has a race: two clients read the same
number and both write back the same increment, losing a count. INCR does the read, add, and write as one
atomic operation on the server, so concurrent callers never collide. INCRBY adds an arbitrary amount, and
INCR even initialises a missing key to 0 first. This single guarantee is the foundation of rate limiting
and metrics in Redis.
Atomic counters
INCR views:home # -> (integer) 1 (key created at 0, then +1)
INCR views:home # -> (integer) 2
INCRBY views:home 10 # -> (integer) 12
DECR views:home # -> (integer) 11
# A counter that resets every minute: bump it, set expiry only on first hit
INCR rate:ip:1.2.3.4 # -> (integer) 1
EXPIRE rate:ip:1.2.3.4 60Agent prompt — paste into an agent with repo access
Role: Backend engineer adding a Redis-backed cache to this repo.
Context: A Go service uses github.com/redis/go-redis/v9 with a *redis.Client at rdb. A getUser(ctx, id) hits a slow store.
Task: Add a read-through cache: serve from Redis if present, else load from the store, write to Redis with a 5-minute TTL, and return.
Requirements:
- Cache key is "user:" + id; value is the JSON-encoded user.
- Use rdb.Get(ctx, key); treat the sentinel redis.Nil as a cache miss (NOT an error).
- On miss: load, then rdb.Set(ctx, key, jsonBytes, 5*time.Minute).
- Never let a Redis error fail the request — log it and fall back to the store.
Tests / acceptance:
- Unit test with a miniredis (github.com/alicebob/miniredis/v2) instance: first call populates the key, second call reads from Redis without touching the store (use a call-counting fake store).
- `go test ./...` passes.
Output: a unified diff plus a one-paragraph note on why redis.Nil must be handled specially.Build a leaderboard with a sorted set
IntermediateAdd players and scores with ZADD, then read the top N with ZREVRANGE ... WITHSCORES.
Sorted sets are why Redis owns leaderboards
A sorted set stores unique members each with a floating-point score, kept ordered by score at all times.
ZADD game scores member inserts or updates in O(log N); the set is always sorted, so reading the top of the
board is cheap. Members with equal scores are ordered lexicographically by member name (which ZREVRANGE
then reverses, so a tie can put the later name first). ZREVRANGE key 0 9 WITHSCORES returns the ten highest scorers, highest first; ZRANK /
ZREVRANK give a member’s position; ZINCRBY bumps a score atomically. Doing this in a relational store
means an ORDER BY ... LIMIT and an index scan on every read — Redis keeps the ranking maintained for you.
A live leaderboard
ZADD game 1500 "alice" 1200 "bob" 1750 "carol"
ZINCRBY game 300 "bob" # bob -> 1500, position updates instantly
# Top 3, highest score first, with the scores attached
ZREVRANGE game 0 2 WITHSCORES # -> carol 1750 bob 1500 alice 1500
ZREVRANK game "alice" # -> (integer) 2 (0-based rank from the top; bob is 1)
ZSCORE game "carol" # -> "1750"
ZCARD game # -> (integer) 3 (number of players)Agent prompt — paste into an agent with repo access
Role: Backend engineer building a real-time leaderboard in this repo.
Context: Go service, github.com/redis/go-redis/v9, *redis.Client at rdb. Scores live in a sorted set keyed "leaderboard:{season}".
Task: Implement RecordScore(ctx, season, player, delta) and TopN(ctx, season, n) returning ranked players with scores.
Requirements:
- RecordScore uses rdb.ZIncrBy so repeated calls accumulate a player's score atomically.
- TopN uses rdb.ZRevRangeWithScores(ctx, key, 0, int64(n-1)) and returns []struct{Player string; Score float64} in descending order.
- Add Rank(ctx, season, player) returning the 0-based rank via ZRevRank, mapping redis.Nil to "not ranked".
Tests / acceptance:
- Using miniredis: record alice+10, bob+30, alice+25 (alice total 35), then TopN(2) returns [alice 35, bob 30] in that order; Rank for alice is 0.
- `go test ./...` passes.
Output: a unified diff plus a one-paragraph explanation of why ZINCRBY is correct here instead of read-modify-ZADD.Use lists as a simple queue
IntermediatePush jobs onto a list with LPUSH and have a worker block-pop them with BRPOP.
Lists give you a FIFO queue for free
A Redis list is an ordered sequence you push to and pop from either end. Treat it as a job queue:
producers LPUSH queue payload onto the left, a worker BRPOP queue 0 pops from the right (FIFO order).
BRPOP blocks until an item arrives or a timeout elapses, so the worker isn’t spinning in a busy loop.
This is the simplest durable-enough queue you can stand up; for at-least-once delivery and consumer groups,
graduate to Redis Streams (XADD / XREADGROUP).
A push/pop work queue
LPUSH jobs '{"task":"resize","id":1}' # producer enqueues
LPUSH jobs '{"task":"resize","id":2}'
LLEN jobs # -> (integer) 2
# Worker: block up to 5s waiting for the next job (0 = wait forever)
BRPOP jobs 5 # -> jobs {"task":"resize","id":1} (FIFO: oldest first)Chat prompt — paste into a chat to get the code
Role: Backend teacher. The reader has no repo access here — return complete, runnable code.
Task: Show a minimal Go producer/worker pair using github.com/redis/go-redis/v9 that uses a Redis list as a FIFO job queue.
Requirements:
- Producer enqueues with rdb.LPush(ctx, "jobs", payload).
- Worker loops calling rdb.BRPop(ctx, 5*time.Second, "jobs"); on redis.Nil (timeout) it continues the loop rather than erroring.
- Each job payload is JSON {"task": string, "id": int}; the worker unmarshals and prints it.
- Include the redis.NewClient setup and a graceful exit on context cancellation.
Tests / acceptance (describe, since no repo):
- Enqueue two jobs, run the worker, and it prints them oldest-first.
- A 5s idle period logs "no jobs" and keeps polling instead of crashing.
Output: the complete producer.go and worker.go files, no commentary.Fan out events with pub/sub
IntermediatePublish messages to a channel with PUBLISH and have subscribers receive them live with SUBSCRIBE.
Pub/sub is fire-and-forget broadcast
Redis pub/sub decouples senders from receivers: a publisher PUBLISH room:42 "hello" sends to a channel,
and every client currently SUBSCRIBEd to room:42 receives it. It’s fire-and-forget — messages are not
stored, so a subscriber that isn’t connected when you publish never sees that message. That makes it perfect
for real-time fan-out (chat, live presence, “someone joined”) and wrong for anything that needs durability or
replay. When you need persistence and consumer groups, reach for Streams instead.
Publish and subscribe
# Terminal A: subscribe and wait for messages
SUBSCRIBE room:42
# (blocks, printing each message as it arrives)
# Terminal B: publish — returns the number of subscribers that received it
PUBLISH room:42 "alice joined" # -> (integer) 1
# Pattern subscribe: every room:* channel at once
PSUBSCRIBE room:*Agent prompt — paste into an agent with repo access
Role: Backend engineer adding real-time fan-out in this repo.
Context: Go service, github.com/redis/go-redis/v9, *redis.Client at rdb. Game rooms broadcast events to connected players over WebSockets.
Task: Implement Publish(ctx, room, event) and a Subscribe(ctx, room) that returns a <-chan Event for callers to range over.
Requirements:
- Publish marshals event to JSON and calls rdb.Publish(ctx, "room:"+room, payload).
- Subscribe uses rdb.Subscribe(ctx, channel); read messages via the subscription's Channel() and forward decoded Events onto an out channel; close the out channel when ctx is cancelled.
- Document in a comment that pub/sub is at-most-once: events published while no subscriber is connected are lost.
Tests / acceptance:
- Integration test with miniredis (which supports pub/sub): subscribe to "lobby", publish one event, assert it arrives on the channel within 1s.
- `go test ./...` passes.
Output: a unified diff plus a one-paragraph note on when to choose Streams over pub/sub.Make multi-key updates atomic with MULTI/EXEC
AdvancedGroup commands in a transaction with MULTI … EXEC so they run as one isolated unit.
What a Redis transaction does and doesn't promise
MULTI opens a transaction; the commands you queue after it don’t run until EXEC, and then they run
sequentially with no other client interleaving. This is how you keep two related writes consistent — e.g.
remove a player from one set and add them to another. Redis transactions are not rollback-on-error like
SQL: if a queued command fails at runtime the others still apply. For optimistic concurrency, WATCH a key
first — if it changes before EXEC, the transaction aborts and you retry. For genuinely atomic
read-decide-write logic, a Lua script (next Step) is usually cleaner.
A watched, atomic transfer
WATCH inventory:sku1 # abort EXEC if this key changes underneath us
MULTI
DECRBY inventory:sku1 1 # reserve one unit
LPUSH orders "sku1" # record the order
EXEC # -> array of replies, or (nil) if WATCH tripped
# DISCARD throws away a queued MULTI block without running itRun atomic logic server-side with a Lua script
AdvancedMove read-decide-write logic into a Lua script run by EVAL, so it executes atomically on the server.
Why a Lua script for rate limiting
Some logic needs to read a value, decide, and write back as one indivisible step — a token-bucket rate
limiter is the classic case. EVAL runs a Lua script atomically inside Redis: nothing else executes while it
runs, so there’s no window for a race. The script below increments a per-window counter, sets the expiry only
on the first hit, and returns whether the caller is over the limit. In production, load it once with
SCRIPT LOAD and call it by SHA with EVALSHA to avoid re-sending the body.
Atomic fixed-window rate limiter
# KEYS[1] = rate key, ARGV[1] = limit, ARGV[2] = window seconds
# Returns 1 if allowed, 0 if the limit is exceeded.
EVAL "local n = redis.call('INCR', KEYS[1]) if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end if n > tonumber(ARGV[1]) then return 0 end return 1" 1 rate:ip:1.2.3.4 100 60
# Preload once, then call by SHA (cheaper on the wire):
SCRIPT LOAD "return redis.call('INCR', KEYS[1])" # -> "<sha1>"
EVALSHA <sha1> 1 counterAgent prompt — paste into an agent with repo access
Role: Backend engineer hardening an API in this repo.
Context: Go service, github.com/redis/go-redis/v9. We need a fixed-window rate limiter that is atomic under concurrency.
Task: Implement Allow(ctx, key, limit int, window time.Duration) (bool, error) using a Lua script via redis.NewScript.
Requirements:
- The Lua script INCRs KEYS[1], sets EXPIRE to ARGV[2] only when the counter is 1, and returns 1 (allowed) or 0 (blocked) by comparing against ARGV[1].
- Use script.Run(ctx, rdb, []string{key}, limit, int(window.Seconds())); go-redis falls back from EVALSHA to EVAL automatically.
- A Redis error must propagate (do NOT fail open silently for a limiter).
Tests / acceptance:
- Using miniredis: with limit=3, the first 3 Allow calls return true and the 4th returns false within the window.
- `go test ./...` passes.
Output: a unified diff plus a one-paragraph note on why the EXPIRE is guarded by the n==1 check.Choose a persistence model: RDB vs AOF
AdvancedDecide between point-in-time RDB snapshots and the append-only file (AOF), and confirm what’s configured.
Durability is a dial, not a default to ignore
Redis is in-memory but can persist to disk two ways. RDB takes periodic point-in-time snapshots — compact,
fast to restart from, but you can lose everything written since the last snapshot on a crash. AOF logs every
write command and replays it on restart; with appendfsync everysec you lose at most ~1 second of writes,
at the cost of a larger file and slightly more I/O. You can run both. The honest framing for this curriculum:
even with AOF, Redis is a fast complement to your durable store, not the system of record for data you can’t
lose. Size your dataset to fit in RAM, and back the source of truth with PostgreSQL or MongoDB.
Inspect and tune persistence
# What is this server doing right now?
CONFIG GET save # RDB snapshot rules (empty string = snapshots off)
CONFIG GET appendonly # "yes" if AOF is enabled
INFO persistence # last save time, AOF status, rewrite progress
# Turn on AOF with ~1s durability without restarting
CONFIG SET appendonly yes
CONFIG SET appendfsync everysec
BGSAVE # trigger a background RDB snapshot nowScan the keyspace safely in production
AdvancedIterate keys with SCAN (never KEYS) and watch memory with INFO memory and MEMORY USAGE.
Why KEYS is a foot-gun and SCAN is safe
KEYS pattern walks the entire keyspace in one blocking call — on a large production server it freezes Redis
for every other client. SCAN is the safe alternative: it returns a cursor and a small batch of keys per
call, so you iterate incrementally without blocking. Pair it with MATCH for a pattern and COUNT as a hint
for batch size. For operability, INFO memory shows total usage and the eviction policy, and MEMORY USAGE key reports the bytes a single key costs — essential when you’re deciding what to cache and for how long.
Cursor-based iteration and memory checks
# Iterate keys matching a pattern, 100 at a time, without blocking the server
SCAN 0 MATCH "session:*" COUNT 100
# -> "<next-cursor>" [ session:42 session:43 ... ]
# repeat with the returned cursor until it comes back as 0
INFO memory # used_memory_human, maxmemory_policy, fragmentation
MEMORY USAGE user:42 # -> bytes this one key occupies
DBSIZE # -> total number of keysAgent prompt — paste into an agent with repo access
Role: SRE-minded backend engineer operating Redis in this repo.
Context: Go service, github.com/redis/go-redis/v9, *redis.Client at rdb. We need to expire-sweep stale keys matching a prefix without blocking the server.
Task: Implement CountByPrefix(ctx, prefix) (int, error) that iterates the keyspace with SCAN and counts matching keys.
Requirements:
- Use rdb.Scan(ctx, cursor, prefix+"*", 100) in a loop, advancing the returned cursor until it is 0. NEVER use KEYS.
- Accumulate the count across batches; respect ctx cancellation between iterations.
- Document in a comment why SCAN is used instead of KEYS.
Tests / acceptance:
- Using miniredis: seed 5 keys "session:1".."session:5" plus 2 unrelated keys; CountByPrefix("session:") returns 5.
- `go test ./...` passes.
Output: a unified diff plus a one-paragraph note on the SCAN cursor-completion contract (cursor returns to 0).Where to take it next
- Make Redis load-bearing in Ticker, where Redis Streams are the change feed, the SSE fan-out source, and the reconnect-replay log for a live price/stock control room on a durable store — or in Concord, where pub/sub fan-out drives a collaborative editor.
- Pair Redis with its system of record — PostgreSQL for relational integrity and ACID transactions, the durable copy Redis caches and accelerates.
- Caching documents instead of rows? See how Redis complements MongoDB for read-heavy, flexible-schema workloads.