12 Redis: Building Fast, Stateful Applications
This lecture builds two small pieces of TerpTasks —
Learning Objectives
By the end of this lecture, you should be able to:
Explain what Redis is and when to reach for it —
contrast an in-memory key-value store against a disk-backed database like PostgreSQL, and identify which parts of an application’s state belong in each. Use Redis’s core data structures —
strings, lists, sets, and hashes — from both redis-cli and the Python client, and choose the right structure for a given problem. Build a cache with expiration —
use TTL to store the result of an expensive operation (an LLM call) for a bounded time, and explain why a cache without expiration is a memory leak with extra steps. Build a producer/consumer job queue —
use Redis Lists (LPUSH/BRPOP) so a slow background task never blocks a web request, and track per-job status with a Redis Hash. Explain Redis persistence trade-offs —
describe what RDB snapshots and AOF command logs each protect against, and how much data each can lose on a crash. Place Redis in a modern AI system architecture —
diagram where a cache, a task queue, a vector database, and an LLM each sit, and say which layer owns which kind of state. Recognize the most common Redis mistakes —
keys with no TTL, using Redis as a system of record, and blocking a web request on synchronous background work.
1. Motivation: Why Redis?
1.1 The problem: the same expensive call, over and over
Suppose TerpTasks grows an “Ask AI” box: a student types a question —
Student 1 ──┐ |
Student 2 ──┤ |
... ├──► Application ──► LLM API (slow + $$$, every time) |
Student 20 ──┘ |
The fix is the oldest idea in computing: remember the answer you
already computed. That’s a cache —
Student 1 ──┐ |
Student 2 ──┤ ┌─────────┐ miss ┌─────────┐ |
... ├──► App ─────►│ Redis │─────────►│ LLM API │ |
Student 20 ──┘ │ Cache │◄─────────┘ (once) │ |
└─────────┘ store |
The first student to ask pays the LLM’s latency and cost; Redis stores the
answer; every subsequent identical question is a cache hit answered
in microseconds, for free. This exact pattern —
1.2 What Redis is
Redis (REmote DIctionary Server) is an in-memory, key-value data store: it holds all of its data in RAM and exposes a small set of rich data structures (not just strings) through a simple command protocol.
Plain English. Redis is a giant, extremely fast dictionary/hash-map
that lives outside your application process, so multiple processes —
Intuition. A Python dict in your web server’s memory is fast but private to that one process, and it vanishes when the process restarts. Redis is that same dict, pulled out into its own long-lived process, reachable over the network, and shared by everyone. You trade a tiny bit of latency (a network round trip, still sub-millisecond on localhost) for sharing and persistence across restarts of your app.
Compare Redis to the databases you already know:
Database | Primary storage | Typical use |
PostgreSQL | Disk | The permanent record — |
MongoDB | Disk | Permanent, flexible-schema documents |
Redis | RAM (optionally backed up to disk) | Speed: caches, queues, counters, session state |
Common misconception: “Redis is a replacement for my database.” It is not, and shouldn’t be. Redis can persist to disk (§8), but its whole design center is speed, not durability guarantees or rich querying. The rule of thumb this lecture builds toward: if losing this data would be catastrophic and unrecoverable, it belongs in Postgres; if losing it means “recompute it” or “a user re-logs-in,” it belongs in Redis.
1.3 The smallest possible example
Every Redis command follows the same shape: a verb, a key, and (sometimes) a value. Start the server (§2) and connect with its command-line client, redis-cli:
SET user:1001 "Alice"
GET user:1001
"Alice"That’s the whole mental model in two lines: SET stores a value under a key, GET retrieves it. Everything else in this lecture is “what other verbs exist, and what other shapes of value can a key hold besides a string.”
2. Installing and Connecting to Redis
2.1 Running the server with Docker
The fastest way to get a real Redis server running locally is Docker —
docker run \
--name redis \
-p 6379:6379 \
-d redis6379 is Redis’s default port. -d runs it in the background. Connect to it with the bundled CLI:
docker exec -it redis redis-cliand sanity-check the connection with the traditional Redis “are you alive” command:
PING
PONGIf you see PONG, the server is up and every example in this lecture will work against it.
2.2 Connecting from Python
Install the official client:
pip install redisand connect:
import redis
r = redis.Redis(
host="localhost",
port=6379,
decode_responses=True, # return Python str, not bytes
)
r.set("name", "Anwar")
print(r.get("name"))AnwarThe decode_responses=True flag matters. By default the Python client returns raw bytes (Redis itself is byte-string-only under the hood); setting this flag decodes every response to a normal str so your application code doesn’t sprinkle .decode() calls everywhere. Every example in this lecture assumes it’s set.
Common misconception: “I need a database migration / schema before
I can start.” Redis is schemaless —
3. Redis Strings
Strings are Redis’s simplest data type: a key maps to a single byte string (which can hold text, JSON, or a number Redis will treat specially for arithmetic commands).
3.1 Worked example: a user-profile cache field
SET user:101:name Alice
GET user:101:name
"Alice"Plain English. A string key is exactly the dict entry from §1.3 —
3.2 Worked example: atomic counters
Redis strings that hold integers support atomic increment/decrement —
SET page_views 0
INCR page_views
INCR page_views
GET page_views
2Intuition —
Common uses: page views, likes, API rate-limit counters (§9), “how many times has this student asked the AI assistant today.”
4. Expiration: Building the AI Answer Cache
A cache that never forgets isn’t a cache —
4.1 EX and TTL
SET weather:fairfax "72F" EX 60
TTL weather:fairfax
45EX 60 means “expire 60 seconds from now.” TTL reports the
seconds remaining (here, we happened to check 15 seconds later). Once it
hits zero, the key is simply gone —
Common misconception: “I should just delete keys myself when I’m
done with them.” You could, but you’d have to remember to, from
every code path, including the ones that crash. EX makes
expiration a property of the data, not of your application’s
control flow —
4.2 Worked example: caching the TerpTasks AI assistant
Return to §1.1’s problem. Without a cache, every question re-runs the (here, simulated) LLM call:
answer = call_llm(question) # slow + costs money, every single timeWith a Redis cache in front of it:
import redis
r = redis.Redis(decode_responses=True)
def answer(question: str) -> str:
cached = r.get(question)
if cached is not None:
print("Cache hit")
return cached
print("Calling AI...")
result = call_llm(question) # only runs on a miss
r.set(question, result, ex=3600) # remember it for one hour
return resultTrace it exactly the way §8 of the React lecture traced a click: a student asks “What is RAG?” twice.
Call 1: answer("What is RAG?") |
1. r.get("What is RAG?") → None (miss) |
2. call_llm("What is RAG?") → "AI answer for: What is RAG?" |
3. r.set(question, result, ex=3600) |
4. returns "AI answer for: What is RAG?" (paid the LLM cost) |
|
Call 2: answer("What is RAG?") (asked again, 10 minutes later) |
1. r.get("What is RAG?") → "AI answer for: What is RAG?" (HIT) |
2. returns immediately (no LLM call at all) |
Why ex=3600 and not forever? An LLM’s answer to a
general question ("What is RAG?") is safe to cache for a long time.
A question like “What’s due this week?” has an answer that goes
stale the moment an assignment is added —
Practical implication. Notice the cache key is the raw question
text. That’s simple but fragile —
5. Redis Lists: Queues
A list is an ordered sequence of strings, efficient to push and pop
from either end —
5.1 Producer and consumer
LPUSH jobs "process_file1"
LPUSH jobs "process_file2"
LPUSH pushes onto the left end, so the list now reads, left to right:
jobs: process_file2 process_file1 |
A worker pulls from the right end with RPOP:
RPOP jobs
"process_file1"Plain English. Push-left, pop-right (LPUSH/RPOP) gives you
FIFO order —
r.lpush("tasks", "summarize_document")
task = r.rpop("tasks")
print(task)summarize_document5.2 The problem with RPOP: a worker that spins
RPOP on an empty list returns immediately with nothing. A naive worker loop therefore looks like this:
while True:
task = r.rpop("tasks")
if task is None:
time.sleep(0.1) # poll again shortly
continue
process(task)This works, but it’s polling: the worker asks Redis “anything for me?” ten times a second forever, even when the queue has been empty for a week. §6 introduces the fix.
6. Building a Background Job Queue
This section builds the lecture’s second running example: instead of making a student wait while TerpTasks summarizes an uploaded PDF, we queue the work and let a separate worker process do it.
6.1 Why background work at all?
User uploads syllabus.pdf |
│ |
▼ |
Web Application ──┐ if this thread does the summarizing, |
│ │ the HTTP request hangs for however |
(must respond) │ long extraction + the LLM call take |
▼ │ |
HTTP response ◄──┘ |
A web request should return in milliseconds. “Extract text, summarize, generate embeddings” can take seconds to minutes. The fix is the same producer/consumer split every real system uses (Celery, SQS, RabbitMQ all implement this same idea):
User uploads syllabus.pdf |
│ |
▼ |
Web Application ── LPUSH job onto queue ──► responds immediately: "queued" |
│ |
▼ |
Redis Queue |
│ │ |
▼ ▼ |
Worker 1 Worker 2 (separate processes, pull jobs) |
The web server’s only job is to record the job and push it onto the queue; it never touches the slow work itself.
6.2 Job metadata: a Redis Hash
A queue entry needs more than a filename —
HSET job:1001 filename lecture.pdf status queued
HGETALL job:1001
filename lecture.pdf
status queuedPlain English. Where a string key holds one value, a hash key
holds a whole group of named fields under one key —
{"filename": "lecture.pdf", "status": "queued"}From Python:
r.hset(
"job:1001",
mapping={"filename": "lecture.pdf", "status": "queued"},
)6.3 BRPOP: a worker that waits instead of spins
Redis has a blocking pop: BRPOP behaves exactly like RPOP, except that if the list is empty, the connection waits (blocks) until something arrives, instead of returning immediately.
job_id = r.brpop("job_queue", timeout=0)timeout=0 means “wait forever.” This directly fixes §5.2’s spinning worker: no polling loop, no wasted CPU, no artificial 100ms delay before a new job is picked up. The worker process is simply asleep, parked inside the BRPOP call, until Redis wakes it the instant a job is pushed.
Common misconception: “BRPOP polls Redis under the hood, so
it’s the same cost as my while loop.” It is not —
6.4 Putting it together: submit → queue → process → query
import redis
import time
import uuid
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def submit_job(filename: str) -> str:
"""Producer: record the job, then queue it."""
job_id = "job:" + str(uuid.uuid4())
r.hset(job_id, mapping={"filename": filename, "status": "queued"})
r.lpush("job_queue", job_id)
return job_id
def process_job(job_id: str) -> None:
"""Consumer: do the (simulated) work, updating status before and after."""
r.hset(job_id, "status", "processing")
time.sleep(2) # stand-in for extract text / summarize / embed
r.hset(job_id, "status", "completed")
def worker() -> None:
"""Runs forever in its own process, waiting for jobs."""
while True:
_, job_id = r.brpop("job_queue") # blocks until a job exists
process_job(job_id)
def get_status(job_id: str) -> dict:
return r.hgetall(job_id)Run the producer in one terminal, the worker in another, and query status
from a third —
Terminal 1 (producer) Terminal 2 (worker) Terminal 3 (status) |
────────────────────── ───────────────────── ───────────────────── |
>>> submit_job("lecture.pdf") Worker started |
'job:abc123' Processing job:abc123 >>> get_status("job:abc123") |
Completed {'filename': 'lecture.pdf', |
'status': 'processing'} |
(query again a moment later) |
{'filename': 'lecture.pdf', |
'status': 'completed'} |
Practical implication. Nothing here required the producer and the
worker to be the same program, the same language, or even running on the
same machine —
7. Redis Sets
A set stores an unordered collection of
unique strings —
7.1 Worked example: deduplication
SADD students Alice
SADD students Bob
SADD students Alice
SMEMBERS students
Alice
BobAlice only appears once, even though SADD was called with her name twice. Plain English: a set is Python’s set(), hosted in Redis and shared across processes.
7.2 Worked example: set algebra for recommendations
Sets support the algebra you’d expect —
SADD cmsc330 Alice Bob Charlie
SADD cmsc433 Bob Charlie David
SINTER cmsc330 cmsc433
Bob
CharlieIntuition. “Students in both cmsc330 and cmsc433” is exactly the
set-theory intersection you’d draw as a Venn diagram —
Practical implication. Common uses: tags (a post’s set of tags; SINTER finds posts sharing tags), permissions (a user’s set of roles; SISMEMBER checks membership in O(1)), and recommendations (“students who took a class you also took”).
8. Redis Hashes, Revisited
§6.2 already introduced hashes for job metadata; here is the general
picture. A hash maps a key to a set of
field/value pairs —
HSET user:1 name Alice age 25 major CS
HGETALL user:1
name Alice
age 25
major CSRedis type | Shape | Analogy |
String | one value | a single variable |
List | ordered sequence | a Python list / a queue |
Set | unordered, unique | a Python set |
Hash | named fields → values | a Python dict / one DB row |
Common misconception: “I’ll just json.dumps my object and store
it as one string.” You can, and for small, rarely-updated objects it’s
often simplest. But updating one field of a JSON string means
fetching the whole string, parsing it in your application, changing one
key, re-serializing, and writing the whole thing back —
9. Redis Persistence
The question every student asks the moment they hear “in-memory”: if the Redis process restarts, is everything gone?
By default, yes —
9.1 RDB: periodic snapshots
RDB (Redis Database file) periodically writes the entire in-memory dataset to a single compact file on disk.
Memory (all keys) ── every few minutes ──► dump.rdb (a full snapshot) |
Trade-off. Fast to restart from (load one file), small on disk, but
you can lose everything written since the last snapshot —
9.2 AOF: an append-only log of every write
AOF (Append-Only File) logs every write command as it happens, in order:
SET x 10
INCR x
SET y hello
To recover, Redis simply replays the log from the start. Configure how often the log is flushed to disk with appendfsync:
appendfsync everyseceverysec (the recommended default) flushes once per second —
RDB | AOF | |
What it stores | Periodic full snapshot | Every write command, in order |
Worst-case data loss | Since the last snapshot (minutes) | ≈1 second with everysec |
Restart speed | Fast (load one file) | Slower (replay the log) |
Disk size | Compact | Grows continuously (Redis compacts it periodically) |
Practical implication. Production Redis often runs both: RDB
for fast, cheap backups/restarts, AOF for the tight durability window. But
notice the recurring theme of this whole lecture: if a piece of data
would be a disaster to lose even for one second, it doesn’t belong in
Redis at all —
10. Redis in Modern AI Systems
Zoom out to where everything in this lecture fits in a realistic AI application architecture:
User |
│ |
▼ |
Application (TerpTasks web server) |
│ |
┌──────────────┼──────────────┐ |
▼ ▼ ▼ |
Redis Cache Job Queue Vector Database |
(§4: answers) (§6: async (semantic search over |
(rate limits) document task descriptions, |
(sessions) processing) for retrieval) |
│ │ │ |
└──────────────┼──────────────┘ |
▼ |
LLM |
Redis alone typically covers all of the following in an AI stack:
LLM response cache (§4) —
don’t pay for the same generation twice. Conversation history —
a list per session of the last N turns, so the LLM has context without a database round trip per message. User sessions —
a hash per logged-in user with a TTL, so a session dies automatically after inactivity (Practice Exercise 8). Rate limiting —
an INCR-with-EX counter per user per minute (§3.2), so one user can’t monopolize an expensive LLM endpoint. Task queues (§6) —
the async work: embeddings, summaries, document extraction.
Notice what is not on this list: the vector database and the
system-of-record data (the actual Task rows). That’s the boundary this
whole lecture has been drawing —
11. The Complete Project: terptasks-redis
Everything above is implemented, runnable, and tested in a small companion project. It extends TerpTasks with exactly the two features this lecture built: an AI answer cache and a document-processing job queue.
11.1 Repository structure
terptasks-redis/ |
├── requirements.txt # redis, pytest, fakeredis |
├── README.md |
├── docker-compose.yml # spins up Redis for local dev |
└── app/ |
├── __init__.py |
├── cache.py # AI answer cache (§4) |
├── queue.py # submit_job / worker / get_status (§6) |
├── stats.py # INCR-based counters (§3.2, §9) |
└── cli.py # tiny command-line demo of all three |
└── tests/ |
├── conftest.py # fakeredis fixture — no real server needed |
├── test_cache.py |
└── test_queue.py |
11.2 docker-compose.yml
services:
redis:
image: redis:7
ports:
- "6379:6379"docker compose up -d11.3 app/cache.py — the AI answer cache
"""AI answer cache for TerpTasks (see lecture ยง4)."""
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
CACHE_TTL_SECONDS = 300 # 5 minutes, per the exercise spec
def _fake_llm(question: str) -> str:
"""Stand-in for a real LLM call โ replace with an actual API call."""
return "AI answer for: " + question
def answer(question: str) -> str:
from app.stats import record_question, record_hit, record_miss
record_question()
cached = r.get(question)
if cached is not None:
record_hit()
return cached
record_miss()
result = _fake_llm(question)
r.set(question, result, ex=CACHE_TTL_SECONDS)
return result11.4 app/queue.py — the job queue
"""Background job queue for TerpTasks document processing (see lecture ยง6)."""
import time
import uuid
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
QUEUE_KEY = "job_queue"
def submit_job(filename: str) -> str:
job_id = "job:" + str(uuid.uuid4())
r.hset(job_id, mapping={"filename": filename, "status": "queued"})
r.lpush(QUEUE_KEY, job_id)
return job_id
def process_job(job_id: str) -> None:
r.hset(job_id, "status", "processing")
time.sleep(1) # simulate extract โ summarize โ embed
r.hset(job_id, "status", "completed")
def worker(run_once: bool = False) -> None:
"""`run_once=True` lets tests exercise one iteration without blocking forever."""
while True:
popped = r.brpop(QUEUE_KEY, timeout=0 if not run_once else 1)
if popped is None:
return
_, job_id = popped
process_job(job_id)
if run_once:
return
def get_status(job_id: str) -> dict:
return r.hgetall(job_id)11.5 app/stats.py — counters
"""Statistics counters (see lecture ยง3.2 and ยง9)."""
import redis
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
def record_question() -> None:
r.incr("total_questions")
def record_hit() -> None:
r.incr("cache_hits")
def record_miss() -> None:
r.incr("cache_misses")
def get_stats() -> dict:
return {
"total_questions": int(r.get("total_questions") or 0),
"cache_hits": int(r.get("cache_hits") or 0),
"cache_misses": int(r.get("cache_misses") or 0),
}11.6 Testing without a real Redis server
Just as the FastAPI/React lectures fake the network boundary in unit tests
(a stubbed fetch, a FakeClient), Redis code is tested against
fakeredis —
# tests/conftest.py
import fakeredis
import pytest
@pytest.fixture(autouse=True)
def fake_redis(monkeypatch):
"""Replace every module's `r` with one shared in-memory fake Redis."""
fake = fakeredis.FakeStrictRedis(decode_responses=True)
monkeypatch.setattr("app.cache.r", fake)
monkeypatch.setattr("app.queue.r", fake)
monkeypatch.setattr("app.stats.r", fake)
yield fake# tests/test_cache.py
from app.cache import answer
from app.stats import get_stats
def test_second_identical_question_is_a_cache_hit():
first = answer("What is RAG?")
second = answer("What is RAG?")
assert first == second == "AI answer for: What is RAG?"
assert get_stats() == {
"total_questions": 2,
"cache_hits": 1,
"cache_misses": 1,
}# tests/test_queue.py
from app.queue import submit_job, worker, get_status
def test_submitted_job_is_completed_after_one_worker_pass():
job_id = submit_job("lecture.pdf")
assert get_status(job_id)["status"] == "queued"
worker(run_once=True)
assert get_status(job_id)["status"] == "completed"pytest -vtests/test_cache.py::test_second_identical_question_is_a_cache_hit PASSED
tests/test_queue.py::test_submitted_job_is_completed_after_one_worker_pass PASSEDWhy fake, not real, Redis in tests? The same reason every earlier lecture faked its I/O boundary: tests that need a live server are slow, flaky in CI, and require infrastructure just to run pytest. fakeredis implements the real command semantics in memory, so the logic under test (cache-hit bookkeeping, status transitions) is verified exactly, with zero setup.
11.7 Running the demo end to end
docker compose up -d # start real Redis
python -m app.cliQuestion: What is RAG? |
Calling AI... |
AI answer for: What is RAG? |
|
Question: What is RAG? |
Cache hit |
AI answer for: What is RAG? |
|
Queued: document1.pdf, document2.pdf |
Worker: Processing document1.pdf... completed |
|
Statistics |
Questions: 2 |
Cache hits: 1 |
Cache misses: 1 |
Practice Exercises
Basic
Using redis-cli, SET a key with a 30-second TTL, run TTL on it immediately and again after 35 seconds, and explain the two different outputs you see.
Explain, in your own words, why INCR needs to be atomic and what could go wrong if an application implemented the same counter as value = int(r.get(k)); r.set(k, value + 1) instead.
Given the hash job:2001 with fields filename=paper.pdf and status=queued, write the single Redis command that updates only its status to processing without touching filename.
Two sets, cmsc330 and cmsc433, each list student names. Write the command that finds students in cmsc330 but not in cmsc433. (Hint: look up SDIFF.)
A teammate says “let’s just store the whole Task object as one JSON string in a Redis string key instead of using a hash.” Give one concrete scenario from §8 where this choice causes a bug their approach wouldn’t have.
Intermediate
6. Fix the cache key. §4.2’s cache uses the raw question text as the Redis key, so “What is RAG?” and “what is rag” miss independently. Normalize the key (lowercase + strip punctuation, or hash the normalized text) before using it in answer(), and write a test proving both spellings now hit the same cache entry.
7. Expiring sessions. Store a session as a hash session:<user_id> with fields like logged_in_at, and set a 30-minute TTL with EXPIRE. Write a function touch_session(user_id) that resets the TTL back to 30 minutes on every request (so an active user is never logged out), and explain why EXPIRE on every request, rather than TTL once, is the right approach.
8. Leaderboard with sorted sets. Redis has a fifth data structure not covered in lecture: the sorted set (ZADD, ZRANGE), which keeps members ordered by a numeric score. Build a small “AI assistant leaderboard” tracking how many helpful answers each student has rated, using ZADD scores 100 Alice / ZINCRBY, and retrieve the top 3 with ZREVRANGE.
9. Priority queue. §6’s job queue processes strictly FIFO. Rebuild it as a priority queue using a sorted set instead of a list (urgent jobs get a higher score), so an urgent document is always processed before a normal one submitted earlier. What does the worker’s pop operation become, and why can’t you use BRPOP anymore?
10. Failed jobs and retries. Extend process_job to randomly fail 10% of the time (simulate an extraction error), set status=failed and a retries field, and have the worker re-queue a failed job up to 2 times before giving up and setting status=permanently_failed. Track a jobs_failed counter alongside jobs_completed.
Advanced
11. Rate limit the AI endpoint. Using only INCR and EXPIRE, implement allow_request(user_id, limit=5, window=60) that returns False once a user has made more than 5 requests in the current 60-second window, and True otherwise. Handle the edge case of the first request in a window carefully (the key doesn’t exist yet —
when do you set its expiration, and what happens if you set it on every call instead of only the first?). 12. Real RAG integration. Replace _fake_llm in app/cache.py with a real pipeline: embed the question, query a vector database (or an in-memory stand-in) for the most relevant TerpTasks records, and pass them plus the question to a real LLM API. Keep the Redis caching layer exactly as-is. What does this tell you about where a cache belongs relative to the “smart” part of a system?
13. Design for a crash mid-job. §6’s worker sets status=processing then does the work then sets status=completed. If the worker process is killed during the work, the job is stuck at processing forever, and no other worker will ever pick it up (it already left the queue). Design a fix using a combination of a per-job TTL/heartbeat and a periodic “reaper” process, and explain the trade-off between checking too often (wasted work) and too rarely (jobs stuck for a long time before being retried).
Summary
Key takeaways
Redis is an in-memory key-value store with rich data structures —
strings, lists, sets, and hashes cover the overwhelming majority of real use cases, and each maps to a familiar Python type (value, list, set, dict). A cache without expiration is a memory leak. Always attach a TTL (EX/EXPIRE) to cache entries, and choose its length as a product decision about acceptable staleness, not a technical afterthought.
Atomic operations (INCR, single-field HSET) avoid the read-modify-write race that plain application-level counters are prone to.
Lists plus BRPOP give you a producer/consumer job queue for free —
a web request pushes work and returns immediately; a separate worker process blocks efficiently until work exists, instead of polling. Hashes track structured, frequently-updated state (like a job’s status) without the read-whole-object-to-change-one-field trap of storing JSON in a plain string.
RDB and AOF trade restart speed against data-loss window —
neither makes Redis a system of record; both exist to make Redis’s cache and queue state survive an ordinary restart. Redis owns fast, ephemeral, recomputable state; a real database owns the truth. Every design decision in this lecture —
what goes in Redis versus Postgres, how long a TTL should be, whether a crash losing a few seconds of writes is acceptable — comes back to that one line.
Terminology
Term | Definition |
Key-value store | A database where every value is looked up by a single key, with no query language over the value’s internals |
TTL | Time-to-live: seconds remaining before Redis auto-deletes a key |
Cache hit / miss | The requested value was / was not already stored when looked up |
Atomic operation | A command Redis executes as one indivisible step, with no interleaving from other clients |
Producer / consumer | One process creates units of work; a separate process (or pool) executes them |
Blocking pop (BRPOP) | A pop that waits for an item to exist instead of returning empty immediately |
RDB | Point-in-time snapshot persistence: periodic full dump to disk |
AOF | Append-only log persistence: every write command recorded, replayed on restart |
System of record | The authoritative, durable store for data that cannot be regenerated if lost |
Common mistakes
Setting a key with no TTL for data that’s meant to be a temporary cache, so Redis’s memory grows without bound.
Implementing a counter as application-level read-then-write instead of INCR, introducing a race condition under concurrent requests.
Storing an entire object as one JSON string, then needing a read-modify-write round trip (with a race window) to update a single field —
when a hash would update that field atomically. Writing a worker loop around plain RPOP with a sleep(), polling Redis continuously instead of using BRPOP.
Treating Redis as the permanent record for data that would be a disaster to lose, instead of keeping the source of truth in a real database and using Redis only for what’s cheap to recompute.
Using raw, un-normalized user input as a cache key, so trivially different inputs (case, whitespace, punctuation) miss the cache independently.
Forgetting that a worker crash mid-job leaves that job’s status stuck forever, with nothing to notice or retry it (Advanced Exercise 13).
Connections
Backward: the REST/FastAPI lecture’s TerpTasks is exactly what this lecture adds a cache and a job queue to; the layered-architecture lecture’s “isolate the cross-cutting concern in one layer” is why cache.py/queue.py/stats.py are separate modules rather than inline in route handlers; the testing lecture’s “fake the boundary” philosophy reappears as fakeredis standing in for a live server.
Forward: the vector-database and RAG lectures fill in _fake_llm’s replacement with real retrieval (Advanced Exercise 12); the deployment lecture covers running Redis (and a worker process) as separate services alongside the API in production; message brokers like Celery, RabbitMQ, and SQS generalize §6’s producer/consumer pattern beyond what a single Redis list can express (priority, delayed delivery, at-least-once acknowledgment).
Instructor Notes
Not part of the student-facing notes —
Suggested duration. 2–3 hours total: roughly 45 minutes for §1–4
(motivation through the cache), 45 minutes for §5–6 (lists through the
job queue —
Where students commonly struggle.
LPUSH/RPOP ordering. Which end is “first out” is genuinely confusing on first exposure; draw the list as a physical queue on the board (§5.1’s ASCII diagram) rather than describing it verbally.
Why BRPOP matters. Students who haven’t hit a spinning poll loop before don’t feel the pain §5.2/§6.3 are solving. If time allows, have them run the naive while True: RPOP(); sleep() version and watch CPU usage, then swap to BRPOP and watch it drop to zero.
TTL vs. deleting yourself. Some students reach for manual cleanup code before being shown EX/EXPIRE; reinforce that expiration-as-data-property is the whole point.
“Isn’t this just a dict?” Yes, and that’s the right intuition for a single process —
the lecture’s job is to make clear why pulling that dict out into its own shared, network-reachable process (multiple workers, a restart-safe queue) is worth the trade-off.
Suggested live demonstrations.
Open two terminal windows side by side: run the §6.4 producer in one and the worker in the other, submit a job, and watch the worker wake up instantly on BRPOP with no polling delay.
redis-cli MONITOR in a third terminal while running the Python client examples —
students seeing the actual wire commands stream by demystifies “what is the Python library actually doing.” Kill the Redis container mid-demo (with no persistence configured), restart it, and show that a plain in-memory cache is gone —
then repeat with AOF enabled and show the data survives.
Suggested quiz questions.
Why does INCR need to be atomic? Give an interleaving of two clients that would produce a wrong count without it.
A key is set with SET k v EX 60. A client calls TTL k at the 65-second mark. What does it return, and why?
Explain the difference in worst-case data loss between RDB and AOF with appendfsync everysec.
Why is BRPOP preferable to a while True: RPOP(); sleep() loop for a queue worker?
Give one example of data that belongs in Redis and one example of data that must live in a system-of-record database, and justify each.
Suggested homework. The Final Exercise structure in
notes/redis/plan.md (LLM cache + task queue + statistics, with
optional extensions for expiring sessions, a leaderboard via sorted sets,
and real RAG integration) and the standalone job-processing exercise in
notes/redis/exercise_plan.md both map directly onto §11’s
terptasks-redis project scaffold and the Practice Exercises above —