Stack: PostgreSQL + Redis · Project: TicketFlow Status: Published — closing the data module Prerequisite: Lesson 11 — Migrations
Objectives
- Choose between Postgres and a specialized store with judgement (what you sacrifice with each).
- Use Redis as cache, atomic counter, distributed lock and leaderboard — with sane TTLs.
- Use PostgreSQL's JSONB when the "NoSQL" you need fits inside your current DB.
- Recognize the anti-patterns: Mongo "because it's modern", Redis as the primary database.
1. What you sacrifice with each store
| Store | Gains | Sacrifices | In TicketFlow |
|---|---|---|---|
| PostgreSQL | ACID, constraints, SQL, EXPLAIN | simple writes at extreme scale | the source of truth (always) |
| Redis | µs latency, atomics, TTL, structures | durability (by default), no SQL, RAM | cache, counters, locks, queues (broker) |
| MongoDB | flexible documents, easy sharding | joins/multi-doc ACID (improved, but not its point) | varied read-only catalogs (here: no) |
| Elasticsearch | text search, aggregations | consistency, sync cost | event search (later, if demand exists) |
The senior rule: Postgres is the primary database; everything else is specialization justified by a metric (availability latency, counter throughput, text search). "I need NoSQL" is not a metric.
2. Redis: the five uses that are worth it
import redis
r = redis.Redis(host="localhost", port=6379, db=0)
# 1. Cache with TTL (availability, public listing)
r.setex(f"avail:{event_id}", 10, json.dumps(free_seats)) # TTL 10s: "fresher-ish" data
# 2. Atomic counter (per-IP rate limit, tickets per user)
r.incr(f"rate:{ip}:{minute}", ex=60)
# 3. Distributed lock (prevent double gift-card redemption across 2 pods)
lock = r.lock(f"gift:{card_code}", timeout=5, blocking_timeout=2)
with lock: # SET NX + expiry: the lock dies if the pod dies
charge_the_ledger(...)
# 4. Leaderboard (top sales) — sorted sets
r.zincrby("sales:ranking", amount, event_id)
r.zrevrange("sales:ranking", 0, 9, withscores=True)
# 5. Simple queue (Celery's broker: Lesson 29)
r.lpush("jobs:emails", job_id)What you must know: Redis is single-threaded (atomic commands: that is why INCR and SET NX solve concurrency without locks on your side), in-memory (durability depends on RDB/AOF config: it is not your source of truth), and SQL-less (key-based access patterns: if you need "all the X where condition", that's not Redis).
3. JSONB: the NoSQL you already have
Semi-structured fields with an index, inside Postgres:
ALTER TABLE events_event ADD COLUMN metadata JSONB DEFAULT '{}';
CREATE INDEX idx_event_metadata ON events_event USING GIN (metadata);
SELECT * FROM events_event
WHERE metadata @> '{"venue_type": "stadium"}'; -- the GIN index serves itWhen: attributes that vary per event (capacity config per zone, organizer preferences) without a schema that evolves through migrations. What you lose: per-field constraints, NOT NULLs, hard types. Rule: JSONB for decorations; columns for identity and money.
4. Anti-patterns you will see (and how to respond)
- "Mongo because the schema changes fast" → the schema changes through versioned migrations precisely to control that change (Lesson 11). What changes daily is not the schema: it's the discipline.
- "Redis as primary DB" → the queue loses messages if Redis dies (partial persistence), no ad hoc queries, everything must fit in RAM. Redis is a co-factor, not the protagonist.
- "Polyglot everywhere" → every new store is: another deploy, another backup, another failure mode, another set of drivers. The cost is paid in operations (Module 9), not in the MVP.
- Cache stampede → 1000 requests hit the DB when the hot cache expires. Antidote: TTL with jitter, or a regeneration lock (only one worker recomputes).
Self-assessment
- Which three questions do you ask before adding a new store to the stack?
- Why does Redis's INCR solve the rate limit without a race condition, while a Postgres
SELECT + UPDATE(without FOR UPDATE) doesn't? - What happens in production if the gift-card lock has no TTL and the pod dies holding it?
- When do you choose JSONB over real columns, and which constraint do you lose along the way?
- Your availability cache's hit rate is 40% with a 10s TTL. What do you look at: the TTL, the key, or that the query shouldn't be cached at all?
Continue with the exercises. The solutions only after trying it yourself.