Problem
Flash sale. Five units left. Twenty-five buyers hit checkout in the same second, and every one of their requests does the same three things: read the stock, decide there is enough, write a sale. Two of those requests read the same "5" — and both are told yes. The oversell is not a bug anyone wrote; it is the gap between checking and acting that every check-then-act flow contains. Payment systems, seat maps, promo codes, terraform state locks, Kubernetes leaders: same shape, same hole.
Context
The demo sells one SKU (5 units) under three concurrency strategies — identical schema, identical traffic (25 concurrent buyers per rush), identical Go code except for how the purchase guards its check-then-act window. Every attempt is journaled with its strategy and outcome, so after each rush you can read exactly who was told "confirmed" for a unit that never existed.
One implementation note that is itself the lesson: the SQLite handle in this
demo opens multiple connections for the no-lock strategy on purpose. With
a single connection, database/sql serializes every statement and the race
disappears — the bug is hidden by the deployment configuration, not fixed.
Run the same code on a real connection pool (or RDS with 200 connections) and
it is back. My first test run "passed" for exactly this reason: green check,
phantom safety.
Alternatives Considered
Optimistic concurrency (version columns). Each SKU row carries a
version. The purchase reads stock+version, writes UPDATE ... WHERE version
= ?, and — here is the part everyone forgets — decides what a failed write
means: retry, or give up. Under contention most attempts lose, so you are
choosing between retry storms and angry buyers. It is correct, but the
correctness comes from conflict, and conflict is latency.
Pessimistic locking (BEGIN IMMEDIATE). Grab the write lock, then read,
decide, and write inside one transaction. Buyers queue; correctness comes
from scheduling. The cost is throughput on the hot row and, on network
databases, lock waits that show up as your p99.
Redis reservation / queue-everything. Move the stock counter out of the database, or accept orders into a queue and settle them sequentially. Both are legitimate at scale; both add an infrastructure boundary — and a new consistency question ("what if the reservation service dies after the payment?") — before the race is even solved.
The demo keeps all three strategies in-process against one real SQLite file, so the only variable is the strategy.
Solution
The three purchases:
// no-lock: the bug. Check and act are two separate moments.
var stock int
_ = db.QueryRow(`SELECT stock FROM skus WHERE sku = ?`, sku).Scan(&stock)
if stock <= 0 { return reject() }
_, _ = db.Exec(`UPDATE skus SET stock = stock - 1 WHERE sku = ?`, sku)
// optimistic: the check and the act share a guard.
res, _ := db.Exec(`UPDATE skus
SET stock = stock - 1, version = version + 1
WHERE sku = ? AND version = ?`, sku, version)
// pessimistic: everyone waits their turn.
tx, _ := db.Begin() // first statement grabs the write lock
_, _ = tx.Exec(`UPDATE _lock_anchor SET x = x + 1`)
// ...read, decide, deduct inside the tx...
_ = tx.Commit()
Measured on the demo rush (25 buyers, 5 units — rerun it, the journal keeps
every attempt): no-lock confirms 14 orders for 5 units and finishes at
stock −9; optimistic confirms exactly 5 and turns the other 20 into clean
refusals; pessimistic does the same by lining buyers up. The oversold orders
are classified honestly: after each deduction the strategy re-reads the
actual stock, and a confirmed order that pushed stock negative is recorded
as oversold — the journal shows the phantom sales, not a vague error count.
Two contract details matter as much as the strategy. Idempotency is a
schema constraint, not hope: UNIQUE (sku, buyer) plus a fast-path check
before the strategy runs, so a retried request can never double-deduct —
putting that check after the deduction is a bug my own tests caught during
development (stock 8 after one purchase and one replay). Classification
requires the post-write read: from the buyer's stale local value, an oversold
order is indistinguishable from a normal one.
Trade-offs
- Optimistic: readers never block anyone and the happy path is one
round-trip — but under contention, retries multiply load exactly when you can least afford it. Cap the attempts (the demo allows 8) and have a real answer for attempt 9. Wrong for hot rows, right for rarely-contended ones.
- Pessimistic: bounded, predictable, easy to reason about — and it makes
the database the choke point. On SQLite the lock is the file; on Postgres it is row locks and idle-in-transaction timers; on anything networked, every waiter is holding a connection. Correct by scheduling, paid in tail latency.
- Idempotency keys are not optional. Whichever strategy wins, the network
will replay requests: timeout, retry, double-click. The first buyer to
touch the endpoint owns the key; everyone after gets their original answer
back. The demo's duplicate outcome is the system working correctly —
show me a checkout without it and I will show you double charges.
- Queueing is a product decision. "Sold out" versus "you are #14 in line,
restock incoming" are different businesses. Pessimistic locking naturally produces the second; optimistic produces the first. Neither is the default answer.
Production Notes
- SQLite specifics: modernc/sqlite has no
BEGIN IMMEDIATEthrough
database/sql, so the pessimistic path grabs the write lock by making a
write the transaction's first statement. Multi-connection handles need
busy_timeout, or concurrent writers fail with SQLITE_BUSY instead of
waiting — the demo sets 5s. And MaxOpenConns(1) is a legitimate
production choice for small SQLite services, but know what it is doing: it
is a global mutex that also hides check-then-act bugs.
- The deadlock I shipped while building this: the pessimistic rejected
path journaled the refusal while still holding the transaction — and with one pooled connection, the journal write waited forever on the connection the transaction held. End the transaction before touching anything else that needs the pool. Tests found it as a 15-second timeout hang; in production it would have been a pile of hung goroutines.
- Postgres translation:
BEGIN IMMEDIATEbecomes the default
INSERT/UPDATE first-write behavior or SELECT ... FOR UPDATE; the
version column works verbatim; UNIQUE (sku, buyer) works verbatim. The
strategies are storage-agnostic; only the lock mechanics differ.
- Real flash sales do one more thing: they hold the reservation (with a
TTL) at add-to-cart time, not at payment. The race is then between reservations, which are cheap, and only the winners ever reach the payment-side stock check. The strategies here are the building blocks; the TTL reservation is the orchestration around them.