How Flash Sales Accidentally Sell 2 Items When Stock is 1
π― The Questionβ
"During a high-concurrency flash sale, an item has exactly 1 unit left in stock. Two users click 'Buy Now' at the exact same millisecond. Why does a standard application allow BOTH users to purchase the item (selling 2 items with stock of 1), and how do you prevent this Lost Update race condition?"
β‘ 30-Second Elevator Pitchβ
This is the classic Read-Modify-Write Race Condition (Lost Update):
- User A's thread reads stock:
SELECT stock FROM items WHERE id = 1;Returns1. - User B's thread reads stock at the exact same moment Returns
1. - User A verifies
1 > 0and executes:UPDATE items SET stock = 0 WHERE id = 1;. - User B also verified
1 > 0and executes:UPDATE items SET stock = 0 WHERE id = 1;. - Both orders are confirmed, payment gateways charge both cards, and the business accidentally oversells stock!
The Root Cause: Checking stock in application code and then updating it in a separate SQL statement creates a race condition window between the Read and the Write.
π§ Under-the-Hood: The Concurrent Oversell Race Windowβ
π¬ Production Solutions to Eliminate Oversellingβ
Solution 1: Atomic SQL Decrement with Guard Condition (Best & Simplest)β
Eliminate the separate SELECT. Collapse the read and write into a single atomic SQL statement:
UPDATE inventory
SET stock = stock - 1
WHERE item_id = 101 AND stock >= 1;
- The database engine acquires an exclusive row lock on the record.
- If User A runs first, the row matches
stock >= 1, stock becomes 0, and MySQL reports1 row affected(Order succeeds). - When User B's update executes,
stock >= 1is false, MySQL reports0 rows affected(Order cleanly rejected).
Solution 2: Pessimistic Row Locking (SELECT ... FOR UPDATE)β
BEGIN;
SELECT stock FROM inventory WHERE item_id = 101 FOR UPDATE; -- Acquires Exclusive X-Lock
-- Other transactions block here until this transaction commits!
UPDATE inventory SET stock = stock - 1 WHERE item_id = 101;
COMMIT;
Solution 3: Distributed In-Memory Caching (Redis Lua Script)β
In flash sales with 100,000 requests/second, databases get overwhelmed. Redis processes commands in a single-threaded event loop. A Redis Lua script checks and decrements stock in microsecond memory:
local stock = tonumber(redis.call('get', KEYS[1]))
if stock and stock > 0 then
redis.call('decr', KEYS[1])
return 1 -- Success
else
return 0 -- Sold out
end
π Comparison Matrix: Oversell Prevention Strategiesβ
| Strategy | Performance / Throughput | Implementation Complexity | Database Load | Best Use Case |
|---|---|---|---|---|
| Atomic SQL Guard Clause | β‘ High | Very Low (1 SQL line) | Moderate | Standard e-commerce checkout |
SELECT ... FOR UPDATE | Moderate (Thread lock queuing) | Low (Needs transaction) | High (Long lock duration) | Complex multi-item cart validation |
| Redis Lua Scripts | π Ultra-high (>100k req/s) | Medium | Offloads DB completely | High-velocity flash sales (Sneaker drops, tickets) |
π‘ What Interviewers Ask Next (Follow-Up Traps)β
-
"What happens if payment fails after decrementing stock in Redis?"
- Answer: You must implement a Compensating Transaction (Stock Reservation Pattern). Decrementing stock places the item in a temporary "Reserved" state with a 5-minute TTL. If payment succeeds, finalize the sale; if payment fails or times out, a worker increments the stock back.
-
"Can an unsigned integer column (
stock INT UNSIGNED) prevent overselling?"- Answer: Yes, setting
stock INT UNSIGNEDcauses MySQL to throw an error (BIGINT UNSIGNED value is out of range) if an update tries to push stock below 0. However, relying on database exceptions for business logic is bad practice; explicit guard clauses (WHERE stock >= 1) are much cleaner.
- Answer: Yes, setting
Interview Answer: Flash sales oversell inventory because separate read and write steps create a Lost Update race condition. To prevent overselling, never check stock in application memory. Use an atomic SQL update with a guard clause (UPDATE items SET stock = stock - 1 WHERE id = 1 AND stock >= 1) or a single-threaded Redis Lua script.