
雖然沒實作過,但研究一下電商的搶購系統,怎麼設計,下次要做就會比較快,這篇文章用 最白話的方式 講解三個核心技術:
可以先把三個角色想成這樣:Redis 衝在第一線,用記憶體超快扣庫存,負責「撐壓」;Queue 佇列負責先把請求收進來排隊,讓使用者看到「排隊中」;資料庫則當正本和記帳本,只管資料正不正確、有沒有存好,不負責硬撐所有流量。
如果只想抓重點,可以這樣記:不想超賣,就在資料庫那層加「樂觀鎖 + 版本號」來更新庫存;想撐高併發,就在前面多放一層 Redis,用記憶體做原子扣庫存;想讓系統撐久一點,再加上一個 Queue 佇列,先把請求排隊起來,後面慢慢處理。
假設只剩 1 件商品,有 2 個人同時搶購。
時間 user1 user2 t1 查庫存:1件 t2 確認下單 查庫存:1件 t3 扣庫存 → 0 件 確認下單 t4 扣庫存 → -1 件 ❌ 超賣!
根本原因:讀和寫之間的間隙,允許多人同時操作同一件商品。
在講樂觀鎖之前,先快速補一下「悲觀鎖」這個經典做法,很多舊系統或金流相關系統都還是用這一套,這樣的做法確實很容易,就能確保交易的一致性。
所謂 悲觀鎖,就是「我先假設一定會衝突,所以乾脆一開始就鎖起來」。
簡化流程會像這樣:
BEGIN TRANSACTION)。SELECT ... FOR UPDATE 把那筆商品資料鎖起來(別人只能等你用完)。COMMIT 提交交易,釋放鎖。只要你還沒 COMMIT / ROLLBACK,其他人就算查同一筆資料也會被「卡住」等你處理完,天生不會超賣,但換來的是「大家都在排隊等資料庫」。
-- 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 釋放鎖。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(); }
所以在一般高併發搶購系統裡,悲觀鎖通常不會當成第一選擇,而是「你真的很在意資料不能有一絲誤差」的補充手段;多數情況會改用「樂觀鎖 + Redis + Queue」這種比較能撐流量的組合。
樂觀鎖的想法很簡單:我不鎖住數據,而是在提交時檢查「期間有沒有人改過」。
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 幫忙分壓。
先簡單解釋一下「原子性」:可以把它想成一個動作要嘛整個做完、要嘛完全沒發生,中間不會卡在「做到一半」的奇怪狀態。
樂觀鎖通常是「先查再決定要不要更新」,在這中間還會經過應用程式和網路;Redis 的做法比較像是把整個扣庫存的流程包成一次原子操作,丟進去之後在執行的那一刻不會被別的指令打斷。
Redis 是單線程,所有操作都在記憶體,指令執行不會被中斷,相當的快速。
User1 User2 Redis 減庫存 減庫存 庫存 = 1 ───────────────────────────────────────── ├─ 執行 DECR │ 庫存 = 0 │ ├─ 執行 DECR │ │ 庫存 = -1 └─ 返回 0 ✓ └─ 返回 -1 ❌
先從 Redis 自己說起:它是單線程模型,一次只會處理一個指令,其他請求都乖乖排在隊伍裡等,所以不會出現「兩個人同時改同一個值」這種情況。
再來是 Lua。可以把 Lua 想成一個很輕量的腳本語言,很常被「嵌」進別的系統裡當客製化邏輯在用,像 Nginx / OpenResty、很多遊戲引擎都有 Lua 的身影。Redis 本身就內建 Lua 執行環境,所以我們可以把一串 Redis 指令寫成一小段 Lua 程式碼,交給 Redis 一次跑完。
用更白話一點講,Lua 腳本就是把很多 Redis 指令打包成一顆不可拆的膠囊。
裡面可以先 GET 再 DECR,中間不會有人插隊,外面看起來就像送進去一個「大指令」,要嘛整包成功、要嘛整包失敗,這就是 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,如果持久化沒設好,斷電或異常時有遺失風險,而且最後還是得同步回主資料庫,中間會有一點點時間差,要用對場景。
洪峰來臨時,與其讓所有請求同時進來把伺服器壓爆,不如先丟進 Queue 佇列排隊慢慢處理。
心智模型可以這樣想:
- 前端 API:同步 把這次購買請求「寫進佇列」,很快回應「排隊中」。
- 後端 worker:在背景 非同步 把佇列裡的請求一個一個拿出來,扣庫存、建訂單、刷卡。
在這個設計裡,使用者不需要等 queue 把事情做完 才拿到 API 回應。
可以簡單分成兩層來看:
purchaseQueue.add(...) 有沒有成功把這次請求加進佇列(或是佇列已滿、Redis 掛掉等錯誤)。{ status: '排隊中', jobId / position },不會等到扣庫存、刷卡整個流程都跑完。使用者 佇列服務 庫存檢查 付款 ├─ 同步:加入佇列 ──→ ├─ 排隊等待 ├─ 輪到你 └─ 非同步:扣款、確認 ──→ 減庫存 ──→ 成功返回
流量從 尖峰 變成 平緩。
使用 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 要扛的事。
具體流程:
實務上流程可以想成:
真的要把搶購系統丟上線,可以先簡單檢查幾個方向就好。庫存這邊,記得幫 Redis 開好持久化機制,偶爾把數字對回資料庫,賣完要能立刻停掉入隊,避免還在收單。佇列這邊,設一個合理的長度上限,有失敗重試和告警機制,避免佇列爆掉都沒人發現。資料一致性的部分,可以規劃定期對帳、重試策略,以及退貨、異常時要怎麼補償。最後是使用者體驗,給使用者一個「排隊中」或大概的排隊位置,有查詢訂單狀態的方式,真的沒搶到時,也有說明或補償方案,感受會好很多。
| 技術 | 用途 | 優點 | 缺點 |
|---|---|---|---|
| 樂觀鎖 | 版本檢查,防止超賣 | 簡單、高效 | 衝突時需重試 |
| Redis 原子性 | 快速扣庫存 | 超快、原子 | 需持久化、有延遲 |
| 佇列 | 削峰填谷、限流 | 穩定、容錯 | 使用者等待 |
最佳實踐:簡單講,就是「佇列先把請求排隊、Redis 負責快速扣庫存、資料庫用樂觀鎖把結果寫正確」,這三招一起用,通常就能撐過大部分的搶購活動。🚀