Mark Ku's Blog
首頁 關於我
電商閃購搶購系統設計:樂觀鎖、Redis 原子性、佇列完整解決方案
電商閃購搶購系統設計:樂觀鎖、Redis 原子性、佇列完整解決方案
Mark Ku
Mark Ku
December 26, 2025
2 min

前言

雖然沒實作過,但研究一下電商的搶購系統,怎麼設計,下次要做就會比較快,這篇文章用 最白話的方式 講解三個核心技術:

  1. 樂觀鎖 - 怎麼防止超賣
  2. Redis 原子性 - 怎麼確保不會重複扣款
  3. 佇列 - 怎麼削峰填谷,不讓服務器累趴

可以先把三個角色想成這樣:Redis 衝在第一線,用記憶體超快扣庫存,負責「撐壓」;Queue 佇列負責先把請求收進來排隊,讓使用者看到「排隊中」;資料庫則當正本和記帳本,只管資料正不正確、有沒有存好,不負責硬撐所有流量。


懶人包:一句話版本

如果只想抓重點,可以這樣記:不想超賣,就在資料庫那層加「樂觀鎖 + 版本號」來更新庫存;想撐高併發,就在前面多放一層 Redis,用記憶體做原子扣庫存;想讓系統撐久一點,再加上一個 Queue 佇列,先把請求排隊起來,後面慢慢處理。

問題:為什麼會超賣?

假設只剩 1 件商品,有 2 個人同時搶購。

時間    user1                 user2
t1      查庫存:1件            
t2      確認下單              查庫存:1件
t3      扣庫存 → 0 件         確認下單
t4                            扣庫存 → -1 件 ❌ 超賣!

根本原因:讀和寫之間的間隙,允許多人同時操作同一件商品。


方案 1:悲觀鎖(Pessimistic Lock)

在講樂觀鎖之前,先快速補一下「悲觀鎖」這個經典做法,很多舊系統或金流相關系統都還是用這一套,這樣的做法確實很容易,就能確保交易的一致性。

核心概念

所謂 悲觀鎖,就是「我先假設一定會衝突,所以乾脆一開始就鎖起來」。

簡化流程會像這樣:

  1. 開啟交易(BEGIN TRANSACTION)。
  2. SELECT ... FOR UPDATE 把那筆商品資料鎖起來(別人只能等你用完)。
  3. 檢查庫存是否足夠,如果足夠就更新庫存。
  4. COMMIT 提交交易,釋放鎖。

只要你還沒 COMMIT / ROLLBACK,其他人就算查同一筆資料也會被「卡住」等你處理完,天生不會超賣,但換來的是「大家都在排隊等資料庫」。

SQL 範例(以 MySQL 為例)

-- 1. 開啟交易
BEGIN;

-- 2. 讀取並鎖住指定商品的那一列資料
SELECT id, quantity
FROM products
WHERE id = 123
FOR UPDATE;

-- 3. 檢查庫存是否足夠
--   (這段通常在應用程式內判斷)

-- 4. 足夠的話,扣庫存
UPDATE products
SET quantity = quantity - 1
WHERE id = 123;

-- 5. 提交交易
COMMIT;

這裡的關鍵是 FOR UPDATE

  • 第一個先拿到鎖的交易可以繼續往下跑;
  • 之後來的人會在 SELECT ... FOR UPDATE 那行被卡住,一直等到前一個交易 COMMIT / ROLLBACK 釋放鎖。

C# + Dapper 範例(簡化版)

public async Task<GeneralResult> PurchaseWithPessimisticLock(
  int productId,
  IDbConnection connection,
  IDbTransaction transaction)
{
  // Step 1: 先在同一個 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 = "已售完"
    };
  }

  // Step 2: 直接在同一個交易裡扣庫存
  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 = "購買失敗"
    };
  }

  return new GeneralResult
  {
    Success = true,
    Message = "購買成功(悲觀鎖)"
  };
}

注意:上面的程式碼假設交易 (transaction) 是在外層開好並且控制 Commit / Rollback,例如:

using (var transaction = connection.BeginTransaction())
{
  var result = await PurchaseWithPessimisticLock(productId, connection, transaction);

  if (result.Success)
    transaction.Commit();
  else
    transaction.Rollback();
}

悲觀鎖的優缺點

  • 優點
    • 寫法直覺,「先鎖再改」,不容易超賣;
    • 很適合「真的不能有任何衝突」或「交易金額很大」的情境。
  • 缺點
    • 高併發時,大量交易都在等鎖,延遲飆高;
    • 鎖住的是資料庫 row / page,所有壓力都集中在 DB;
    • 鎖用得不好可能產生死鎖(Deadlock),需要小心順序與 timeout 設定。

所以在一般高併發搶購系統裡,悲觀鎖通常不會當成第一選擇,而是「你真的很在意資料不能有一絲誤差」的補充手段;多數情況會改用「樂觀鎖 + Redis + Queue」這種比較能撐流量的組合。


方案 2:樂觀鎖(Optimistic Lock)

核心概念

樂觀鎖的想法很簡單:我不鎖住數據,而是在提交時檢查「期間有沒有人改過」。

1. 讀取商品資訊 (quantity=1, version=5)
2. 計算扣款後的數量
3. 更新時檢查:version 還是 5 嗎?
   - 是 → 更新成功
   - 否 → 代表有人改過,重試

實作範例

資料庫設計:

CREATE TABLE products (
  id INT PRIMARY KEY,
  name VARCHAR(100),
  quantity INT,
  version INT  -- 版本號,每次更新 +1
);

購買邏輯(C# 範例,簡化版):

public GeneralResult PurchaseWithOptimisticLock(int productId, IDbConnection connection)
{
  // Step 1: 讀取當前庫存和版本
  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: 檢查庫存是否足夠
  if (currentQty <= 0)
  {
    return new GeneralResult
    {
      Success = false,
      Message = "已售完"
    };
  }

  // Step 3: 嘗試更新(樂觀鎖檢查)
  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: 檢查更新是否成功
  if (affectedRows == 0)
  {
    // Version 不符 → 代表有人搶先更新了,請使用者重試
    return new GeneralResult
    {
      Success = false,
      Message = "購買失敗,請重試"
    };
  }

  // 成功!
  return new GeneralResult
  {
    Success = true,
    Message = "購買成功"
  };
}

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; }
}

優缺點

整體來說,樂觀鎖的好處是很好懂,也不用真的去「鎖表」,自然也不會卡死鎖,在一般系統裡效能通常都不錯。缺點是,一旦大家常常同時搶同一筆資料,就會出現一堆「更新失敗、請重試」,前端或服務端還要寫重試邏輯,衝突一多,效能就會開始掉。

單靠資料庫樂觀鎖會遇到什麼問題?

如果整套架構只靠資料庫樂觀鎖,問題會慢慢浮出來:所有讀寫壓力都堆在資料庫上,高峰時連線數、IO、鎖競爭都會被撐到極限;熱門商品那幾筆 row 會變成大塞車現場,大家一直重讀、重試,效能只會越來越差,而且前面就算有 CDN 或反向代理,最後流量還是一次灌進 DB,幾乎沒辦法幫你削峰。

小結一下,樂觀鎖主要在顧「資料正不正確」,不太管「一次湧進多少人」。平常一般下單、後台操作,用樂觀鎖就很夠;但秒殺、搶購這種幾萬人同時衝進來,只靠樂觀鎖 DB 還是會爆,一定要搭配前面的 Redis + Queue 幫忙分壓。


方案 3:Redis 原子性(Atomic Operation)

先簡單解釋一下「原子性」:可以把它想成一個動作要嘛整個做完、要嘛完全沒發生,中間不會卡在「做到一半」的奇怪狀態。

樂觀鎖通常是「先查再決定要不要更新」,在這中間還會經過應用程式和網路;Redis 的做法比較像是把整個扣庫存的流程包成一次原子操作,丟進去之後在執行的那一刻不會被別的指令打斷。

核心概念

Redis 是單線程,所有操作都在記憶體,指令執行不會被中斷,相當的快速。

User1              User2              Redis
減庫存             減庫存              庫存 = 1
─────────────────────────────────────────
  ├─ 執行 DECR       
  │                                   庫存 = 0
  │                 ├─ 執行 DECR
  │                 │                庫存 = -1
  └─ 返回 0 ✓       └─ 返回 -1 ❌

為什麼 Redis 可以做到原子性?

先從 Redis 自己說起:它是單線程模型,一次只會處理一個指令,其他請求都乖乖排在隊伍裡等,所以不會出現「兩個人同時改同一個值」這種情況。

再來是 Lua。可以把 Lua 想成一個很輕量的腳本語言,很常被「嵌」進別的系統裡當客製化邏輯在用,像 Nginx / OpenResty、很多遊戲引擎都有 Lua 的身影。Redis 本身就內建 Lua 執行環境,所以我們可以把一串 Redis 指令寫成一小段 Lua 程式碼,交給 Redis 一次跑完。

用更白話一點講,Lua 腳本就是把很多 Redis 指令打包成一顆不可拆的膠囊

裡面可以先 GETDECR,中間不會有人插隊,外面看起來就像送進去一個「大指令」,要嘛整包成功、要嘛整包失敗,這就是 Lua 原子性的概念,這也是為什麼搶購場景更傾向用 Redis 當前線閘門。

雖然 Redis 在跑 Lua 腳本的那一小段時間,其他請求會被稍微卡一下,但因為全部都在記憶體裡運作,整段腳本通常不到 1 毫秒就結束,實務上幾乎感受不到。

對照資料庫的樂觀鎖:DB 要「先查再改」,中間還要經過應用程式和網路,這段空窗期內其他交易可能讀到舊資料,造成版本衝突、一直重試;Redis 則是把「檢查 + 扣庫存」綁成同一個步驟,天生衝突就比較少。

實作範例

初始化庫存:

# 設定商品 123 的庫存為 100
SET product:123:qty 100

減少庫存(Lua 腳本保證原子性):

-- redis_script.lua
local key = KEYS[1]
local qty = redis.call('GET', key)

if not qty or tonumber(qty) <= 0 then
  return 0  -- 已售完
end

redis.call('DECR', key)
return 1  -- 成功

後端程式碼:

// Node.js + ioredis
const redis = require('ioredis');
const client = new redis();

async function purchaseWithRedis(productId, quantity = 1) {
  const key = `product:${productId}:qty`;
  
  // 使用 DECR 自動減庫存(原子操作)
  const remaining = await client.decr(key);
  
  if (remaining < 0) {
    // 庫存不足,還原
    await client.incr(key);
    return false;
  }
  
  return true;  // 成功
}

優缺點

Redis 最大的優點就是快,所有東西都在記憶體裡,指令又是原子執行,很適合拿來當庫存、計數器這種「數字型」的前線閘門,大多數情況也不太需要重試。缺點是,資料本身只存在 Redis,如果持久化沒設好,斷電或異常時有遺失風險,而且最後還是得同步回主資料庫,中間會有一點點時間差,要用對場景。


方案 4:Queue 佇列削峰(Queue-Based Load Shedding)

洪峰來臨時,與其讓所有請求同時進來把伺服器壓爆,不如先丟進 Queue 佇列排隊慢慢處理。

心智模型可以這樣想:

  • 前端 API:同步 把這次購買請求「寫進佇列」,很快回應「排隊中」。
  • 後端 worker:在背景 非同步 把佇列裡的請求一個一個拿出來,扣庫存、建訂單、刷卡。

使用者到底在等什麼?

在這個設計裡,使用者不需要等 queue 把事情做完 才拿到 API 回應。

可以簡單分成兩層來看:

  • HTTP API 層
    • 只會「同步等」一件事:purchaseQueue.add(...) 有沒有成功把這次請求加進佇列(或是佇列已滿、Redis 掛掉等錯誤)。
    • 這通常只要幾毫秒 → 加完就立刻回 { status: '排隊中', jobId / position }不會等到扣庫存、刷卡整個流程都跑完
  • 背景處理層(worker)
    • 真正的「扣庫存 / 建訂單 / 刷卡」都在 worker 裡 非同步慢慢處理
    • 使用者接下來可以:
      • 看前端顯示的「排隊中」畫面,前端自己定期去 call 狀態查詢 API;或
      • 用 WebSocket / push 通知,在「搶購成功 / 失敗」時再主動提醒使用者。

核心概念

使用者         佇列服務           庫存檢查          付款
├─ 同步:加入佇列 ──→ 
├─ 排隊等待
├─ 輪到你
└─ 非同步:扣款、確認 ──→ 減庫存 ──→ 成功返回

流量從 尖峰 變成 平緩

實作範例

使用 RabbitMQ / Kafka 或 Redis Queue:

// 1. 使用者點購買 → 加入佇列
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;
  
  // 直接返回,交給佇列處理
  await purchaseQueue.add({ productId, userId });
  
  res.json({ 
    status: '排隊中',
    position: await purchaseQueue.count()
  });
});

// 2. 佇列背景處理
purchaseQueue.process(async (job) => {
  const { productId, userId } = job.data;
  
  // 真正執行購買邏輯
  const success = await deductInventory(productId);
  
  if (success) {
    await processPayment(userId, productId);
    await sendConfirmationEmail(userId);
    return { status: 'success' };
  } else {
    throw new Error('庫存不足');
  }
});

優缺點

Queue 的好處,就是把原本一秒湧進來的海量請求攤平變成一條隊伍,後端伺服器的壓力會平均很多,加上多半有內建重試機制,整體穩定度也會比較好。相對的,使用者就得接受「排隊一下」這件事,系統本身也多了一套佇列基礎建設要維護,整體複雜度會比單一資料庫高一些。


整合方案:三層防守

實務上,通常三個技術都用上,形成 多層防禦

┌─────────────────────────────────────┐
│  使用者請求                          │
└──────────────┬──────────────────────┘
               │
        ┌──────▼─────────┐
        │  佇列削峰       │  防止雪崩
        │  (限流)        │
        └──────┬─────────┘
               │
        ┌──────▼─────────────────┐
        │  Redis 原子扣庫存      │  快速、可靠
        │  DECR / Lua Script    │
        └──────┬─────────────────┘
               │
        ┌──────▼─────────────────┐
        │  樂觀鎖同步到 DB       │  持久化
        │  (最終一致性)         │
        └──────┬─────────────────┘
               │
        ┌──────▼──────────┐
        │  訂單、支付流程  │
        │  (異步處理)     │
        └─────────────────┘
  「樂觀鎖同步到 DB」可以這樣實作:

  1. 資料庫的 `products` 表保留 `quantity` + `version` 欄位。
  2. 佇列消費者在 **Redis 扣庫存成功之後**,再從 DB 讀取該商品當前 `version`。
  3. 以樂觀鎖更新:`UPDATE ... SET quantity = quantity - 1, version = version + 1 WHERE id = ? AND version = ?`。
  4. 若 `affectedRows = 0`,代表版本被別人更新過,可以:
     - 重試幾次;或
     - 記錄成待對帳資料,之後用批次任務修正。

  這樣 Redis 只負責「前線搶購速度」,DB 則負責「最終正本」,兩者透過樂觀鎖維持最終一致性。

換句話說,在這個架構裡:

- **資料庫只負責「資料正確、記錄完整」**(正本、對帳、報表)。
- **不要把「撐高併發」丟給資料庫**,那是 Redis + Queue 要扛的事。

具體流程:

實務上流程可以想成:

  1. API 收到請求 → 加入佇列,立刻回「排隊中」。
  2. 佇列 worker 從 Queue 拿出請求,先用 Redis 原子扣庫存。
  3. 扣成功後,再用樂觀鎖更新 DB,確保資料正確。
  4. 最後在背景處理付款、寄信等後續動作。

實戰檢查清單

真的要把搶購系統丟上線,可以先簡單檢查幾個方向就好。庫存這邊,記得幫 Redis 開好持久化機制,偶爾把數字對回資料庫,賣完要能立刻停掉入隊,避免還在收單。佇列這邊,設一個合理的長度上限,有失敗重試和告警機制,避免佇列爆掉都沒人發現。資料一致性的部分,可以規劃定期對帳、重試策略,以及退貨、異常時要怎麼補償。最後是使用者體驗,給使用者一個「排隊中」或大概的排隊位置,有查詢訂單狀態的方式,真的沒搶到時,也有說明或補償方案,感受會好很多。


總結

技術用途優點缺點
樂觀鎖版本檢查,防止超賣簡單、高效衝突時需重試
Redis 原子性快速扣庫存超快、原子需持久化、有延遲
佇列削峰填谷、限流穩定、容錯使用者等待

最佳實踐:簡單講,就是「佇列先把請求排隊、Redis 負責快速扣庫存、資料庫用樂觀鎖把結果寫正確」,這三招一起用,通常就能撐過大部分的搶購活動。🚀


Tags

ecommerceflash saleoptimistic lockredisqueuehigh concurrencyinventory
Mark Ku

Mark Ku

Software Developer

10年以上豐富網站開發經驗,開發過各種網站,電子商務、平台網站、直播系統、POS系統、SEO 優化、金流串接、AI 串接,Infra 出身,帶過幾次團隊,也加入過大團隊一起開發。

Expertise

前端(React)
後端(C#)
網路管理
DevOps
溝通
領導

Social Media

facebook github website

Quick Links

關於我

Social Media