Checkout and inventory, without overselling the last one
Checkout touches three things that fail independently — stock, payment and the order — and the design is mostly about what happens when one of them succeeds and the next does not.
The brief
Customers add products to a cart and check out. Checkout reserves stock, takes payment and creates an order.
Stock is finite, and a flash sale sells a small quantity of one product to a very large number of people at once.
Requirements
Functional
- Show a product's availability
- Reserve stock at checkout, take payment, and create the order
- Release reserved stock when payment fails or the customer abandons checkout
- Never confirm more units than exist
Non-functional
- Overselling is worse than showing out of stock too early; an order that cannot be fulfilled costs a refund, a support contact and trust
- A customer must never be charged without an order, or have an order without a charge
- Browsing and cart traffic must not be slowed by a flash sale on one product
Back-of-envelope
Assume
- 1 million orders a day in ordinary trading, 3 items per order
- Peak hour carries 10% of the day
- A flash sale: 1,000 units of one product, 200,000 people trying in the first minute
- Checkout-to-payment-result takes 5 seconds at p50 and 30 seconds at p99
Therefore
- Ordinary peak: 100,000 orders an hour ≈ 28 orders a second ≈ 85 stock decrements a second, spread across many products. Any database handles that comfortably — ordinary checkout is not the problem.
- Flash sale: 200,000 attempts a minute ≈ 3,300 a second, all on ONE row. The problem is not throughput, it is contention on a single counter.
- 999 of every 1,000 people in the flash sale get no unit. As with ticketing, most of the design's work is saying no quickly.
- A 30-second p99 payment means reserved units are unavailable to others for at least that long; if 10% of reservations are abandoned, about a hundred units sit reserved-but-unsold at the moment of sell-out.
The ordinary and flash-sale numbers point at different designs. The mistake is to design for one and assume it covers the other.
The interface
What is stored
sku · on_hand · reserved · CHECK (reserved <= on_hand)A reservation is UPDATE stock SET reserved = reserved + n WHERE sku = ? AND on_hand - reserved >= n. The condition and the change are one statement, and the CHECK constraint refuses an oversell even if some other code path gets it wrong.
reservation_id · checkout_id · sku · quantity · expires_at · state · INDEX (state, expires_at)One row per reserved line, so a release knows exactly how much to give back. The index exists for the expiry job and nothing else.
orders: order_id · checkout_id UNIQUE · status · total · outbox: event_id · type · payload · published_atcheckout_id is unique so a retried checkout cannot create a second order. The outbox row is written in the same transaction as the status change, so 'order confirmed' is never published for an order that rolled back.
The design
The decisions
Each of these could go the other way. The choice, the reason, and what it costs — a design that lists only what it chose teaches the choice; one that lists what it gave up teaches the judgement.
What breaks first
In order. Each names what you would actually observe, and each fix carries its cost.
When something fails
Scaling it
Each step is triggered by a number, not a feeling — and carries what it costs.
What gets probed
The design is the easy half. These are where the conversation goes, and each has a defensible answer above.
- A customer's payment succeeds, and the stock was released a second earlier. What happens next?
- Why is the stock check a WHERE clause rather than a SELECT followed by an UPDATE?
- The product page said 'in stock' and checkout said 'out of stock'. Is that a bug?
- What does the reconciler compare, how often does it run, and what decides that interval?