System Design
T8

Booking: hold and confirm several nights

Hold a room for several nights without a double booking. Redis holds the nights while the guest pays. Postgres decides who gets the room.

Not startedSaved in this browser only.
  1. 1One inventory row per room type per night. That row is the lock.
  2. 2All nights or none: one atomic script in Redis, one transaction in Postgres.
  3. 3Redis holds the nights while the guest pays. Postgres decides at confirm.
  4. 4Lock nights in date order. Commit only if rows changed equals nights.
T8
    A

    The race

    check, then act
    Guest Ainventory rowOct 7 · total 1Guest Bfree on Oct 7?t1yes, 0 of 1 takenfree on Oct 7?t2yes, 0 of 1 takenbook Oct 5 to 8t3book Oct 7 to 9t4✕ Oct 7 now has 2 guests for 1 room
    • Every night is a separate unit of stock.
    • A stay gets all its nights or none.
    • The availability check and the take are one atomic step.

    The check and the write must be one atomic step, or two guests both see the room as free.

    B

    Which lock, where

    for a multi-night stay
    methodwherestatus
    Read availability, then insertPostgresNot approved
    FOR UPDATE on night rows, in date orderPostgresApproved
    Conditional UPDATE, count rowsPostgresApproved
    Version column per night (optimistic)PostgresLow contention
    SERIALIZABLE, retry on abortPostgresLow contention
    Exclusion constraint on a date rangePostgresOne room
    Redis lock as the only lockRedisNot approved
    Redlock around the transactionRedisNot approved
    LOCK TABLE inventoryPostgresNot approved

    Redlock adds a network round trip and still cannot fence a paused client. The row lock already serializes the guests.

    I use row locks in a fixed order plus a conditional update. Redis only reduces contention.

    C

    Schema

    entities, then SQL
    1 : n nightsassigned at check-inroom_type_inventoryPKhotel_idPKroom_type_idPKnightdatetotalintreservedintCHECK reserved ≤ totalreservationsPKidroom_type_idwhich stockcheck_indatecheck_outdateUQidempotency_keyCHECK check_out > check_inroom_staysroom_idone physical roomFKreservation_idstaydaterangeEXCLUDE overlapping stays
    rows
    1M hotels × 5 room types × 365 nights ≈ 1.8 billion rows a year
    shard
    by hotel_id, so one confirm touches one shard
    partition
    by month; drop past nights to an archive
    inventory and bookingssql
    -- One row per room type per night. This row is the lock.
    CREATE TABLE room_type_inventory (
      hotel_id     bigint NOT NULL,
      room_type_id bigint NOT NULL,
      night        date   NOT NULL,
      total        int    NOT NULL,
      reserved     int    NOT NULL DEFAULT 0,
      PRIMARY KEY (hotel_id, room_type_id, night)1,
      CHECK (reserved BETWEEN 0 AND total)2
    );
    
    -- One row per booking. The unique key makes a retried request safe.
    CREATE TABLE reservations (
      id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
      hotel_id        bigint NOT NULL,
      room_type_id    bigint NOT NULL,
      guest_id        bigint NOT NULL,
      check_in        date   NOT NULL,
      check_out       date   NOT NULL,
      status          text   NOT NULL DEFAULT 'confirmed',
      idempotency_key text   NOT NULL UNIQUE3,
      CHECK (check_out > check_in)4
    );
    1. 1One row per room type per night. Locks are per night, so stays that share no night never wait for each other.
    2. 2The database refuses an oversell, even when the application has a bug.
    3. 3A retried request finds its key and returns the first booking.
    4. 4A stay is [check-in, check-out). The check-out day is not a night.

    I keep one inventory row per room type per night. That row is the unit I lock.

    D

    The request path

    click a step; its path lights up
    Guestbrowser or appLoad balancerspreads requestsBooking servicestateless, N copiesCache and searchstale is fineRedisholds, TTL 10 minPostgresinventory, bookingsPayment providerauthorize, capture

    Step 1: Search

    • Show rooms that are probably free.
    • Stale data is acceptable here. Later steps check again.
    • Cache availability per hotel and date for 30 to 60 seconds.

    If it fails

    Show slightly stale results. Nothing is booked at this step, so nothing is lost.

    Search can be stale, the hold is fast and can be lost, and only the confirm transaction is the truth.

    E

    Capabilities used

    what each tool gives you
    toolcapabilitywhat it gives this designalso used for
    RedisCommands run one at a time on a single threadEach command is atomic with no lock of your own.Counters, rate limits
    RedisServer-side scripts (EVAL, Functions)Check every night, then write every night, as one step. A script can branch on what it reads.Inventory decrement, token bucket
    RedisSET with NX and an expiry (PX)Create a key only if it is absent, with a time to live, in one command.Idempotency keys, simple locks
    RedisKey expiry (TTL)An abandoned hold removes itself. No cleanup job.Sessions, caches
    RedisSorted sets, scored by timeCount active holds per night and drop expired ones by score.Leaderboards, sliding windows, delay queues
    RedisCluster hash tags: {room:42}All nights of one room live in one slot, so one script can touch them all.Any multi-key operation in a cluster
    RedisAsynchronous replicationLimit A failover can lose recent holds. Redis cannot be the truth.
    PostgresTransactionsAll nights or none. A failure rolls everything back.Every multi-row change
    PostgresRow locks: SELECT ... FOR UPDATEGuests who want the same night queue on that row.Wallet transfers, counters
    PostgresConditional UPDATE and its row countCheck and take in one statement. The count says how many nights you got.Stock, quotas, seat maps
    PostgresCHECK constraintThe database refuses an oversold night, whatever the code does.Balances that must not go negative
    PostgresUnique index and ON CONFLICTA retried request finds its key. A duplicate waits on the index.Idempotent APIs, dedupe
    PostgresExclusion constraint on a rangeNo two stays overlap for one physical room.Calendars, seat and desk booking

    Redis gives me an atomic multi-key check-and-set with expiry. Postgres gives me row locks, constraints and transactions, so it is the source of truth.

    F

    Hold several nights in Redis

    pseudo code
    hold and releasepseudo code
    hold(room, nights, token, ttl):          // runs as ONE atomic step1 in Redis
      key(night) = "hold:{room:42}2:" + night
      taken = nights WHERE key(night) EXISTS3
      IF taken is not empty:
        RETURN taken4                          // write nothing at all
      FOR EACH night IN nights:
        SET key(night) = token, expire after ttl5
      RETURN []                               // the hold is yours
    
    release(room, nights, token):             // also ONE atomic step
      FOR EACH night IN nights:
        IF key(night) == token6: DELETE key(night)
    1. 1Redis runs a script to the end before any other command. No other guest can act between the check and the write.
    2. 2Hash tag. All nights of room 42 share one cluster slot, so one script can use all of them.
    3. 3First pass: check every night. Write nothing yet.
    4. 4Any night held: stop and report which nights. The guest holds nothing.
    5. 5The expiry is set in the same command, so a crashed client cannot leave a hold forever.
    6. 6Delete only your own nights. Your hold may have expired and gone to another guest.
    Tested source Redis script: hold · Redis script: release
    Redis script: holdlua
    -- Hold one room for every night of a stay, or for no night.
    -- KEYS    one key per night: hold:{room:42}:2026-10-05
    -- ARGV[1] hold token, unique to this guest's attempt
    -- ARGV[2] time to live, in milliseconds
    -- Returns the 1-based positions of nights already held. Empty means the hold is yours.
    local taken = {}
    for i, key in ipairs(KEYS) do
      if redis.call('EXISTS', key) == 1 then
        taken[#taken + 1] = i
      end
    end
    if #taken > 0 then
      return taken
    end
    for _, key in ipairs(KEYS) do
      redis.call('SET', key, ARGV[1], 'PX', ARGV[2])
    end
    return taken
    Redis script: releaselua
    -- Release only the nights this token still holds.
    -- If a hold expired and another guest took the night, that night stays with the other guest.
    local released = 0
    for _, key in ipairs(KEYS) do
      if redis.call('GET', key) == ARGV[1] then
        redis.call('DEL', key)
        released = released + 1
      end
    end
    return released
    methodstatuswhy
    One script for all nightsApprovedAtomic. All nights or none.
    SET NX per night, from the clientNot approvedA failure on night 3 leaves nights 1 and 2 held. A crash leaves them held until expiry.
    MULTI / EXECNot approvedQueues commands but cannot branch on a result. It cannot stop when a night is taken.
    WATCH, then MULTI / EXECLow contentionWorks, but aborts and retries under contention. The script is simpler.

    One server-side script checks every night, then writes every night. Redis runs it as one step, so no other guest can act in between.

    G

    Try it: two guests, one room

    recorded from Redis

    Room 42, October 2026. Click a night to start a stay, then click the last night.

    Mon5
    Tue6
    Wed7
    Thu8
    Fri9
    Sat10
    Sun11
    Guest Aholds first
    Guest Btries second
    • A held by A
    • B held by B
    • ✕ clash
    • · free, not written
    Hold refused. 1 night is held by guest A. Guest B writes no key, not even for free nights.
    > EVALSHA 92f12f4984df… 3 hold:{room:42}:2026-10-07 hold:{room:42}:2026-10-08 hold:{room:42}:2026-10-09 b 600000
    < 1
    Redis key (guest B)value after the script
    hold:{room:42}:2026-10-07"a" (unchanged)
    hold:{room:42}:2026-10-08not set
    hold:{room:42}:2026-10-09not set

    If any night clashes, the script returns its position and writes nothing at all.

    H

    Confirm in Postgres

    pseudo code, then SQL
    one transactionpseudo code
    confirm(stay, key):
      BEGIN
        insert booking with key, skip if the key exists1
        IF the key existed: RETURN the first booking    // a retry
        lock the night rows, in date order2               // others wait here3
        rows = take 1 room on each night WHERE reserved < total4
        IF rows != number of nights5:
          ROLLBACK; RETURN sold out                      // nights go back
      COMMIT
    1. 1Unique index plus ON CONFLICT DO NOTHING. A duplicate that arrives at the same time waits on the index, then sees the key.
    2. 2Every transaction locks nights in the same order. Two stays can never wait for each other in a cycle (deadlock).
    3. 3SELECT ... FOR UPDATE. A second guest who wants the same night waits until the first commits or rolls back.
    4. 4A conditional UPDATE: take only nights that have a room free.
    5. 5The row count is the check. One full night fails the whole stay.
    Tested source SQL: claim, lock, take · Go: the row-count check
    SQL: claim, lock, takesql
    -- Claim the idempotency key first. A retry of the same request finds the key taken.
    INSERT INTO reservations (hotel_id, room_type_id, guest_id, check_in, check_out, idempotency_key)
    VALUES ($1, $2, $3, $4, $5, $6)
    ON CONFLICT (idempotency_key) DO NOTHING
    RETURNING id;
    
    -- Lock every night of the stay, in date order. A fixed order prevents deadlocks.
    SELECT night
    FROM room_type_inventory
    WHERE hotel_id = $1 AND room_type_id = $2
      AND night >= $3 AND night < $4
    ORDER BY night
    FOR UPDATE;
    
    -- Take one room on each night that has one free.
    UPDATE room_type_inventory
    SET reserved = reserved + 1
    WHERE hotel_id = $1 AND room_type_id = $2
      AND night >= $3 AND night < $4
      AND reserved < total;
    Go: the row-count checkgo
    tag, err := tx.Exec(ctx, confirm["take_nights"], r.HotelID, r.RoomTypeID, r.CheckIn, r.CheckOut)
    if err != nil {
      return Reservation{}, fmt.Errorf("take nights: %w", err)
    }
    if tag.RowsAffected() != int64(nights) {
      return Reservation{}, ErrSoldOut // the deferred rollback returns every night taken
    }
    if err := tx.Commit(ctx); err != nil {
      return Reservation{}, fmt.Errorf("commit: %w", err)
    }
    ✕ Opposite orderTx 1Tx 2Oct 7Oct 8holdsholdsboth waitEach waits for the other: a deadlock.Postgres kills one after deadlock_timeout.✓ Date orderTx 1Tx 2Oct 7Oct 8holdsholdswaits for Oct 7Tx 2 waits on the first night only.Tx 1 commits; Tx 2 then runs. No cycle.

    I claim the idempotency key, lock the nights in date order, take a room per night, and commit only if every night was taken.

    I

    A room type with N rooms

    pseudo code
    hold, countedpseudo code
    hold(room_type, nights, free[], token, ttl):   // ONE atomic step
      now = Redis clock1
      FOR EACH night i:
        holds = sorted set "holds:{rt:7}:" + night   // member = token, score = expiry
        remove from holds every score < now2
        IF token not in holds3 AND size(holds) >= free[i]4:
          full += night
      IF full is not empty: RETURN full
      FOR EACH night: add token to holds with score now + ttl
      RETURN []
    1. 1Use the Redis clock. Client clocks differ.
    2. 2Expired holds leave the set and free their room. No cleanup job.
    3. 3The same token again is a renewal, not a second room.
    4. 4Free rooms come from Postgres: total minus confirmed. Active holds must stay below it.
    Tested source Redis script: hold, counted
    Redis script: hold, countedlua
    -- Hold one room of a room type for every night of a stay, or for no night.
    -- KEYS      one sorted set per night: holds:{rt:7}:2026-10-05
    --           member = hold token, score = expiry time in ms
    -- ARGV[1]   hold token
    -- ARGV[2]   time to live, in milliseconds
    -- ARGV[2+i] rooms free on night i (total minus confirmed), read from Postgres
    -- Returns the 1-based positions of full nights. Empty means the hold is yours.
    local token, ttl = ARGV[1], tonumber(ARGV[2])
    local t = redis.call('TIME')                          -- the Redis clock, not the caller's
    local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
    
    local full = {}
    for i, key in ipairs(KEYS) do
      redis.call('ZREMRANGEBYSCORE', key, '-inf', now)    -- expired holds free their room
      local mine = redis.call('ZSCORE', key, token)
      if not mine and redis.call('ZCARD', key) >= tonumber(ARGV[2 + i]) then
        full[#full + 1] = i
      end
    end
    if #full > 0 then
      return full
    end
    for _, key in ipairs(KEYS) do
      redis.call('ZADD', key, now + ttl, token)
      redis.call('PEXPIRE', key, ttl)                     -- the set goes when its last hold goes
    end
    return full

    For a room type, each night keeps a sorted set of holds, scored by expiry. A hold fits only if every night still has a free room.

    J

    One specific room

    exclusion constraint
    one physical roomsql
    -- A specific room, not a room type: the database refuses two stays that overlap.
    CREATE TABLE room_stays (
      room_id        bigint    NOT NULL,
      reservation_id bigint    NOT NULL,
      stay           daterange2 NOT NULL,
      EXCLUDE USING gist (room_id WITH =, stay WITH &&)1
    );
    1. 1Same room and overlapping range: the insert fails with error 23P01.
    2. 2Half-open: [Oct 5, Oct 8). A stay that starts on Oct 8 does not overlap.
    • Sell by room type. Assign the physical room at check-in.
    • Use the same constraint for any single resource: a seat, a desk, a meeting room.
    • The extension btree_gist lets one index combine = and &&.

    For one physical room, a date-range exclusion constraint makes Postgres refuse any overlapping stay.

    K

    Failure cases

    what breaks, and why it stays correct
    eventresultwhy it is safesaved by
    The hold expires while the guest paysAnother guest can hold the nights.Confirm decides. The loser gets sold out, and the card authorization is voided.Postgres
    Redis fails over and loses holdsTwo guests hold the same night.Same as above. Postgres admits one.Postgres
    The client retries confirm after a timeoutThe retry finds the idempotency key.It returns the first booking. No second room, no second charge.Unique key
    Two guests confirm the last room togetherThe second waits on the row lock.It then sees reserved = total. Rows changed < nights, so it rolls back.Row lock
    Stays 5 to 8 and 7 to 10 confirm togetherBoth lock night 7 first.One waits; no cycle, no deadlock.Lock order
    A slow guest releases after expiryAnother guest holds the nights now.The release deletes only keys that carry the caller's token.Token check
    Commit succeeds, capture failsBooked but not paid.Retry the capture with its idempotency key. A reconcile job checks unpaid bookings.Reconcile job
    L

    Scale ladder

    start simple; climb only on a signal
    Each step adds one component1Postgres2+ replicas, cache3+ Redis holds4+ shards5+ waiting roommore load →
    Capacity against demand101001k10k100kDemand, average: 17 confirms or holds per secondDemand, average17Demand, 10× peak: 200 confirms or holds per secondDemand, 10× peak200Postgres, one hot row: 2,500 confirms or holds per secondPostgres, one hot row2,500Postgres, spread: 19,500 confirms or holds per secondPostgres, spread19,500Redis, 3-night hold: 55,000 confirms or holds per secondRedis, 3-night hold55,000confirms or holds per second, log scale
    stepaddit handlesmove up when you see
    1One Postgres. A hold is a booking row with status "held" and an expiry. A job returns expired holds.About 2,500 confirms a second on one hot room type, and about 19,500 spread over many. Most hotel sites never need more.Search queries slow down the primary.
    2Read replicas and a cache for search.Search grows with replicas. Reads are 100 times the writes; writes stay on the primary.A flash crowd on the same nights: lock waits, timeouts, many abandoned holds.
    3Redis holds, as on this sheet.About 55,000 three-night hold scripts a second on one Redis. Losers get a fast "no" before they reach Postgres.One primary nears its write or storage limit.
    4Shard Postgres by hotel_id.Add shards as hotels grow. Each confirm stays on one shard.One event wants far more than its rows can commit: 100,000 people for 500 rooms.
    5A waiting room per hot event.Admit guests at the rate the hot rows can commit. Everyone else waits in a fair queue.Top of the ladder.

    Demand example: 1.5 million room nights a day. Capacity measured in the lab, so read it as orders of magnitude. Do not start at step 3. Redis adds a second store to run, and it never removes the Postgres check.

    I start with one Postgres and holds as rows. I add Redis holds only when a flash crowd makes guests wait on the same rows.

    M

    Drill

    predict, then reveal

    0 of 9 known

    1. Guest B wants 3 nights. Guest A holds night 2. Which keys does guest B write?

    2. Why lock the nights in date order?

    3. Why is the Redis hold not the lock that decides?

    4. The UPDATE changed 2 rows. The stay has 3 nights. What happens next?

    5. Why claim the idempotency key before you lock the nights?

    6. What does the hash tag in hold:{room:42}:2026-10-05 do?

    7. The room type has 20 rooms. How does the hold change?

    8. Can SERIALIZABLE isolation replace FOR UPDATE here?

    9. A guest checks out on the 8th and the next guest checks in on the 8th. Is that a conflict?

    N

    Numbers to say

    measured in the lab
    demand
    1.5 million room nights a day is 17 confirms a second. A 10× peak is 200 a second.
    hot row
    About 2,500 confirms a second on one room type and night.
    spread
    About 19,500 confirms a second over 32 room types.
    Redis
    About 55,000 three-night holds a second; 78,000 plain SETs.
    rows
    1M hotels × 5 room types × 365 nights is 1.8 billion a year.
    reads
    100 or more searches per booking.

    Postgres 16 and Redis 8 on an 8-core laptop, 32 clients. A server that flushes every commit to durable storage commits slower. Use these as orders of magnitude.