Mark Ku's Blog
Podcast ConversationAI dialogue version of this article · Mandarin audio
Audio for this article is powered by VoAIVoAI

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.

  1. Optimistic locking — How to prevent overselling
  2. Redis atomicity — How to ensure no double-deductions
  3. 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:

  1. Begin a transaction (BEGIN TRANSACTION).
  2. SELECT ... FOR UPDATE to lock the product row (others have to wait).
  3. Check if stock is sufficient; if so, update inventory.
  4. COMMIT to 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 UPDATE until the previous transaction's COMMIT / ROLLBACK releases 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 transaction is opened by the caller, who controls Commit / 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.
  • 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:

  1. API receives a request → enqueue → immediately return "queued."
  2. Queue worker pulls a request from the queue, first deducts inventory atomically in Redis.
  3. After Redis succeeds, update the DB with optimistic locking to ensure correctness.
  4. 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

TechniqueUseProsCons
Optimistic lockVersion check, prevent oversellingSimple, efficientRequires retry on conflict
Redis atomicityFast inventory deductionSuper fast, atomicNeeds persistence, has lag
QueueShed peaks, rate-limitStable, fault-tolerantUsers 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. 🚀

Author

Mark Ku

擁有 10+ 年經驗的資深軟體工程師,現為 AI 應用 Builder,專注於大型平台架構與簡化複雜系統設計,從電商系統到訂閱與收費平台,結合 AI Agent、AI 整合與自動化開發,打造高效率且可持續演進的產品技術基礎。Read More

Found this useful?

The author's free tools, daily podcasts and newsletter are all here.

Mark Ku · This article is licensed under CC BY 4.0. Credit the author and link back to the original when reusing it.

Comments

Subscribe to Newsletter

Subscribe to get new posts delivered instantly — never miss a tech share.

By submitting, you agree to receive emails. You can anytime.

Popular Posts

View all
Mark Ku
··602

Oracle Cloud Always Free Tier: Linux Host and Static IP for a $0 Cloud Solution

Oracle Cloud Always Free Tier: Linux Host and Static IP for a $0 Cloud Solution
Mark Ku
··490

Say Goodbye to Postman's Fee Trap! A Hands-on Guide to Bruno, the Open-Source Git-Native API Testing Powerhouse.

Say Goodbye to Postman's Fee Trap! A Hands-on Guide to Bruno, the Open-Source Git-Native API Testing Powerhouse.
Mark Ku
··333

A Free, Open-Source, Notion-like Knowledge Base — A Complete Guide to Deploying and Backing Up Outline Wiki

A Free, Open-Source, Notion-like Knowledge Base — A Complete Guide to Deploying and Backing Up Outline Wiki
Mark Ku
··264

Training Your Own AI Voice: Hardware Requirements, Open-Source Model Comparison, and LoRA Fine-Tuning

Training Your Own AI Voice: Hardware Requirements, Open-Source Model Comparison, and LoRA Fine-Tuning
Mark Ku
··221

Building an Efficient API Management Platform: Deploying Kong Gateway from Scratch - Part 1

Building an Efficient API Management Platform: Deploying Kong Gateway from Scratch - Part 1
Mark Ku
··215

Setting Up Samba on Ubuntu to Share Folders with Windows 11

Setting Up Samba on Ubuntu to Share Folders with Windows 11
電商閃購搶購系統設計:樂觀鎖、悲觀鎖、Redis 原子性、佇列完整解決方案 - Mark Ku's Tech Notes