Preface
I haven't actually built one, but I've been studying how flash-sale systems for e-commerce should be designed — so when the time comes, I'll be faster.
- Optimistic locking — How to prevent overselling
- Redis atomicity — How to ensure no double-deductions
- Queues — How to flatten peaks and avoid crushing servers
Think of the three roles like this: Redis sits on the front line, deducting inventory at memory speed and "absorbing pressure"; the Queue takes incoming requests and lines them up so users see "queued"; the database is the source of truth and ledger — it cares about correctness and durability, not about taking all the traffic head-on.
The problem: why does overselling happen?
Suppose only 1 item is left and 2 users buy at the same moment.
Time user1 user2
t1 check stock: 1
t2 confirm order check stock: 1
t3 deduct → 0 left confirm order
t4 deduct → -1 left ❌ Oversold!
Root cause: the gap between read and write allows multiple people to operate on the same item simultaneously.
Approach 1: Pessimistic locking
Before we get to optimistic locking, a quick refresher on the classic "pessimistic lock" approach. Many legacy systems and finance-related systems still use it — and it does ensure transaction consistency easily.
Core idea
Pessimistic locking means: "I assume conflict will happen, so I just lock from the start."
Simplified flow:
- Begin a transaction (
BEGIN TRANSACTION). SELECT ... FOR UPDATEto lock the product row (others have to wait).- Check if stock is sufficient; if so, update inventory.
COMMITto commit the transaction and release the lock.
As long as you haven't COMMIT / ROLLBACK, anyone querying the same row gets blocked until you're done. By design no overselling — but the trade-off is "everyone queues at the database."
SQL example (MySQL)
-- 1. Begin transaction
BEGIN;
-- 2. Read and lock the row for the specified product
SELECT id, quantity
FROM products
WHERE id = 123
FOR UPDATE;
-- 3. Check if stock is sufficient
-- (Usually decided in application code)
-- 4. If sufficient, deduct stock
UPDATE products
SET quantity = quantity - 1
WHERE id = 123;
-- 5. Commit
COMMIT;
The key here is FOR UPDATE:
- The first transaction to acquire the lock proceeds;
- Subsequent ones block at
SELECT ... FOR UPDATEuntil the previous transaction'sCOMMIT / ROLLBACKreleases the lock.
C# + Dapper example (simplified)
public async Task<GeneralResult> PurchaseWithPessimisticLock(
int productId,
IDbConnection connection,
IDbTransaction transaction)
{
// Step 1: Lock the row inside the same transaction
const string selectSql = @"SELECT Id, Quantity
FROM Products
WHERE Id = @Id
FOR UPDATE";
var product = await connection.QuerySingleAsync<Product>(
selectSql,
new { Id = productId },
transaction
);
if (product.Quantity <= 0)
{
return new GeneralResult
{
Success = false,
Message = "Sold out"
};
}
// Step 2: Deduct inventory in the same transaction
const string updateSql = @"UPDATE Products
SET Quantity = Quantity - 1
WHERE Id = @Id";
var affectedRows = await connection.ExecuteAsync(
updateSql,
new { Id = productId },
transaction
);
if (affectedRows == 0)
{
return new GeneralResult
{
Success = false,
Message = "Purchase failed"
};
}
return new GeneralResult
{
Success = true,
Message = "Purchase successful (pessimistic lock)"
};
}
Note: the code above assumes the
transactionis opened by the caller, who controlsCommit / Rollback. For example:using (var transaction = connection.BeginTransaction()) { var result = await PurchaseWithPessimisticLock(productId, connection, transaction); if (result.Success) transaction.Commit(); else transaction.Rollback(); }
Pros and cons of pessimistic locking
- Pros:
- Intuitive — "lock first, then modify" — very hard to oversell;
- A great fit for "absolutely no conflict allowed" or "high transaction value" scenarios.
- Cons:
- Under high concurrency, lots of transactions wait on locks; latency spikes;
- The lock is at the DB row/page level — all pressure concentrates on the DB;
- Misuse can cause deadlocks; ordering and timeouts need care.
So in typical high-concurrency flash-sale systems, pessimistic locking usually isn't the first choice. It's more of a supplementary tool when "data accuracy must be perfect." Most systems use the more scalable combo of "optimistic lock + Redis + Queue."
Approach 2: Optimistic locking
Core idea
Optimistic locking is simple: don't lock the data — instead, on commit, check "did anyone change it in between?"
1. Read product info (quantity=1, version=5)
2. Compute the post-deduction quantity
3. On update, check: is version still 5?
- Yes → update succeeds
- No → someone else modified it, retry
Implementation example
Database design:
CREATE TABLE products (
id INT PRIMARY KEY,
name VARCHAR(100),
quantity INT,
version INT -- Version number, +1 on every update
);
Purchase logic (C# example, simplified):
public GeneralResult PurchaseWithOptimisticLock(int productId, IDbConnection connection)
{
// Step 1: Read current stock and version
const string selectSql = @"SELECT Id, Quantity, Version FROM Products WHERE Id = @Id";
var product = connection.QuerySingle<Product>(selectSql, new { Id = productId });
var currentVersion = product.Version;
var currentQty = product.Quantity;
// Step 2: Check if stock is sufficient
if (currentQty <= 0)
{
return new GeneralResult
{
Success = false,
Message = "Sold out"
};
}
// Step 3: Try to update (optimistic-lock check)
const string updateSql = @"UPDATE Products
SET Quantity = Quantity - 1, Version = Version + 1
WHERE Id = @Id AND Version = @Version";
var affectedRows = connection.Execute(updateSql, new
{
Id = productId,
Version = currentVersion
});
// Step 4: Verify update succeeded
if (affectedRows == 0)
{
// Version mismatch → someone updated first, ask user to retry
return new GeneralResult
{
Success = false,
Message = "Purchase failed, please retry"
};
}
// Success!
return new GeneralResult
{
Success = true,
Message = "Purchase successful"
};
}
public class Product
{
public int Id { get; set; }
public int Quantity { get; set; }
public int Version { get; set; }
}
public class GeneralResult
{
public bool Success { get; set; }
public string Message { get; set; }
}
Pros and cons
Overall, optimistic locking is easy to reason about and doesn't actually "lock the table," so no deadlocks. Performance is usually decent in normal systems. The downside: when many people compete on the same row, you get a flood of "update failed, please retry," which means the frontend or service needs retry logic. As conflicts pile up, performance degrades.
What problems does relying solely on database optimistic locking cause?
If the entire architecture only relies on database optimistic locking, problems gradually surface: all read/write pressure piles on the database. At peak times, connection counts, IO, and lock contention all hit limits; popular product rows become traffic-jam scenes — everyone re-reads, retries, performance keeps degrading. Even with a CDN or reverse proxy in front, traffic still funnels into the DB at the end. There's almost no way to flatten the peak.
To summarize: optimistic locking is mainly about "data correctness," not about "how many users surge in at once." For everyday orders and admin operations, optimistic locking is plenty. But for flash sales with tens of thousands of concurrent users, DB optimistic locking alone will explode — you must pair Redis + Queue in front to share the load.
Approach 3: Redis atomicity (atomic operations)
Quick definition of "atomicity": think of an action as either fully completing or never happening at all — no weird "halfway done" states in between.
Optimistic locking typically does "query first, then decide whether to update," with application code and network in between. Redis's approach is more like packaging the entire stock-deduction flow into a single atomic operation — once it starts executing, nothing can interrupt it.
Core idea
Redis is single-threaded with all operations in memory. Commands execute uninterrupted, and very fast.
User1 User2 Redis
DECR DECR stock = 1
─────────────────────────────────────────
├─ run DECR
│ stock = 0
│ ├─ run DECR
│ │ stock = -1
└─ returns 0 ✓ └─ returns -1 ❌
Why can Redis achieve atomicity?
Start with Redis itself: it's single-threaded, processes one command at a time, and other requests politely line up. So you never get "two people changing the same value simultaneously."
Then there's Lua. Think of Lua as a very lightweight scripting language often "embedded" into other systems for custom logic — Nginx / OpenResty and many game engines use Lua. Redis ships with a built-in Lua execution environment, so we can write a series of Redis commands as a small Lua script and have Redis run it in one shot.
In plain English, a Lua script bundles a sequence of Redis commands into a single uncrackable capsule.
Inside, you can GET then DECR without anyone cutting in. From the outside it looks like a single "big command" that either fully succeeds or fully fails — that's the atomicity Lua provides on Redis. It's also why flash-sale scenarios prefer Redis as the front gate.
While Redis runs a Lua script, other requests do briefly wait, but since everything happens in memory, the entire script usually finishes in under 1ms — practically imperceptible.
Compared to DB optimistic locking: the DB has "query then update" with application code and network in between. In that gap, other transactions might read stale data, causing version conflicts and retries. Redis ties "check + deduct" into one step, so by design there's much less conflict.
Implementation example
Initialize stock:
# Set product 123's stock to 100
SET product:123:qty 100
Decrement stock (Lua script for atomicity):
-- redis_script.lua
local key = KEYS[1]
local qty = redis.call('GET', key)
if not qty or tonumber(qty) <= 0 then
return 0 -- Sold out
end
redis.call('DECR', key)
return 1 -- Success
Backend code:
// Node.js + ioredis
const redis = require('ioredis');
const client = new redis();
async function purchaseWithRedis(productId, quantity = 1) {
const key = `product:${productId}:qty`;
// DECR auto-decrements (atomic operation)
const remaining = await client.decr(key);
if (remaining < 0) {
// Out of stock, restore
await client.incr(key);
return false;
}
return true; // Success
}
Pros and cons
Redis's biggest pro is speed — everything in memory and atomic execution makes it ideal as a front-gate for "numeric" things like inventory or counters. Most cases don't even need retry. The con: data only lives in Redis. If persistence isn't configured properly, there's a risk of loss during power failures or anomalies. You eventually need to sync back to the master DB, with a small time gap, so use it for the right scenario.
Approach 4: Queue-based load shedding
When the peak hits, instead of letting all requests in at once and crushing the server, drop them into a Queue and process them gradually.
A useful mental model:
- Frontend API: synchronously "writes this purchase request to the queue" and quickly responds "queued."
- Backend worker: asynchronously in the background, pulls requests from the queue one by one — deducts inventory, creates orders, charges cards.
What is the user actually waiting for?
In this design, users don't have to wait for the queue to finish processing to get the API response.
It splits cleanly into two layers:
- HTTP API layer:
- Only "synchronously waits" for one thing: whether
purchaseQueue.add(...)successfully added this request to the queue (or returned an error like queue full, Redis down, etc.). - This usually takes milliseconds → immediately returns
{ status: 'queued', jobId / position }. It doesn't wait for inventory deduction or payment to complete.
- Only "synchronously waits" for one thing: whether
- Background processing layer (worker):
- The actual "deduct inventory / create order / charge card" happens asynchronously in the worker, gradually.
- The user can then:
- See a "queued" UI; the frontend periodically polls a status API; or
- Use WebSocket / push notifications to be alerted when "purchase succeeds / fails."
Core idea
User Queue Service Inventory Check Payment
├─ sync: enqueue ──→
├─ wait in queue
├─ your turn
└─ async: deduct, confirm ──→ deduct stock ──→ success
Traffic shifts from peak to smooth.
Implementation example
Using RabbitMQ / Kafka or Redis Queue:
// 1. User clicks buy → enqueue
const queue = require('bull');
const purchaseQueue = new queue('purchases', {
redis: { host: '127.0.0.1', port: 6379 }
});
app.post('/purchase', async (req, res) => {
const { productId, userId } = req.body;
// Return immediately, hand off to the queue
await purchaseQueue.add({ productId, userId });
res.json({
status: 'queued',
position: await purchaseQueue.count()
});
});
// 2. Queue background processing
purchaseQueue.process(async (job) => {
const { productId, userId } = job.data;
// Actually run the purchase logic
const success = await deductInventory(productId);
if (success) {
await processPayment(userId, productId);
await sendConfirmationEmail(userId);
return { status: 'success' };
} else {
throw new Error('Out of stock');
}
});
Pros and cons
The Queue's benefit: it flattens a per-second flood of requests into a single line, dropping backend pressure significantly. Most queues have built-in retry, improving overall stability. The trade-off: users have to accept "queueing for a bit," and you have an extra piece of queue infrastructure to maintain — overall complexity is higher than a single-database setup.
Integrated solution: three-layer defense
In practice, the three techniques are usually combined into layered defense:
┌─────────────────────────────────────┐
│ User request │
└──────────────┬──────────────────────┘
│
┌──────▼─────────┐
│ Queue load │ Prevent avalanche
│ shedding │
└──────┬─────────┘
│
┌──────▼─────────────────┐
│ Redis atomic deduct │ Fast, reliable
│ DECR / Lua Script │
└──────┬─────────────────┘
│
┌──────▼─────────────────┐
│ Optimistic-lock sync │ Persistence
│ (eventual consistency)│
└──────┬─────────────────┘
│
┌──────▼──────────┐
│ Order, payment │
│ (async) │
└─────────────────┘
"Optimistic-lock sync to DB" can be implemented like this:
1. The DB `products` table keeps `quantity` + `version` columns.
2. After **Redis deduction succeeds**, the queue consumer reads the product's current `version` from the DB.
3. Update with optimistic lock: `UPDATE ... SET quantity = quantity - 1, version = version + 1 WHERE id = ? AND version = ?`.
4. If `affectedRows = 0`, the version was changed by someone else; you can:
- Retry a few times; or
- Record it as reconciliation data and fix it via a batch job later.
Redis handles "front-line speed" while the DB owns the "final source of truth" — the two stay eventually consistent via optimistic locking.
In other words, in this architecture:
- **The database only handles "data is correct, records are complete"** (source of truth, reconciliation, reporting).
- **Don't make the database carry "high concurrency"** — that's Redis + Queue's job.
Concrete flow:
In practice the flow looks like:
- API receives a request → enqueue → immediately return "queued."
- Queue worker pulls a request from the queue, first deducts inventory atomically in Redis.
- After Redis succeeds, update the DB with optimistic locking to ensure correctness.
- Finally, handle payment, emails, and other follow-ups in the background.
Production checklist
Before pushing a flash-sale system live, here are a few directions worth checking. For inventory: enable Redis persistence, periodically reconcile numbers with the database, and stop accepting requests immediately when sold out. For the queue: set a reasonable length cap, with retries and alerts so a queue blowing up doesn't go unnoticed. For data consistency: plan periodic reconciliation, retry strategies, and compensation flows for refunds and exceptions. Finally, for UX: give users a "queued" UI or approximate position, a way to check order status, and a clear explanation or compensation when they don't get the item — all of which dramatically improves perception.
Summary
| Technique | Use | Pros | Cons |
|---|---|---|---|
| Optimistic lock | Version check, prevent overselling | Simple, efficient | Requires retry on conflict |
| Redis atomicity | Fast inventory deduction | Super fast, atomic | Needs persistence, has lag |
| Queue | Shed peaks, rate-limit | Stable, fault-tolerant | Users wait |
Best practice: in short, "queue takes requests in line, Redis quickly deducts inventory, database uses optimistic locking to write the result correctly." Combine the three and you can usually weather most flash-sale events. 🚀



























Comments