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.
- 1One inventory row per room type per night. That row is the lock.
- 2All nights or none: one atomic script in Redis, one transaction in Postgres.
- 3Redis holds the nights while the guest pays. Postgres decides at confirm.
- 4Lock nights in date order. Commit only if rows changed equals nights.
- 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.
| method | where | status |
|---|---|---|
| Read availability, then insert | Postgres | Not approved |
| FOR UPDATE on night rows, in date order | Postgres | Approved |
| Conditional UPDATE, count rows | Postgres | Approved |
| Version column per night (optimistic) | Postgres | Low contention |
| SERIALIZABLE, retry on abort | Postgres | Low contention |
| Exclusion constraint on a date range | Postgres | One room |
| Redis lock as the only lock | Redis | Not approved |
| Redlock around the transaction | Redis | Not approved |
| LOCK TABLE inventory | Postgres | Not 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.
- 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
-- 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
);- 1One row per room type per night. Locks are per night, so stays that share no night never wait for each other.
- 2The database refuses an oversell, even when the application has a bug.
- 3A retried request finds its key and returns the first booking.
- 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.
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.
| tool | capability | what it gives this design | also used for |
|---|---|---|---|
| Redis | Commands run one at a time on a single thread | Each command is atomic with no lock of your own. | Counters, rate limits |
| Redis | Server-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 |
| Redis | SET 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 |
| Redis | Key expiry (TTL) | An abandoned hold removes itself. No cleanup job. | Sessions, caches |
| Redis | Sorted sets, scored by time | Count active holds per night and drop expired ones by score. | Leaderboards, sliding windows, delay queues |
| Redis | Cluster 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 |
| Redis | Asynchronous replication | Limit A failover can lose recent holds. Redis cannot be the truth. | |
| Postgres | Transactions | All nights or none. A failure rolls everything back. | Every multi-row change |
| Postgres | Row locks: SELECT ... FOR UPDATE | Guests who want the same night queue on that row. | Wallet transfers, counters |
| Postgres | Conditional UPDATE and its row count | Check and take in one statement. The count says how many nights you got. | Stock, quotas, seat maps |
| Postgres | CHECK constraint | The database refuses an oversold night, whatever the code does. | Balances that must not go negative |
| Postgres | Unique index and ON CONFLICT | A retried request finds its key. A duplicate waits on the index. | Idempotent APIs, dedupe |
| Postgres | Exclusion constraint on a range | No 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.
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)- 1Redis runs a script to the end before any other command. No other guest can act between the check and the write.
- 2Hash tag. All nights of room 42 share one cluster slot, so one script can use all of them.
- 3First pass: check every night. Write nothing yet.
- 4Any night held: stop and report which nights. The guest holds nothing.
- 5The expiry is set in the same command, so a crashed client cannot leave a hold forever.
- 6Delete only your own nights. Your hold may have expired and gone to another guest.
Tested source Redis script: hold · Redis script: release
-- 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-- 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| method | status | why |
|---|---|---|
| One script for all nights | Approved | Atomic. All nights or none. |
| SET NX per night, from the client | Not approved | A failure on night 3 leaves nights 1 and 2 held. A crash leaves them held until expiry. |
| MULTI / EXEC | Not approved | Queues commands but cannot branch on a result. It cannot stop when a night is taken. |
| WATCH, then MULTI / EXEC | Low contention | Works, 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.
Room 42, October 2026. Click a night to start a stay, then click the last night.
- A held by A
- B held by B
- ✕ clash
- · free, not written
> 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-08 | not set |
| hold:{room:42}:2026-10-09 | not set |
If any night clashes, the script returns its position and writes nothing at all.
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- 1Unique index plus ON CONFLICT DO NOTHING. A duplicate that arrives at the same time waits on the index, then sees the key.
- 2Every transaction locks nights in the same order. Two stays can never wait for each other in a cycle (deadlock).
- 3SELECT ... FOR UPDATE. A second guest who wants the same night waits until the first commits or rolls back.
- 4A conditional UPDATE: take only nights that have a room free.
- 5The row count is the check. One full night fails the whole stay.
Tested source SQL: claim, lock, take · Go: the row-count check
-- 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;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)
}I claim the idempotency key, lock the nights in date order, take a room per night, and commit only if every night was taken.
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 []- 1Use the Redis clock. Client clocks differ.
- 2Expired holds leave the set and free their room. No cleanup job.
- 3The same token again is a renewal, not a second room.
- 4Free rooms come from Postgres: total minus confirmed. Active holds must stay below it.
Tested source Redis script: hold, counted
-- 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 fullFor 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.
-- 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
);- 1Same room and overlapping range: the insert fails with error 23P01.
- 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.
| event | result | why it is safe | saved by |
|---|---|---|---|
| The hold expires while the guest pays | Another guest can hold the nights. | Confirm decides. The loser gets sold out, and the card authorization is voided. | Postgres |
| Redis fails over and loses holds | Two guests hold the same night. | Same as above. Postgres admits one. | Postgres |
| The client retries confirm after a timeout | The retry finds the idempotency key. | It returns the first booking. No second room, no second charge. | Unique key |
| Two guests confirm the last room together | The 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 together | Both lock night 7 first. | One waits; no cycle, no deadlock. | Lock order |
| A slow guest releases after expiry | Another guest holds the nights now. | The release deletes only keys that carry the caller's token. | Token check |
| Commit succeeds, capture fails | Booked but not paid. | Retry the capture with its idempotency key. A reconcile job checks unpaid bookings. | Reconcile job |
| step | add | it handles | move up when you see |
|---|---|---|---|
| 1 | One 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. |
| 2 | Read 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. |
| 3 | Redis 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. |
| 4 | Shard 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. |
| 5 | A 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.
0 of 9 known
Guest B wants 3 nights. Guest A holds night 2. Which keys does guest B write?
Why lock the nights in date order?
Why is the Redis hold not the lock that decides?
The UPDATE changed 2 rows. The stay has 3 nights. What happens next?
Why claim the idempotency key before you lock the nights?
What does the hash tag in hold:{room:42}:2026-10-05 do?
The room type has 20 rooms. How does the hold change?
Can SERIALIZABLE isolation replace FOR UPDATE here?
A guest checks out on the 8th and the next guest checks in on the 8th. Is that a conflict?
- 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.