← All tech

Database

Redis

In-memory data structures for caching, queues, pub/sub, and real-time leaderboards.

  • Sub-millisecond caching in front of a slower store
  • Real-time leaderboards and ranking with sorted sets
  • Pub/sub fan-out, presence, and rate limiting
  • Atomic counters and short-lived state with TTL
Use it when

Reach for Redis when you need speed and rich in-memory data structures — a cache, a leaderboard, a queue, rate limiting, or real-time fan-out — sitting alongside a durable primary store.

Reach for something else when

Don't make Redis your only system of record for data you can't afford to lose, and skip it for complex relational queries, joins, or large datasets that won't fit in RAM — use PostgreSQL or MongoDB for the durable copy.

Official docs ↗


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

Beginner

Start 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
Run these in your terminal / editor
# 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        # -> PONG

Set and get a string, then make it expire

Beginner

Store 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
Run these in your terminal / editor
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 key

Count atomically with INCR

Beginner

Use 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
Run these in your terminal / editor
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 60
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Intermediate

Add 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
Run these in your terminal / editor
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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Intermediate

Push 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
Run these in your terminal / editor
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
For a plain chat. It returns complete code; you paste it in yourself.
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

Intermediate

Publish 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
Run these in your terminal / editor
# 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
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Group commands in a transaction with MULTIEXEC 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
Run these in your terminal / editor
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 it

Run atomic logic server-side with a Lua script

Advanced

Move 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
Run these in your terminal / editor
# 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 counter
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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

Advanced

Decide 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
Run these in your terminal / editor
# 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 now

Scan the keyspace safely in production

Advanced

Iterate 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
Run these in your terminal / editor
# 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 keys
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
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.