---
title: "Designing an E-Commerce Flash-Sale System: A Complete Guide to Optimistic Locking, Pessimistic Locking, Redis Atomicity, and Queues"
description: "A plain-English walkthrough of the three core techniques behind e-commerce flash-sale systems: optimistic locking for inventory control, Redis atomicity for data consistency, and queues for handling high concurrency — to help you grasp high-concurrency system design quickly."
canonical_url: "https://blog.markkulab.net/en/post/ecommerce-flash-sale-optimization"
author: "Mark Ku"
author_url: "https://blog.markkulab.net/en/author/mark-ku"
site: "Mark Ku's Tech Notes"
date_published: "2025-12-26 10:00:00 +0800"
category: "Architecture"
tags: ["ecommerce", "flash sale", "optimistic lock", "redis", "queue", "high concurrency", "inventory", "system design"]
language: "en"
license: "CC BY 4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
attribution: "when reusing or quoting, credit the author and link back to the original"
---

# Designing an E-Commerce Flash-Sale System: A Complete Guide to Optimistic Locking, Pessimistic Locking, Redis Atomicity, and Queues

## 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)

```sql
-- 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)

```csharp
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:
>
> ```csharp
> 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:**

```sql
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):**

```csharp
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:**

```bash
# Set product 123's stock to 100
SET product:123:qty 100
```

**Decrement stock (Lua script for atomicity):**

```lua
-- 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:**

```javascript
// 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:**

```javascript
// 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

| 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. 🚀

---

## About this article and its author

Originally published on [Mark Ku's Tech Notes](https://blog.markkulab.net/en/post/ecommerce-flash-sale-optimization)

License: [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) — when reusing or quoting, credit the author and link back to the original

### About the author

**[Mark Ku](https://blog.markkulab.net/en/author/mark-ku)** — Software Solution Provider

- 10+ years senior software engineer, now an AI Builder
- Focused on large-platform architecture — North-American e-commerce, AI SaaS subscription billing
- Combining AI Agents and automation to build evolvable product foundations

### Free tools built by the author

All of these are free to use:

- [Free PDF Sign Tool](https://blog.markkulab.net/en/tools/pdf-sign): Online PDF sign tool — draw, type, or upload a signature, then drag, resize, and download. Everything runs in your browser; nothing is uploaded.
- [VS Code Refactory](https://blog.markkulab.net/en/tools/refactory): Refactory is a VS Code refactoring extension: 34 actions plus a 37-rule code-smell inspection layer with a Code Health dashboard, across 18 languages, backed by 534 tests. It learns your repo's conventions: where interfaces live, where DI is registered, whether 'use client' belongs. It ranks files by git churn × complexity so you know what to fix first, and hands any smell to the Claude Code already on your machine. Free to use, and your source never leaves your computer.
- [DB-Kit Database Manager](https://blog.markkulab.net/en/tools/db-kit): DB-Kit is a lightweight, cross-platform database manager built with Tauri + Rust + React. Manage MySQL, MariaDB, PostgreSQL, SQL Server, Oracle, SQLite, MongoDB, Redis, Kafka, Elasticsearch and RabbitMQ from one consistent interface: passwords encrypted in the OS keychain, SSH tunnels, full CRUD, a visual query builder, stacked multi-statement result sets, cross-connection data transfer and compare/sync, Excel / CSV import & export, visualized execution plans, ER diagrams, scheduled backups, SQL stress testing with p50–p99 latency percentiles, a 15-rule SQL review engine, Kafka message browsing with monitoring & alerts, a bilingual UI (Traditional Chinese / English), a built-in AI assistant (natural-language SQL, AI review and tuning advice) and the dbk CLI. Free and open source (MIT), with installers for Windows, macOS and Linux.
- [VS Code Super Mermaid](https://blog.markkulab.net/en/tools/super-mermaid): Super Mermaid is a VS Code extension for beautiful Mermaid diagrams out of the box: auto-colored live preview, mouse pan & zoom, high-res PNG / SVG export, 21 templates and multiple themes. Free and open source (MIT).
- [React Super Mermaid](https://blog.markkulab.net/en/tools/react-super-mermaid): react-super-mermaid is an open-source React component library: render beautiful Mermaid diagrams with a single <MermaidViewer>, with built-in colorful / sketch themes, pan & zoom, in-diagram search, and high-res SVG / PNG export. Lightweight, SSR-safe, fully typed. Free and open source (MIT).
- [Jira / Confluence Super Mermaid](https://blog.markkulab.net/en/tools/jira-super-mermaid): An Atlassian Forge app: write Mermaid syntax directly inside a Jira issue or a Confluence page and get flowcharts, sequence diagrams, state machines and Gantt charts. 11 diagram types, SVG / PNG export, light and dark themes, full CJK support. Runs on Atlassian: your diagrams live in your own site and the app calls no third-party service. Free, coming soon to the Atlassian Marketplace.
- [Mermaid Live Preview](https://blog.markkulab.net/en/tools/mermaid-preview): Write Mermaid in your browser, see it render instantly, and share the whole diagram as a single link. No sign-up, nothing uploaded to a server, and mermaid.live share links work as-is.
- [React Intl Phone Number](https://blog.markkulab.net/en/tools/react-intl-phone-number): react-intl-phone-number is an open-source React component: framework-agnostic and antd-free, with E.164 in/out, a searchable flag / country-code dropdown, configurable validation levels (strict / mobile-strict / loose), themeable CSS, and i18n — phone logic powered by google-libphonenumber. Lightweight and fully typed. Free and open source (MIT).
- [Uptime Kuma Cluster](https://blog.markkulab.net/en/tools/uptime-kuma-cluster): Turn single-node Uptime Kuma into a highly available cluster: OpenResty + Lua smart load balancing, shared MariaDB state, health checks and automatic failover, plus cluster-management REST APIs. One Docker Compose command to start. Free and open source (MIT).
- [Special Education](https://blog.markkulab.net/en/education): Learning materials crafted for special education students

### Daily podcasts

- [Mark's Tech Insights — Daily AI News](https://blog.markkulab.net/en/category/tech-news): Daily curated AI and tech trends. Catch the latest developments via audio summaries — covering AI applications, software architecture, DevOps, and engineering practice. — RSS: https://blog.markkulab.net/feed.xml
- [AI股市蝦聊](https://blog.markkulab.net/en/category/ai-stock-chat): Every trading day, an AI-analyzed take on the Taiwan stock market, delivered as a two-host conversation covering the session and the next-day outlook. — RSS: https://blog.markkulab.net/ai-stock-chat/feed.xml
- [開源好物週報](https://blog.markkulab.net/en/category/open-source-weekly): A weekly two-host pick of free open-source tools surfaced from real Hacker News, GitHub, and Reddit buzz — what pain they solve and the fastest way to get started. — RSS: https://blog.markkulab.net/open-source-weekly/feed.xml

### Newsletter

[Subscribe to the newsletter](https://blog.markkulab.net/en/subscribe) — Be the first to know about new posts. No spam, unsubscribe anytime.
