Skip to main content

Flash Sale: Inventory Under Contention

100 Units, 10,000 Buyers, One Second

Here is the final exam for everything in this section, and it fits on a T-shirt: you have 100 units of something people want badly — sneakers, a console, concert tickets — and at exactly 12:00:00 you put them on sale. Demand doesn't trickle in. Ten thousand buyers arrive in the same second, every one of them trying to buy the same thing at the same instant. This is a flash sale, and it is the most hostile load a transactional system ever sees.

Everything comes down to a single row. Your inventory is a row — inventory(sku, stock) — and every purchase is one operation on it: decrement stock by 1. Normally concurrency is spread across thousands of different rows and conflicts are rare (that was the comfortable assumption behind Pessimistic vs Optimistic Locking). A flash sale does the opposite: it concentrates all contention onto one row. This is the hot-row problem, and you can't shard your way out of it, because there is only one thing everyone wants.

Two things are on the line at once, and the whole lesson is the fight between them:

  • Correctness — never oversell. With 100 units you must sell at most 100. The invariant is stock >= 0, forever, under any amount of concurrency. Sell 149 and 49 customers get a confirmation for a unit that doesn't exist.
  • Throughput — survive the burst. A scheme that's perfectly correct but forces ten thousand buyers through a single-file line turns a two-second sale into a two-minute outage.

The intuition most people carry in is that these trade off — that correct means slow, and you buy safety with a lock. The measured result of this lesson says that intuition is wrong, and watching exactly how it's wrong is the point. Here's the scoreboard we're going to earn:

The flash-sale scoreboard: four concurrency strategies for the same stampede — 100 units, 500 buyers, 50 at once — scored on correctness and throughput. Row one, naive read-modify-write, in red: it OVERSOLD, selling 149 of 100 units so the stock went to −49, at 2150 transactions per second — wrong. Row two, a pessimistic FOR UPDATE lock, in amber: correct, exactly 100 sold and 0 left, but the slowest at 1322 tps because every buyer queues on the row lock. Row three, one atomic conditional write, UPDATE stock = stock − 1 WHERE stock > 0, in green and highlighted as the winner: correct, 100 sold and 0 left, and the FASTEST at 3088 tps. Row four, SERIALIZABLE, in violet: correct, but it aborts the conflicting transactions — 93 percent hit a serialization failure and must retry — the slowest of all at 290 tps. The throughput bars make it visual: the atomic bar is the longest, the serializable bar a sliver. The lesson, integrated: correct and fast is possible — the fix is a smaller atomic operation, not a bigger lock.

The Obvious Code Oversells

Write the purchase the way anyone would the first time:

SELECT stock FROM inventory WHERE sku = 'sale';   -- read
-- (in the app)  if stock > 0:
UPDATE inventory SET stock = stock - 1 WHERE sku = 'sale'; -- act

Read the stock, check it's positive, decrement. It passes every test you write, because your tests run it one call at a time. Then the flash sale hits it with real concurrency and it oversells.

The bug is the gap between the read and the write. Under READ COMMITTED (your database's default), nothing stops a whole wave of buyers from running that SELECT in the same instant, all reading the same stock > 0, all passing the check, and all decrementing. This is the lost update from Isolation Levels & Their Anomalies and the check-then-act race from Pessimistic vs Optimistic Locking — except now it's not two threads, it's five hundred. Run it for real on Postgres (pgbench, 100 units, 500 buyers, 50 at a time) and the damage is not subtle:

strategy: naive read-modify-write
units sold ......  149      ← you had 100
final stock ....  -49      ← forty-nine orders you cannot fulfill
throughput .....  2150 tps

149 sold for 100 units. The stock went to −49. That is not an edge case you can shrug off with a few refunds — at flash-sale concurrency it's the default outcome, and it scales with how many buyers hit at once. Worse, the naive version isn't even fast (2150 tps); you got the wrong answer and paid for it. Every fix from here on has to close that read-to-write gap.

Serialize It: Correct, but You Feel It

The gap oversells because buyers overlap, so the obvious fix is to stop them overlapping — let one buyer touch the row at a time. Two ways, both correct, both with a bill attached.

Pessimistic locking (Pessimistic vs Optimistic Locking): take the row's lock before you decide, with SELECT … FOR UPDATE.

BEGIN;
SELECT stock FROM inventory WHERE sku = 'sale' FOR UPDATE;  -- exclusive row lock
UPDATE inventory SET stock = stock - 1 WHERE sku = 'sale';    -- (if stock > 0)
COMMIT;

Now every buyer queues on that lock — no two can read-then-write at the same time, so there is no oversell, stock stops exactly at 0. But look at the throughput:

strategy: pessimistic FOR UPDATE
units sold ......  100      ✓ correct
final stock ....  0
throughput .....  1322 tps  ← the SLOWEST of the four

Correct, and the slowest of every strategy (1322 tps, barely half the atomic path we're about to see). Each buyer holds the lock from the SELECT all the way to the COMMIT — across a network round trip — while everyone else waits behind them. You bought correctness with latency, one buyer at a time.

SERIALIZABLE isolation reaches the same correctness from the other direction: it lets the transactions run optimistically and aborts the ones that conflict, with a serialization_failure (SQLSTATE 40001) the client must catch and retry. Correct — but under this stampede the abort rate is brutal:

strategy: SERIALIZABLE
processed ......  33 / 500
failed .........  467  (93.4% serialization failures)
throughput .....  290 tps

93.4% of buyers were aborted and have to try again — the retries are wasted work, and it's the slowest path by a mile (290 tps). Serializable didn't make correctness free; it moved the cost into a storm of retries. So: is correctness just expensive? No. We've been reaching for a bigger lock when the answer is a smaller operation.

The Fix Is a Smaller Operation

Step back and notice what the naive version actually did wrong: it split one decision — is there stock, and if so take one — into a read, a check, and a write, with gaps between them. The fix isn't to wrap a lock around those three steps. It's to stop having three steps. Do the whole thing in one atomic statement and let the database run it:

UPDATE inventory SET stock = stock - 1
 WHERE sku = 'sale' AND stock > 0;
--  rows affected = 1  → you got a unit
--  rows affected = 0  → sold out, reject the buyer

The WHERE stock > 0 guard and the stock - 1 decrement are now a single atomic operation on the row — there is no gap for anyone to race into, because the check and the change happen together, inside the engine, on the same row lock. This is ACID, Precisely's Atomicity doing the whole job in one statement, and it's the optimistic / compare-and-set shape from Pessimistic vs Optimistic Locking pushed all the way into the write. The rows-affected count tells you if you won (1) or the item is gone (0). Measured, same stampede:

strategy: atomic conditional write
units sold ......  100      ✓ correct
final stock ....  0
throughput .....  3088 tps  ← the FASTEST of the four

Correct — and the fastest of all four (3088 tps, more than double the pessimistic lock). This is the punchline of the whole section, and it's genuinely surprising the first time: the safest option is also the quickest. Why? The pessimistic lock holds the row from the SELECT to the COMMIT, across a client round trip — milliseconds of holding a contended resource. The single UPDATE holds the row's lock only for the microseconds it takes the engine to do the write, then releases. Same correctness, a fraction of the time spent on the hot row. Under contention, the fix is usually a smaller, atomic operation — not a bigger lock.

Why the naive version oversells and the atomic write does not, side by side. On the left, in red, the naive read-modify-write: three steps with a gap — SELECT stock (reads 1), the app checks stock > 0 (true), then UPDATE stock − 1 — and because several buyers all run the SELECT during the gap, they all read 1, all pass the check, and all decrement, so the stock goes negative and the sale oversells. On the right, in green, the atomic conditional write: one statement, UPDATE inventory SET stock = stock − 1 WHERE sku = 'x' AND stock > 0 — the database performs the check and the decrement together with no gap, so a losing buyer simply matches zero rows and is told sold out. The bottom line: put the check and the change in one atomic statement; the gap between a separate read and write is where oversell lives.

One Buyer, One Unit

The atomic write makes the count correct — you'll never sell a 101st unit. But there's a second, sneakier way a flash sale goes wrong, and the atomic decrement doesn't touch it: the same buyer getting two units.

A stampede is exactly where retries happen — a rage-click on a slow "Buy" button, a mobile reconnect, a load balancer re-sending a request it wasn't sure landed. Each of those can fire the purchase twice, and two atomic decrements are two perfectly valid decrements:

same order, submitted twice, no idempotency key:
  stock 100 → 98    ← one buyer, one order, TWO units gone

The count invariant held (stock never went negative), but the allocation is wrong: this buyer took two, so someone else gets none. That's a different guarantee — per-buyer exactly-once — and it needs the discipline from Idempotency as a Discipline: give the checkout a stable idempotency key (the order id) and gate the decrement on claiming it, in one transaction:

WITH claim AS (
  INSERT INTO orders(order_id) VALUES ('A') ON CONFLICT DO NOTHING RETURNING order_id
)
UPDATE inventory SET stock = stock - 1
 WHERE sku = 'sale' AND stock > 0 AND EXISTS (SELECT 1 FROM claim);
same order, submitted twice, WITH the key:
  stock 100 → 99    ← the second submit is a no-op. One order = one unit.

Two guarantees, two mechanisms: the atomic decrement keeps the total right, the idempotency key keeps each buyer right. A real flash sale needs both — and if buyers hold inventory before they pay (add-to-cart), you add a third: reserve with a TTL and release on timeout, the compensating move from Saga: The Data-Consistency Mechanics.

Run the Flash Sale

Numbers on a page are one thing; watching the stock counter tick to −49 in red is another. Below is the whole sale, live. Set the stock, the size of the stampede, and how many buyers hit at once, pick a strategy, and run it — the one inventory row takes the hit in real time and the scoreboard fills in.

Start with naive and watch it oversell, then drag concurrency down to 2 and watch the bug vanish — that's why it survives your tests and dies in production. Switch to pessimistic and notice the throughput bar shrink; switch to SERIALIZABLE and watch the retries pile up; then land on the atomic conditional and see the only row that's both correct and the longest throughput bar. Feel the tradeoff in your hands — it's the entire section in one screen.

Run the stampede yourself. Set the stock, the number of buyers, and how many hit at once, then pick a strategy and watch the one inventory row take the hit in real time — the counter ticks down and goes NEGATIVE (red) when the naive version oversells. The scoreboard fills in sold, oversold, rejected, and throughput. Naive oversells and isn't even fast; the pessimistic lock is correct but the slowest; SERIALIZABLE is correct but makes most buyers retry; the atomic conditional write is correct AND the fastest. Turn concurrency down to 2 and the naive bug vanishes — which is exactly why it passes in dev and oversells in production.

The §6 Playbook — and Where This Goes Next

The flash sale was a final exam, and every answer traced straight back to a lesson in this section:

  • the oversell was a lost update under weak isolation (Isolation Levels & Their Anomalies);
  • the strategies were pessimistic vs optimistic locking (Pessimistic vs Optimistic Locking);
  • the fix was atomicity doing the check-and-change in one statement (ACID, Precisely);
  • the double-click needed idempotency (Idempotency as a Discipline);
  • the hold-then-confirm was a saga (Saga: The Data-Consistency Mechanics).

And under all of it sits one meta-lesson worth carrying out of this section: push correctness down to where the data lives, and make the critical operation as small and atomic as you can. The database, sitting next to the row, doing the check and the change in a single statement, beats any amount of clever coordination in your application code — it's correct, and it's faster.

One honest limit remains: even the perfect atomic write serializes on the one row. At the scale of a console drop or a Singles' Day, a single hot row is itself a ceiling, and real systems break it up — shard the counter into buckets (Google's sharded-counter pattern), decrement in memory with Redis DECR, or put buyers in a waiting room and admit them at a rate the inventory can serve (Ticketmaster, Shopify). Those are throughput moves, not correctness moves — the atomic decrement is still what keeps the count honest wherever it lives — and they are exactly where this course goes next, in the Problems course, where you'll design the whole flash-sale system, not just its hottest row. You now have the full transactions-and-concurrency toolkit; time to point it at real problems.