If you’ve ever booked concert tickets or a flight, you’ve seen that countdown timer: “You have 10 minutes to complete your purchase.” That timer isn’t just UX theatre — it’s a distributed lock with a TTL. This post walks through why that pattern exists and how to implement it with Redis.
The problem with naive ticket booking Link to heading
The simplest correct implementation of a booking system uses a database transaction:
BEGIN;
SELECT status FROM tickets WHERE id = $1 FOR UPDATE; -- row-level lock
-- if status = 'available', proceed
UPDATE tickets SET status = 'booked', booked_by = $2 WHERE id = $1;
COMMIT;
This prevents double-bookings. But the lock is held for as long as the transaction is open — and if you hold it open while the user fills in their payment form, you’ve just locked other buyers out for 5 minutes.
Two approaches to short-lived reservations Link to heading
Approach 1: Expirable status field (PostgreSQL) Link to heading
Add a reserved_until timestamp column. Your SELECT checks status = 'available' OR (status = 'reserved' AND reserved_until < NOW()) and updates atomically. No lock is held between page loads — each checkout attempt is a short transaction.
This works and keeps everything in one system. The tradeoff: read queries get more complex, and the seat map shows stale “reserved” rows until a cron job sweeps them up.
The cron job runs on a fixed schedule (e.g., every minute) and resets expired reservations:
UPDATE tickets
SET status = 'available', reserved_by = NULL, reserved_until = NULL
WHERE status = 'reserved' AND reserved_until < NOW();
Because Postgres doesn’t expire rows on its own, this cleanup step is mandatory — without it, a seat that Alice abandoned mid-checkout stays marked reserved forever and no one else can book it. The cron job is effectively a manual substitute for the automatic key expiry that Redis provides in Approach 2.
Approach 2: Redis distributed lock with TTL Link to heading
Use Redis as a side-car reservation store. The Postgres table has only two states — available and booked. The transient “reserved” state lives entirely in Redis as a key with an automatic expiry:
lock:ticket:42 → "user-uuid-alice" (expires in 10 minutes)
When the key exists, the seat is taken. When the key expires (because Alice walked away), the seat is automatically available again. No cron job needed.
Redis is the right tool here for 3 reasons:
- Atomic SET NX EX: a single command acquires the lock with no Time-Of-Check To Time-Of-Use gap.
SET lock:ticket:42 "alice-uuid" NX EX 600
NX— only set if the key does not existEX 600— expire automatically after 600 seconds
Either the key is created with the TTL, or the command fails because someone else already holds it. There is no window between “check” lock and “set” lock.
Automatic expiry: Redis expires keys without application intervention.
In-memory speed: lock acquisition and release are sub-millisecond, important at high concurrency during on-sales.
Project Setup Link to heading
lab/
├── docker-compose.yml
├── requirements.txt
├── setup.sql
├── booking_service.py
└── demo.py
# docker-compose.yml
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --appendonly yes
postgres:
image: postgres:15-alpine
environment:
POSTGRES_DB: ticketing
POSTGRES_USER: ticketing
POSTGRES_PASSWORD: ticketing
ports:
- "5432:5432"
volumes:
- ./setup.sql:/docker-entrypoint-initdb.d/setup.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ticketing"]
interval: 5s
timeout: 5s
retries: 5
# requirements.txt
redis==5.2.1
psycopg2-binary==2.9.10
-- setup.sql
CREATE TABLE events (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
date TIMESTAMP NOT NULL
);
CREATE TABLE tickets (
id SERIAL PRIMARY KEY,
event_id INTEGER REFERENCES events(id),
seat_number VARCHAR(10) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available',
booked_by VARCHAR(255),
booked_at TIMESTAMP,
CONSTRAINT chk_status CHECK (status IN ('available', 'booked'))
);
INSERT INTO events (name, date) VALUES ('Taylor Swift - World Tour', '2023-11-15 19:00:00');
INSERT INTO tickets (event_id, seat_number) VALUES
(1, 'A1'), (1, 'A2'), (1, 'A3'),
(1, 'B1'), (1, 'B2'), (1, 'B3');
# booking_service.py
import logging
from typing import Optional
import psycopg2
import psycopg2.extras
import redis
# Atomically release a lock only when the caller is the current owner.
# Returns 1 on success, 0 if the key doesn't exist or belongs to someone else.
_LUA_RELEASE = """
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
"""
# Atomically acquire N locks or none.
# KEYS = list of lock keys
# ARGV[1] = user_id (lock value)
# ARGV[2] = TTL in seconds
# Returns 1 if all acquired, 0 if any failed (releases already-held locks).
_LUA_ACQUIRE_MULTI = """
local acquired = {}
for i = 1, #KEYS do
local ok = redis.call("SET", KEYS[i], ARGV[1], "NX", "EX", ARGV[2])
if ok then
table.insert(acquired, KEYS[i])
else
for _, k in ipairs(acquired) do
redis.call("DEL", k)
end
return 0
end
end
return 1
"""
class BookingService:
def __init__(
self,
redis_url: str = "redis://localhost:6379",
db_dsn: str = "postgresql://ticketing:ticketing@localhost:5432/ticketing",
):
self._redis = redis.Redis.from_url(redis_url, decode_responses=True)
self._db_dsn = db_dsn
self._release_lock = self._redis.register_script(_LUA_RELEASE)
self._acquire_multi = self._redis.register_script(_LUA_ACQUIRE_MULTI)
def _db(self):
return psycopg2.connect(self._db_dsn)
@staticmethod
def _lock_key(ticket_id: int) -> str:
return f"lock:ticket:{ticket_id}"
@staticmethod
def _reserved_set_key(event_id: int) -> str:
return f"event:{event_id}:reserved"
def reserve_ticket(self, ticket_id: int, user_id: str, event_id: int, ttl: int = 600) -> bool:
with self._db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT status FROM tickets WHERE id = %s", (ticket_id,))
row = cur.fetchone()
if not row or row[0] != "available":
return False
acquired = self._redis.set(self._lock_key(ticket_id), user_id, nx=True, ex=ttl)
if not acquired:
return False
self._redis.sadd(self._reserved_set_key(event_id), ticket_id)
return True
def reserve_multiple_tickets(self, ticket_ids: list[int], user_id: str, event_id: int, ttl: int = 600) -> bool:
lock_keys = [self._lock_key(tid) for tid in ticket_ids]
ok = self._acquire_multi(keys=lock_keys, args=[user_id, str(ttl)])
if ok:
self._redis.sadd(self._reserved_set_key(event_id), *ticket_ids)
return bool(ok)
def release_ticket(self, ticket_id: int, user_id: str) -> bool:
result = self._release_lock(keys=[self._lock_key(ticket_id)], args=[user_id])
return bool(result)
def confirm_booking(self, ticket_id: int, user_id: str, event_id: int) -> bool:
if self._redis.get(self._lock_key(ticket_id)) != user_id:
return False
with self._db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE tickets
SET status = 'booked', booked_by = %s, booked_at = NOW()
WHERE id = %s AND status = 'available'
RETURNING id
""",
(user_id, ticket_id),
)
if not cur.fetchone():
conn.rollback()
return False
conn.commit()
self.release_ticket(ticket_id, user_id)
self._redis.srem(self._reserved_set_key(event_id), ticket_id)
return True
def get_ttl(self, ticket_id: int) -> int:
return self._redis.ttl(self._lock_key(ticket_id))
def get_holder(self, ticket_id: int) -> Optional[str]:
return self._redis.get(self._lock_key(ticket_id))
def get_seat_map(self, event_id: int) -> list[dict]:
reserved_ids = {int(x) for x in self._redis.smembers(self._reserved_set_key(event_id))}
with self._db() as conn:
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute(
"SELECT id, seat_number, status, booked_by FROM tickets WHERE event_id = %s ORDER BY seat_number",
(event_id,),
)
rows = [dict(r) for r in cur.fetchall()]
for row in rows:
tid = row["id"]
if row["status"] == "booked":
row["display_status"] = "BOOKED"
row["reserved_by"] = None
elif tid in reserved_ids:
ttl = self._redis.ttl(self._lock_key(tid))
holder = self._redis.get(self._lock_key(tid))
if ttl > 0:
row["display_status"] = f"RESERVED ({ttl}s left)"
row["reserved_by"] = holder
else:
row["display_status"] = "AVAILABLE"
row["reserved_by"] = None
else:
row["display_status"] = "AVAILABLE"
row["reserved_by"] = None
return rows
Core implementation Link to heading
Reserving a ticket Link to heading
The method first confirms the ticket is still available in Postgres before attempting the lock. No point racing for a seat that is already permanently booked. It then calls self._redis.set(..., nx=True, ex=ttl), which maps to a single atomic SET NX EX command on the Redis server. If another user acquired the lock between the DB check and the Redis call, set returns None and we exit early. On success, the ticket ID is added to a Redis Set keyed by event so that get_seat_map can retrieve all reserved seats for an event in one SMEMBERS call rather than scanning every lock key individually.
def reserve_ticket(self, ticket_id: int, user_id: str, event_id: int, ttl: int = 600) -> bool:
# Confirm the ticket is available in DB before touching Redis
with self._db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT status FROM tickets WHERE id = %s", (ticket_id,))
row = cur.fetchone()
if not row or row[0] != "available":
return False
# Attempt lock acquisition
acquired = self._redis.set(self._lock_key(ticket_id), user_id, nx=True, ex=ttl)
if not acquired:
return False
# Track all currently locked ticket IDs (seats) for an event.
# It helps `get_seat_map` fetch all reserved IDs with a single `SMEMBERS` call
# instead of scanning every lock key individually
self._redis.sadd(self._reserved_set_key(event_id), ticket_id)
return True
Releasing a lock safely Link to heading
def release_ticket(self, ticket_id, user_id) -> bool:
result = self._release_lock(
keys=[f"lock:ticket:{ticket_id}"], args=[user_id]
)
return bool(result)
A plain DEL lock:ticket:42 would be dangerous. Any user could accidentally delete someone else’s lock. Instead, _release_lock is a Lua script registered on the Redis server that atomically checks the current value before deleting: if the key holds a different user ID, it returns 0 and the key is left untouched. Because the script runs inside Redis, no other command can sneak in between the GET and the DEL, which is exactly the guarantee a plain two-step Python check-then-delete cannot provide.
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
Confirming a booking after payment Link to heading
confirm_booking is the handoff point where Redis’s transient reservation becomes a permanent record in Postgres, and it has to handle three failure modes along the way.
The first check — GET lock:ticket:{id} — verifies we still own the Redis lock before touching the database. This matters because the TTL might have expired while the user was filling in their payment details. If Alice’s 10-minute window ran out and Bob grabbed the lock in the gap, Alice’s GET returns Bob’s user ID instead of hers, and we return False immediately to trigger a refund. No DB write is attempted.
If we do own the lock, the UPDATE writes to Postgres with WHERE id = %s AND status = 'available'. That AND status = 'available' clause is Optimistic Concurrency Control (OCC) — the database’s own safety net in case two requests somehow slip through the Redis check at the same time. If another process already booked this ticket, the UPDATE matches zero rows, RETURNING id gives back nothing, and we roll back and return False. Only one writer can succeed.
Finally, once the DB commit succeeds, the Redis lock is released and the ticket ID is removed from the event’s reserved Set. From this point Postgres is the sole source of truth — the seat shows as BOOKED on the seat map purely from the database row, with no Redis key needed.
def confirm_booking(self, ticket_id, user_id, event_id) -> bool:
# Verify we still own the lock (TTL might have expired)
if self._redis.get(f"lock:ticket:{ticket_id}") != user_id:
return False # trigger a refund flow
# Write to Postgres — WHERE clause is OCC
with self._db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE tickets
SET status = 'booked', booked_by = %s, booked_at = NOW()
WHERE id = %s AND status = 'available'
RETURNING id
""",
(user_id, ticket_id),
)
if not cur.fetchone():
conn.rollback()
return False # OCC conflict — someone beat us
conn.commit()
# Release the Redis lock; Postgres is now the source of truth
self.release_ticket(ticket_id, user_id)
self._redis.srem(f"event:{event_id}:reserved", ticket_id)
return True
Atomic multi-ticket acquisition Link to heading
Booking 2 adjacent seats with two separate SET NX calls creates a partial-acquisition problem: you might grab seat A but fail on seat B, leaving you holding a seat nobody wants. The fix is a Lua script that acquires all locks atomically or rolls back:
local acquired = {}
for i = 1, #KEYS do
local ok = redis.call("SET", KEYS[i], ARGV[1], "NX", "EX", ARGV[2])
if ok then
table.insert(acquired, KEYS[i])
else
for _, k in ipairs(acquired) do
redis.call("DEL", k)
end
return 0 -- rolled back; nothing held
end
end
return 1
def reserve_multiple_tickets(self, ticket_ids: list[int], user_id: str, event_id: int, ttl: int = 600) -> bool:
lock_keys = [self._lock_key(tid) for tid in ticket_ids]
ok = self._acquire_multi(keys=lock_keys, args=[user_id, str(ttl)])
if ok:
self._redis.sadd(self._reserved_set_key(event_id), *ticket_ids)
return bool(ok)
Edge cases Link to heading
TTL expires during payment processing — If Alice’s 10-minute window closes at minute 10 but her payment completes at minute 11, Bob might have grabbed the lock. confirm_booking checks GET lock:ticket:{id} before writing to Postgres. If Alice no longer owns it, the function returns False and the caller triggers a Stripe refund. The mitigation is a generous TTL and optionally extending it when payment is initiated.
Redis goes down — Reservations are lost. No double-bookings occur (Postgres OCC prevents that), but users may get errors after filling in payment details. This is worse UX than a system that degrades gracefully, but it’s better than all seats showing as unavailable (as a stuck cron lock would cause). Consider Redis Sentinel or Cluster for HA, and use RedLock for fencing tokens if you need stronger guarantees.
Seat-map read path — The event:{id}:reserved Set adds one Redis round-trip to every seat-map render. In practice this is fast, but it means the read path has a dependency on Redis availability. An alternative is a write-through: when you acquire the lock, also write status = 'reserved' to Postgres (accepting the extra write), and let the periodic sweep clean up stale rows. This makes the read path Redis-independent at the cost of more DB writes.
Redis Cluster and the CROSSSLOT problem — In a Redis Cluster, 16,384 hash slots are distributed across nodes and each key lands on the node that owns its slot. Single-key operations like reserve_ticket and release_ticket work fine — the client transparently follows MOVED redirects to the right node. The problem is reserve_multiple_tickets: Redis requires all keys in a Lua script _LUA_ACQUIRE_MULTI to live on the same slot, so if lock:ticket:5 and lock:ticket:6 hash to different nodes, Redis rejects the script with CROSSSLOT Keys in request don't hash to the same slot. The fix is hash tags — wrapping a common substring in {} forces Redis to hash only that part, landing all keys for the same event on the same node:
lock:{event:1}:ticket:5 → hashed on "event:1" → same slot
lock:{event:1}:ticket:6 → hashed on "event:1" → same slot
{event:1}:reserved → hashed on "event:1" → same slot
This requires updating _lock_key to accept event_id and include it in the hash tag, and switching the client from redis.Redis to redis.cluster.RedisCluster. All other logic stays the same — the Lua scripts, the OCC pattern, and the reserved Set all continue to work because every key for a given event is guaranteed to be on the same node.