Overselling a flash sale: eleven ways to decrement stock at scale
By Raphael Discky
What I measured
The flash-sale experiment in my repo has eleven ways to decrement stock under concurrency. I put 38 million checkout attempts through them, which created 1,192,153 order rows across the 22 runs in this comparison.
The setup
Every adapter uses the same two tables. I deliberately left off CHECK (stock >= 0):
otherwise the broken adapter would fail with an error instead of overselling, and the
damage would be hidden in an error count rather than visible in the data.
1CREATE TABLE flashsale.products (2 id BIGINT PRIMARY KEY,3 name TEXT NOT NULL,4 stock INT NOT NULL, -- no CHECK (stock >= 0), on purpose5 version INT NOT NULL DEFAULT 0 -- pg_optimistic's compare-and-set6);7
8CREATE TABLE flashsale.orders (9 id BIGSERIAL PRIMARY KEY,10 product_id BIGINT NOT NULL REFERENCES flashsale.products(id),11 qty INT NOT NULL CHECK (qty > 0),12 idempotency_key UUID UNIQUE, -- token_queue's replay guard13 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()14);
That is the whole schema for nine of the eleven adapters. One needs a third table because it stops treating stock as a number:
1CREATE TABLE flashsale.stock_tickets ( -- pg_skip_locked: one row per unit2 id BIGSERIAL PRIMARY KEY,3 product_id BIGINT NOT NULL REFERENCES flashsale.products(id),4 claimed BOOLEAN NOT NULL DEFAULT false5);6CREATE INDEX ON flashsale.stock_tickets (product_id) WHERE NOT claimed;
Those three tables are all Postgres needs. Outside Postgres, the state is smaller: Redis
holds one integer per product at stock:<product_id>, while go_chan and token_queue
keep a Go map in process memory. token_queue also publishes to the flashsale.orders
Kafka topic, keyed by product ID so a product's orders stay in one partition.
Both scenarios have 50,000 units, so row count is the only variable. Each run uses 500 concurrent connections for 60 seconds.
| Scenario | Shape | Per-row pressure |
|---|---|---|
| One product | 1 x 50,000 units | all 500 connections on one row |
| Fifty products | 50 x 1,000 units | 10 connections per row |
The 500 connections represent requests in flight, not buyers. Each connection starts its next checkout as soon as the previous one finishes, so the workload is self-paced. New requests do not begin until earlier ones complete, which means arrivals are limited by the server's throughput instead of accumulating behind a slow adapter. As a result, this benchmark is more forgiving than a real flash sale, where new customers continue arriving regardless of how quickly the server responds.
For both scenarios, SQL provides the ground truth by comparing count(*) on the orders table with the seeded stock.
The benchmark also keeps an in-process oversell_total counter,
and I report both because a program should not be the only judge of its own correctness. The two agreed on every run.
One product under heavy contention
The naive adapter reads stock, checks it in Go, then writes the computed value back. Two goroutines can both read 1, both decide there is stock left, and both write 0.
1// Racy: newStock is computed from a value another goroutine may also hold.2var current int3tx.QueryRow(ctx, `SELECT stock FROM flashsale.products WHERE id = $1`, id).4 Scan(¤t)5
6if current < qty {7 return domain.ErrOutOfStock8}9
10newStock := current - qty11tx.Exec(ctx, `UPDATE flashsale.products SET stock = $1 WHERE id = $2`, newStock, id)I ran those three approaches against 500 connections targeting one product with 50,000 units: many buyers trying to purchase the same item at once. This is where every strategy sees the most contention:
| Adapter | Orders | Oversold | Unsold | Retry exhausted | Attempts | Req/s | Median | p99 |
|---|---|---|---|---|---|---|---|---|
naive | 83,574 | 33,574 | 0 | 0 | 83,575 | 1,377 | 357ms | 426ms |
pg_for_update | 50,000 | 0 | 0 | 0 | 174,469 | 2,860 | 76ms | 468ms |
pg_cond | 50,000 | 0 | 0 | 0 | 367,006 | 6,018 | 35ms | 410ms |
pg_advisory | 50,000 | 0 | 0 | 0 | 117,392 | 1,918 | 120ms | 494ms |
pg_optimistic | 25,517 | 0 | 24,483 | 55,606 | 81,124 | 1,338 | 415ms | 489ms |
pg_serializable | 29,131 | 0 | 20,869 | 108,869 | 138,001 | 2,257 | 231ms | 299ms |
pg_skip_locked | 50,000 | 0 | 0 | 0 | 441,116 | 7,222 | 47ms | 187ms |
redis_atomic | 50,000 | 0 | 0 | 0 | 1,042,010 | 17,079 | 28ms | 39ms |
redis_lua | 50,000 | 0 | 0 | 0 | 2,044,902 | 33,529 | 14ms | 31ms |
go_chan | 50,000 | 0 | 0 | 0 | 3,970,445 | 65,075 | 3.4ms | 36ms |
token_queue | 50,000 | 0 | 0 | 0 | 4,172,619 | 68,395 | 2.8ms | 40ms |
Read the Req/s column carefully: it is not a like-for-like measure. Once an adapter runs
out of stock, later requests are rejections, and rejections cost less than sales.
naive's 1,377 reflects 83,574 order inserts, while pg_cond's 6,018 includes 50,000
inserts and 317,006 cheap refusals. Compare Orders, Oversold, Unsold, and Retry exhausted
instead.
The first row sold 67% more units than exist. What surprised me was where the damage
appeared. The products table finishes at stock = 0 and never goes negative because every
racing writer checked that stock was positive before calculating its new value. Inventory
looks healthy while the orders table contains 33,574 rows that should not exist. Watching
stock levels alone would miss the bug.
The same stock, spread over fifty rows
The next run changes only the inventory layout: 50,000 units now sit in fifty rows of 1,000 rather than one row of 50,000. The load, duration, and total stock are the same.
| Adapter | Orders | Oversold | Unsold | Retry exhausted | Attempts | Req/s | Median | p99 |
|---|---|---|---|---|---|---|---|---|
naive | 66,431 | 16,431 | 0 | 0 | 1,034,334 | 16,844 | 26ms | 60ms |
pg_for_update | 50,000 | 0 | 0 | 0 | 1,008,479 | 16,407 | 27ms | 59ms |
pg_cond | 50,000 | 0 | 0 | 0 | 812,892 | 13,217 | 35ms | 55ms |
pg_advisory | 50,000 | 0 | 0 | 0 | 837,647 | 13,627 | 33ms | 71ms |
pg_optimistic | 50,000 | 0 | 0 | 215 | 1,033,566 | 16,803 | 26ms | 91ms |
pg_serializable | 50,000 | 0 | 0 | 286 | 992,293 | 16,149 | 27ms | 103ms |
pg_skip_locked | 50,000 | 0 | 0 | 0 | 623,542 | 10,143 | 46ms | 73ms |
redis_atomic | 50,000 | 0 | 0 | 0 | 1,036,448 | 16,853 | 28ms | 40ms |
redis_lua | 50,000 | 0 | 0 | 0 | 2,027,006 | 32,962 | 14ms | 33ms |
go_chan | 50,000 | 0 | 0 | 0 | 3,984,472 | 64,849 | 3.4ms | 36ms |
token_queue | 50,000 | 0 | 0 | 0 | 4,117,864 | 67,024 | 2.8ms | 40ms |
Spreading the stock out reduced overselling from 67.1% of the sale to 32.9%, but only by half. I expected fifty rows to almost eliminate the problem. It did not: ten concurrent connections per row still create enough contention to lose a third of the inventory to the same bug. More rows help less than they appear to.
The larger change is in the retrying strategies. pg_optimistic sold 25,517 of 50,000
units on one row and all 50,000 across fifty. pg_serializable went from 29,131 to
50,000, while their exhausted retries fell from 55,606 and 108,869 to 215 and 286.
Neither adapter changed and neither oversold a unit. Contention alone decided whether the
sale finished.
Two locks that make buyers wait
Four of the adapters in these tables serialize buyers so they take turns.
Two of them, pg_for_update and pg_advisory, achieve this by queuing on different kinds of PostgreSQL locks.
1// pg_for_update: the row is both the data and the gate.2var current int3tx.QueryRow(ctx,4 `SELECT stock FROM flashsale.products WHERE id = $1 FOR UPDATE`, id,5).Scan(¤t)6
7if current < qty {8 return domain.ErrOutOfStock9}10tx.Exec(ctx, `UPDATE flashsale.products SET stock = $1 WHERE id = $2`,11 current-qty, id)That extra lock reduces throughput. pg_advisory handled 1,918 requests per second, while pg_for_update handled 2,860.
The advisory lock adds an extra step without providing any benefit over the row lock, and it introduces an additional risk.
Advisory locks use a single global int64 key space across the entire database.
If another part of the application uses the same key, it can unintentionally block checkouts, and nothing in the schema indicates that the key is already in use.
Two strategies that could not keep up
The other two take the opposite approach. pg_optimistic and pg_serializable let every
buyer try, then resolve conflicts afterward: one through a version column you maintain,
the other through the database engine.
1// pg_optimistic: compare-and-set on version, retry when someone else won.2tag, err := tx.Exec(ctx, `3 UPDATE flashsale.products4 SET stock = $1, version = version + 15 WHERE id = $2 AND version = $36`, current-qty, id, version)7
8if tag.RowsAffected() == 0 {9 return errVersionConflict // caller re-reads, up to 5 attempts10}Both are correct and neither oversold a unit. They were still selling when the 60-second window closed, so their Unsold values reflect the length of the run as much as the strategy itself.
Of the two,
SERIALIZABLE
did better on both measures: 29,131 units sold versus 25,517, and 2,257 requests per
second versus 1,338. It also needs no schema column. The tradeoff is more churn: 108,869
exhausted retries versus 55,606.
One row per unit
One Postgres adapter takes a different approach. pg_skip_locked does not treat stock as
a number to decrement. It treats it as a set of rows to claim with
FOR UPDATE SKIP LOCKED,
the same pattern used by Postgres-backed job queues.
1// The only Postgres adapter here where a blocked buyer does not wait: SKIP2// LOCKED sends them to the next unlocked ticket instead of into a queue.3rows, err := tx.Query(ctx, `4 WITH claimed AS (5 SELECT id FROM flashsale.stock_tickets6 WHERE product_id = $1 AND NOT claimed7 LIMIT $28 FOR UPDATE SKIP LOCKED9 )10 UPDATE flashsale.stock_tickets t SET claimed = true11 FROM claimed c WHERE t.id = c.id12 RETURNING t.id13`, productID, qty)
On one hot product, that claim query produces the best Postgres result in this post:
50,000 units sold at 7,222 requests per second, compared with pg_cond's 6,018, and a
p99 of 187ms compared with 410ms. pg_cond uses the cheaper statement. The p99 gap is
the cost of making several hundred buyers wait on one row.
When the same stock is spread across fifty products, this approach drops to last among the seven PostgreSQL adapters.
It handles 10,143 requests per second, behind naive at 16,844 and pg_cond at 13,217.
With contention spread across many products, there is no longer a queue to avoid.
What remains is the overhead of the approach: inserting 50,000 ticket rows before the sale, performing an index lookup for every purchase,
and making it expensive to answer a simple question like "how many are left?" Instead of reading a single counter,
the database must count the remaining unclaimed tickets.
Taking Postgres out of the decision
Those seven adapters coordinate through Postgres. The final four keep the authoritative counter elsewhere, and they take the top four throughput spots in both scenarios.
1// redis_atomic: DECRBY is atomic, but there is no decrement-if-at-least-n,2// so the condition gets checked afterwards and undone.3remaining, _ := a.rdb.DecrBy(ctx, key, int64(qty)).Result()4
5if remaining < 0 {6 // A crash here leaks the units for good.7 a.rdb.IncrBy(ctx, key, int64(qty))8 return domain.ErrOutOfStock9}Those four adapters make the same trade to different degrees. redis_atomic reached 17,079 requests per second, while redis_lua reached 33,529.
Moving the condition into a Lua script nearly doubled throughput because DECRBY alone requires a second round trip to undo an overshoot,
whereas the script performs the check and decrement atomically.
DECRBY itself is atomic. The problem is the condition based on its result, which runs outside that guarantee.
If the application crashes after the decrement but before the compensating INCRBY, those units are lost permanently.
go_chan and token_queue avoid the network entirely.
They reached 65,075 and 68,395 requests per second, about 11 times higher than pg_cond on the same hot product,
with median latencies of 3.4 ms and 2.8 ms. Those numbers mostly reflect how quickly they reject requests:
both sold all 50,000 units within the first second. token_queue, for example, rejected 4,122,619 buyers while accepting 50,000,
or about 82 rejections for every successful purchase.
Their other advantage is that throughput does not depend on the number of products.
Every PostgreSQL adapter changed between the one-product and fifty-product scenarios,
some by as much as 12x. go_chan and token_queue maintained nearly the same throughput because neither contends on a database row.
Keeping the counter in process memory follows the same pattern I observed across four runtimes in a separate benchmark on 16 cores. There, Go used a buffered channel to apply backpressure, while Node's standard library had no equivalent.
What the fastest adapter costs
token_queue keeps its quota in one process's memory, so its behavior across replicas matters more than its throughput.
I ran four replicas, each seeded with a quarter of the 50,000 units, and pointed the load generator at them:
| Run | Replicas seeded | Taking traffic | Sold | Unsold | Oversold | Req/s |
|---|---|---|---|---|---|---|
| Even | 4 | 4 | 50,000 | 0 | 0 | 65,705 |
| Skewed | 4 | 3 | 37,500 | 12,500 | 0 | 65,705 |
The first row is the happy path: all four replicas receive traffic, and all 50,000 units are sold. In the second run, one replica is taken out of rotation, as a failed health check or slow autoscaler might. Its 12,500 units become unsellable while buyers are still waiting. The sale does not oversell, but it silently leaves a quarter of the inventory unsold because each remaining replica has already exhausted its own quota.
There are two more costs. Orders reach Postgres after the sale ends because the Kafka consumer batches them;
draining took 2 seconds in both runs. A buyer who receives an HTTP 200 has a durable queue entry, not yet a durable database record.
Also, Kafka delivery is at-least-once,
so every message includes an idempotency key, and the consumer inserts orders with ON CONFLICT DO NOTHING.
Without that safeguard, a redelivered batch could create duplicate orders.
When to reach for each one
| Adapter | Mechanism, and what it needs | Reach for it when | What it costs you |
|---|---|---|---|
naive | SELECT stock, compare in Go, then UPDATE to a literal value | Never. It is here to be the bug. | 67% oversold on one row, 33% on fifty |
pg_for_update | The same three steps, with FOR UPDATE on the select | The read-check-write has to stay in application code and the row is not hot | Every buyer queues, so 2,860 req/s on one row |
pg_cond | One statement: UPDATE ... SET stock = stock - $1 WHERE id = $2 AND stock >= $1 | The decrement fits in one statement, which is most of the time | Still one row, and 6,018 req/s is the ceiling |
pg_advisory | pg_advisory_xact_lock(id) first, then read-check-write | You must guard something that is not one row, such as a multi-table invariant | Slower than locking the row, plus a global key space nothing tracks |
pg_optimistic | Read (stock, version), then UPDATE ... WHERE version = $3, five attempts. Needs the version column | Conflicts are rare and you want them visible in your own schema | 425 orders/s under contention, and 69% of buyers turned away |
pg_serializable | Read-check-write at SERIALIZABLE, retry on SQLSTATE 40001, five attempts | Conflicts are rare and you would rather the engine find them than add a column | 486 orders/s under contention, and twice optimistic's retry churn |
pg_skip_locked | SELECT ... FOR UPDATE SKIP LOCKED over unclaimed rows. Needs stock_tickets, one row per unit | One product is hot enough to be the bottleneck and you have to stay in Postgres | 50,000 rows to seed, an index walk per claim, no cheap remaining count |
redis_lua | One EVAL running GET then DECRBY. Needs Redis and a seeded stock:<id> key | Postgres is the bottleneck and you can run a second store | The order insert is a dual write, and Redis joins the critical path |
redis_atomic | DECRBY, then INCRBY to hand back an overshoot. Needs Redis | You want Redis speed without writing Lua | Two round trips on rejection, and a crash between them leaks units |
go_chan | One owner goroutine holds the counter behind a buffered channel. Needs nothing outside the process | Single process, and durability does not matter | Two replicas each sell the whole sale |
token_queue | Grant from a per-replica in-memory quota, then produce to Kafka. Needs Kafka, a consumer, and idempotency_key | The sale is large enough that the cost of rejecting is the whole problem | Quota strands on idle replicas, and orders are durable seconds later |
The eleven adapters can be ranked two ways: by throughput on one product and by strictness. The lists are close to reversed:
| Throughput, fastest first | Strictness, strictest first |
|---|---|
token_queue 68,395 | pg_cond, exact and durable the moment the buyer sees a yes |
go_chan 65,075 | pg_for_update, the same guarantee through a row lock |
redis_lua 33,529 | pg_advisory, the same again through the lock manager |
redis_atomic 17,079 | pg_skip_locked, the same, with the unit as a claimed row |
pg_skip_locked 7,222 | pg_optimistic, never wrong, but too slow to clear the sale |
pg_cond 6,018 | pg_serializable, the same shortfall with more churn |
pg_for_update 2,860 | redis_lua, counter exact, order is a dual write |
pg_serializable 2,257 | redis_atomic, plus a gap that leaks units on a crash |
pg_advisory 1,918 | go_chan, correct inside one process only |
naive 1,377, and wrong | token_queue, strands quota, durable seconds later |
pg_optimistic 1,338 | naive, wrong at any speed |
At the top of the table, the trade-off is clear: the four fastest adapters are also the four least strict.
The four strictest adapters rank fifth, sixth, seventh, and ninth in throughput.
Trading some strictness for speed can be a reasonable choice if the alternative is a sale that cannot keep up with demand,
but the trade-offs should be explicit. For example, token_queue stranded 12,500 units on an idle replica and delayed durable order writes by 2 seconds.
Neither issue appears in the oversell count.
For a real flash sale, I would start with pg_cond behind admission control rather than a quota-based design.
It handles 6,018 checkouts per second on a single hot row, which is sufficient for most sales.
If that becomes the bottleneck, I would try pg_skip_locked before introducing another datastore.
What I did not measure
The largest omission is admission control. Real flash sales typically rate-limit, queue, or otherwise control traffic at the edge before requests reach the inventory service. Every result above therefore reflects a benchmark without that layer, allowing up to 500 concurrent connections to reach the application.
There are two further limits to the setup. Kafka ran as a single KRaft node, so these runs do not cover replication, ISR shrink, or the durability guarantees of a real acks=all deployment.
Tracing was disabled for every run because an OTLP exporter pointed at a missing collector generated retry storms that became the bottleneck.
I wanted to measure adapter latency, not exporter latency.