When Two People Want Seat G7
Friday, 19:58. Two minutes before Night Bus starts on screen 1, and the 8 p.m. show is nearly full. Two customers, Priya and Marco, are looking at the same seat map on their phones, and within the same second both tap the same button: book seat G7.
Isolation, the I from the last page, promised that their transactions won't tangle. This page shows how the promise is kept. The short version: the database briefly makes one of them wait, or fails one of them cleanly, and either outcome beats what the spreadsheet did on that famous Saturday back in Chapter 1. Simultaneity is the everyday miracle databases perform, and it deserves to be seen honestly.
The Race, Replayed Properly
Chapter 1's double-sale is about to be re-staged, this time against the real schema. Both phones send the website's booking code down the same path, and that code runs the same transaction for each customer — only the customer_id differs:
BEGIN; INSERT INTO bookings (customer_id, screening_id, seat) VALUES (207, 88, 'G7'); COMMIT;
Two copies of that transaction now race. Here is the whole collision, moment by moment:
| Moment | Transaction 1 — Priya | Transaction 2 — Marco |
|---|---|---|
| 19:58:01.204 | BEGIN; | |
| 19:58:01.290 | INSERT … 'G7' — the row is written as pending; the database marks G7 for screening 88 as claimed by this open transaction | BEGIN; |
| 19:58:01.311 | INSERT … 'G7' — must wait: an open transaction already holds that seat's row | |
| 19:58:01.350 | COMMIT; — Priya's booking is real | still waiting (about a twentieth of a second so far) |
| 19:58:01.352 | the wait ends; UNIQUE (screening_id, seat) sees a committed G7 and refuses the insert | |
| 19:58:01.360 | ROLLBACK; — the app shows "Seat G7 was just taken" |
Read the last two rows again, because they are the payoff of a promise made back in Chapter 2. The line you were told to remember, UNIQUE (screening_id, seat), is what turns Marco's insert into a clean refusal instead of a second sale. The transaction kept the race orderly; the constraint decided the winner. Neither alone is enough — without the constraint both inserts would land, and without the transaction the refusal could arrive halfway through a multi-step booking. Design and safety, braided together.
Now compare the outcomes. The spreadsheet's Saturday gave two customers the same seat and told no one. The database's Friday gives one customer the seat and the other an honest, immediate no. Marco taps G9, is booked four seconds later, and nobody spends the intermission handing out free popcorn.
Locks, Plainly
The "must wait" row in the timeline is the work of a lock. While a transaction is changing a row, the database holds a lock on it: other transactions that want that same row wait their turn, and everyone else sails past untouched. Marco waited about a twentieth of a second. The dozens of other customers booking other seats that minute never waited at all, because they never wanted the locked row.
A shop's changing room works the same way: one key per room, and the key does the queueing. If the room you want is taken you wait a moment, nobody ever ends up sharing a cubicle, and shoppers heading for other rooms walk straight in. That is the entire mental model — per-row keys, brief waits, and the alternative to waiting is not speed but chaos. Real term from here on: locks.
The Isolation Dial Exists
One honest complication, kept at map altitude. How strictly transactions ignore each other is not a fixed law but a dial. Stricter settings mean more waiting and fewer surprises; looser settings mean less waiting and the occasional strange read. Engines ship with a sensible middle: the common default is called READ COMMITTED, which means your queries only ever see other people's committed work, never their half-done brackets.
That one term is all this book asks you to carry. The full tour of isolation levels, the names of the anomalies, and when to move the dial belong to a deep-dive course. What a beginner needs is to know the dial exists, so that the phrase "isolation level" in a team conversation lands as geography, not magic.
What You Keep
Two working rules fall out of everything above. First: keep transactions short. A bracket holds its locks until COMMIT or ROLLBACK, so a transaction that starts and then waits on a slow web page, or on a person making up their mind, forces everyone who wants those rows to queue behind it. BEGIN, do the work, COMMIT, out. Never BEGIN and go to lunch.
Second: let the database carry simultaneity. The temptation to add your own is_locked column, or to have the app check "is the seat free?" before inserting, is the road back to double-sales — two apps can both pass that check in the same instant and then both write. The constraint plus the transaction close that gap inside the engine, where no timing trick can slip through. You watched it happen at 19:58:01.
- "The database handles one user at a time." Thousands of transactions run simultaneously. Only genuine same-row conflicts ever wait, and briefly — everyone touching different rows proceeds in parallel.
- "The second booking corrupts something, or both customers get the seat." The whole point is that neither happens: one clean success, one clean, explainable failure. The bad outcome, the silent double-sale, is the one the spreadsheet had.
- "I should add my own is_locked column to be safe." Hand-rolled locks are how stuck seats and deadlocks are born. The engine's locks plus the UNIQUE constraint already solve it, and solve it better.
- "Marco's error means the system broke." The refusal is the system working. A clean "seat just taken" message is the honest alternative to selling one seat twice.
- Simultaneous users are every real system's normal state, not an edge case; knowing the database already handles them is what lets a tiny team run a busy booking site and still sleep.
- "Keep transactions short" is the one concurrency habit that prevents most real-world pain — it costs nothing and saves whole evenings of debugging mysterious queues.
- Letting constraints and transactions carry simultaneity means the safety lives in one place, inside the engine, instead of scattered through app code that can be raced.
Knowledge Check
Marco's INSERT for G7 loses the race. What does his transaction experience?
- A silent success that overwrites Priya's booking with his own
- A brief wait, then a clean constraint refusal his app can explain to him
- A crash of his session that the cinema's developer must repair by hand
- Both bookings are accepted, and a nightly cleanup job removes one of them later
While Priya's transaction holds the lock on the G7 row, who has to wait?
- Every other query in the whole database, until she commits
- All customers booking any seat for screening 88 that evening
- Only transactions that want that same row
- Nobody, because locks apply only to queries that read data
Why does the UNIQUE constraint plus a transaction beat an app-side "is the seat free?" check?
- Because app code is always slower than SQL, so the two checks time out under load
- Because application code is not allowed to read the bookings table at all
- Two app checks can both see 'free' at once; the database's rule cannot be raced
- It doesn't: the app-side check is just as safe, only a little slower
Which habit prevents most real-world concurrency pain?
- Setting the isolation dial to its loosest setting so nobody ever waits
- Adding an is_locked column so the app can manage seats itself
- Letting only one customer book at a time during busy evenings
- Keep transactions short: begin, do the work, commit
You got correct