Design a Ticket Booking System

Overselling is the one bug that matters, so seat inventory and holds live in a store that can reject the second writer.

SyntaxUPDATE seats SET held_by = $1, hold_expires_at = now() + interval '10 minutes' WHERE id = $2 AND sold_at IS NULL AND (held_by IS NULL OR hold_expires_at < now()) RETURNING id

A ticket system sells a finite, named thing: seat 14C at one performance. Overselling is not a degraded experience, it is a refund, a support ticket and sometimes a legal obligation. This is the design where "eventually consistent" is the wrong answer for one small part of the data and the right answer for everything else — the seat map image, the artist page and the recommendation rail can all be stale and cached hard.

Three stores, one of them strict

Separate inventory, holds and orders. Inventory and holds belong in a store that can reject the second writer: a conditional UPDATE in PostgreSQL, a ConditionExpression in DynamoDB, a read-write transaction in Spanner. Orders, receipts and emails can live anywhere. A hold is a row — held_by plus hold_expires_at — written in the same transaction that claims the seat. Redis may keep a copy so the seat map renders fast, but it must not be the only record: a failover that loses the hold resells a seat someone is paying for.

Claim, then confirm

The claim is one statement, never read-then-write. Put the condition in the WHERE clause and let the affected row count be the answer. Treat expiry lazily — any hold past its timestamp is free on read — and run a sweeper only to tidy up, so correctness never depends on a cron job being on time. Confirmation after payment must be idempotent on the order id, because the payment step will be retried.

What trips people up

An onsale is a thundering herd on one event's rows. The answer is admission control in front — a waiting room that issues tokens at the rate the strict store can serve — not a bigger database.

Example

Example · javascript
// Seat hold and confirm against PostgreSQL. The seat row is the only authority
// on who holds 14C; seat maps, prices and availability counts may be cached.
import { Pool } from 'pg';

const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const HOLD_SECONDS = 600; // the shopper's checkout window

// One statement, not SELECT-then-UPDATE: the WHERE clause IS the concurrency
// control, and rowCount is the verdict. Two shoppers racing for the same seat
// produce one row updated and one zero rows updated.
export async function holdSeat(seatId, cartId) {
  const { rows } = await pool.query(
    `UPDATE seats
        SET held_by = $1,
            hold_expires_at = now() + ($3 * interval '1 second')
      WHERE id = $2
        AND sold_at IS NULL
        -- expiry is evaluated here, so a lapsed hold is free whether or not
        -- the sweeper has run yet; held_by = $1 makes a retry a no-op
        AND (held_by IS NULL OR held_by = $1 OR hold_expires_at < now())
    -- RETURNING is shorthand for a second SELECT of the same row in the same
    -- transaction: identical result, one round trip instead of two
    RETURNING id, hold_expires_at`,
    [cartId, seatId, HOLD_SECONDS]
  );
  return rows[0] ?? null; // null means somebody else holds it
}

// Runs after the payment provider reports success. Webhooks repeat, so this has
// to be safe to call twice with the same order id.
export async function confirmSeats(orderId, seatIds, cartId) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    const { rowCount } = await client.query(
      `UPDATE seats
          SET sold_at = COALESCE(sold_at, now()), -- a replay keeps the first sale time
              order_id = $1,
              held_by = NULL,
              hold_expires_at = NULL
        WHERE id = ANY($2::bigint[])
          AND (order_id = $1                      -- already sold to THIS order
               OR (held_by = $3
                   AND hold_expires_at > now()
                   AND sold_at IS NULL))`,
      [orderId, seatIds, cartId]
    );
    if (rowCount !== seatIds.length) {
      // A hold lapsed while the card was being charged. Do not sell anyway:
      // roll back and send the order down the refund path.
      await client.query('ROLLBACK');
      return { ok: false, reason: 'hold_expired' };
    }
    await client.query('COMMIT');
    return { ok: true };
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

When to use it

  • Selling reserved seating for one performance where a double sale means a refund and a complaint.
  • Giving a shopper a ten-minute checkout window without losing the seat if they close the tab.
  • Surviving a 10:00 onsale where most of the day's traffic arrives in the first thirty seconds.
  • Deciding which parts of the event page can be cached for minutes and which must read the authoritative row.

More examples

Best-available seats with SKIP LOCKED

Shows the queue-worker pattern applied to inventory: each shopper takes the next unlocked candidate rather than waiting on the same row. Reach for it when selection is "any N that qualify" rather than "these exact ids" — best-available seating, voucher pools, hotel rooms of one type. Note that it does not give you adjacency; seats together needs a window function over seat_number before the lock.

Example · sql
-- "Give me the four cheapest seats still free." Without SKIP LOCKED every
-- concurrent shopper queues behind the same first candidate row and the onsale
-- serialises. SKIP LOCKED is PostgreSQL 9.5 and later.
BEGIN;

WITH candidate AS (
  SELECT id
    FROM seats
   WHERE event_id = $1
     AND sold_at IS NULL
     AND (held_by IS NULL OR hold_expires_at < now())
   ORDER BY price_cents, row_label, seat_number
   LIMIT 4
     FOR UPDATE SKIP LOCKED   -- FOR UPDATE on its own would block instead
)
UPDATE seats s
   SET held_by = $2,
       hold_expires_at = now() + interval '10 minutes'
  FROM candidate c
 WHERE s.id = c.id
RETURNING s.id, s.row_label, s.seat_number, s.price_cents;

COMMIT;

-- The longhand this replaces, and why it is worse under load:
--   SELECT id FROM seats WHERE ... LIMIT 4 FOR UPDATE;  -- blocks on row 1
--   UPDATE seats SET held_by = ... WHERE id IN (...);   -- second round trip

General admission is a counter, not seats

The contrast with reserved seating: capacity is a number, so the hold is a decrement and the rollback is an increment. Reach for it for festival tiers, webinar places or merchandise, and for anything where the count is high enough that a single row becomes the bottleneck — the striping is what turns one hot key into twenty warm ones, at the cost of a sell-out check that has to sum the stripes.

Example · javascript
// No seat identity, just capacity. The entire correctness argument is one
// conditional expression: DynamoDB rejects the write rather than letting
// remaining go below zero.
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb';

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

// One hot item is capped at 1,000 WCU/s in DynamoDB, so a big onsale shards the
// counter: N stripes, each holding capacity/N, picked at random by the writer.
const STRIPES = 20;

export async function reserveGA(eventId, qty) {
  const stripe = Math.floor(Math.random() * STRIPES);
  try {
    const out = await ddb.send(new UpdateCommand({
      TableName: 'event_inventory',
      Key: { pk: `event#${eventId}`, sk: `stripe#${stripe}` },
      UpdateExpression: 'SET remaining = remaining - :q',
      ConditionExpression: 'remaining >= :q', // the whole guarantee, server side
      ExpressionAttributeValues: { ':q': qty },
      ReturnValues: 'UPDATED_NEW',
    }));
    return { ok: true, remainingInStripe: out.Attributes.remaining };
  } catch (err) {
    // Checking .name avoids depending on which package exports the error class.
    if (err.name === 'ConditionalCheckFailedException') {
      return { ok: false, reason: 'stripe_empty' }; // try another stripe
    }
    // A timeout is NOT a failure: the decrement may have applied. Retry with
    // the same request id and let the reservation record deduplicate.
    throw err;
  }
}

Admission control in front of the onsale

Shows load shedding as a product feature rather than an error page: the queue absorbs the spike and the database sees a flat line. Reach for it whenever demand for one row arrives in a thirty-second window. The position number is also the honest answer to give the shopper, which is why this beats retry-with-backoff in the client.

Example · javascript
// 10:00 onsale: 400k shoppers, one event's rows. The waiting room exists so the
// strict store only ever sees the rate it can actually serve.
const CHECKOUT_SLOTS = 2_000; // concurrent shoppers allowed into seat selection
const SLOT_TTL_S = 900;       // a slot not used within 15 minutes is reclaimed

// Join: one sorted-set entry per shopper, scored by arrival time.
export async function joinQueue(eventId, shopperId) {
  const key = `queue:${eventId}`;
  await redis.zAdd(key, { score: Date.now(), value: shopperId }, { NX: true });
  const rank = await redis.zRank(key, shopperId); // 0-based position
  return { position: rank + 1 };
}

// Admit: runs on a timer, not per request, so the admission rate is a knob you
// control rather than a function of incoming traffic.
export async function admitBatch(eventId) {
  const active = await redis.sCard(`admitted:${eventId}`);
  const free = CHECKOUT_SLOTS - active;
  if (free <= 0) return 0;

  // Pop the oldest `free` shoppers off the front of the queue. In node-redis the
  // counted form is its own method: zPopMin(key) takes no count and returns one
  // member, so popping a batch is zPopMinCount(key, count) -> [{ value, score }].
  const next = await redis.zPopMinCount(`queue:${eventId}`, free);
  for (const { value: shopperId } of next) {
    await redis.sAdd(`admitted:${eventId}`, shopperId);
    // The token is what the seat-selection endpoints check. Short TTL: an
    // abandoned tab gives its slot back without anyone intervening.
    await redis.set(`token:${eventId}:${shopperId}`, '1', { EX: SLOT_TTL_S });
  }
  return next.length;
}

Discussion

  • Be the first to comment on this lesson.