結論先講
大部分場景用樂觀鎖(DB version column)就夠了,不需要分散式鎖。 Redis 分散式鎖適合「跨服務搶同一個資源」的場景,但 Redlock 在網路分區時有爭議。搶購場景最穩的做法不是「鎖得更好」,而是「用 Redis 先扣庫存,再異步寫 DB」——把競爭從慢的 DB 層轉移到快的 Redis 層。
Race Condition 是什麼
兩個請求同時操作同一筆資料,結果取決於「誰先到」:
庫存 = 1
用戶 A: 讀庫存 → 1 → 可以買 → 扣庫存 → 0
用戶 B: 讀庫存 → 1 → 可以買 → 扣庫存 → -1 ← 超賣!
A 和 B 都讀到庫存 1,都認為可以買,都扣了。庫存變成 -1。
在壓測中這個問題會被放大。 第 1 篇 提到的「併發請求 → 庫存變成 -1」就是典型的 race condition。UV 10 就可能觸發。
解法一:DB 悲觀鎖(SELECT FOR UPDATE)
怎麼做
BEGIN;
SELECT stock FROM products WHERE id = 123 FOR UPDATE; -- 鎖住這一行
-- 其他 transaction 讀到這裡會等
IF stock > 0 THEN
UPDATE products SET stock = stock - 1 WHERE id = 123;
END IF;
COMMIT;FOR UPDATE 拿到 exclusive lock,其他 transaction 要等到 COMMIT 才能讀這一行。
適合
- 單體架構(一個 DB,一個 application)
- 併發不高(< 100 QPS)
- 邏輯簡單(鎖一行、改一行、解鎖)
不適合
- 微服務(庫存和訂單在不同服務,不同 DB)
- 高併發搶購(鎖排隊 = 所有請求串行化 = 慢)
- 鎖的時間長(transaction 裡做了外部 API call)
壓測數據
第 18 篇:PG 的鎖競爭表現比 MySQL 好 40%——PG 的 MVCC 讓讀不阻塞寫,MySQL 的 gap lock 更積極。
解法二:DB 樂觀鎖(Version Column)
怎麼做
-- 商品表加 version 欄位
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- 扣庫存時帶 version
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 123 AND stock > 0 AND version = 5;
-- 影響 0 行 = 被別人先改了 → 重試或告訴用戶「被搶了」不加鎖,而是在 UPDATE 時檢查 version 是否和讀取時一致。如果不一致(被別人改了),UPDATE 影響 0 行 → 知道有衝突。
ORM 寫法
# FastAPI + SQLAlchemy
result = await session.execute(
update(Product)
.where(Product.id == product_id, Product.stock > 0, Product.version == current_version)
.values(stock=Product.stock - 1, version=Product.version + 1)
)
if result.rowcount == 0:
raise HTTPException(409, "商品已售完或被搶走")適合
- 大部分場景(推薦預設用這個)
- 衝突不頻繁(< 10% 的請求會衝突)
- 可以接受「失敗重試」
不適合
- 衝突極頻繁(搶購最後 1 件,100 人同時搶 → 99 人都失敗要重試)
- 跨服務的資源(version 只在一個 DB 裡)
解法三:Redis 分散式鎖
什麼時候需要
庫存在 Product Service 的 DB,但扣庫存的邏輯在 Order Service。跨服務不能用 DB 鎖——你不能在 Order Service 的 transaction 裡鎖 Product Service 的 DB row。
最簡單的 Redis 鎖
// 嘗試拿鎖
const acquired = await redis.set(
`lock:product:${productId}`,
requestId, // 唯一識別,解鎖時驗證
'NX', // 只在 key 不存在時 SET(原子操作)
'EX', 5 // 5 秒過期(防止持有者掛了永遠不解鎖)
);
if (!acquired) {
return { error: '系統繁忙,請稍後再試' };
}
try {
// 拿到鎖:安全地扣庫存
const stock = await productService.getStock(productId);
if (stock <= 0) throw new Error('售完');
await productService.decrementStock(productId);
await orderService.createOrder(userId, productId);
} finally {
// 解鎖(只有持有者能解)
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(script, 1, `lock:product:${productId}`, requestId);
}注意事項
- 一定要設過期時間(EX):持有者掛了,鎖會自動釋放
- 解鎖要驗證身分(requestId):不能解別人的鎖
- 解鎖用 Lua script:GET + DEL 要原子操作,不然 GET 完還沒 DEL 就過期了
- 鎖的粒度要小:鎖
product:123不是鎖product:*
Redlock 的爭議
Redis 官方提出的 Redlock 用多個 Redis 節點做分散式鎖(過半數拿到才算拿到鎖)。但 Martin Kleppmann(DDIA 作者)指出 Redlock 在以下情況可能失效:
- GC 暫停:拿到鎖 → GC 暫停 5 秒 → 鎖過期 → 別人拿到鎖 → GC 恢復 → 兩個人都以為自己有鎖
- 時鐘跳動:NTP 校時導致 Redis 節點的 TTL 計算不一致
結論:Redlock 不是 100% 安全的。 如果你需要「絕對不能超賣」,用 DB 的 serializable isolation + 樂觀鎖。Redis 鎖適合「大部分情況能防住就好」的場景。
解法四:搶購場景的最佳實踐
搶購(flash sale)的特性:幾千人同時搶幾十件商品。任何鎖方案在這個併發下都會排隊排到 timeout。
最穩的做法:Redis 先扣,DB 後寫
1. 商品上架時:INCRBY product:123:stock 100(Redis 存庫存)
2. 用戶搶購:
result = DECR product:123:stock
if result < 0:
INCR product:123:stock // 補回去
return "售完"
else:
發 event → Order Service 異步建訂單 + 寫 DB
return "搶購成功,訂單處理中"
為什麼這樣做:
DECR是原子操作,Redis 單線程保證不會有 race condition- 不需要鎖——DECR 本身就是原子的
- Redis 每秒能處理 10 萬+ 個 DECR,不會是瓶頸
- DB 寫入異步化,不會被搶購流量打爆
風險
- Redis 掛了怎麼辦:庫存數字丟了。解法:Redis 持久化(RDB/AOF)+ 定期和 DB 對帳
- 異步訂單失敗怎麼辦:DECR 成功但訂單沒建。解法:Queue 重試 + 補償機制
- 用戶體驗:「搶購成功」但訂單還沒建好。解法:頁面顯示「訂單處理中」,WebSocket 推送最終結果
選型速查
| 場景 | 推薦方案 | 理由 |
|---|---|---|
| 單體 + 低併發 | DB FOR UPDATE | 最簡單 |
| 單體 + 中併發 | DB 樂觀鎖(version) | 不排隊,衝突重試 |
| 微服務 + 跨服務資源 | Redis SET NX 鎖 | 跨服務唯一方案 |
| 搶購 / 秒殺 | Redis DECR(不用鎖) | 原子操作,10 萬 QPS |
| 絕對不能錯(金融) | DB serializable + 樂觀鎖 | 犧牲效能換正確性 |
和系列其他文章的關聯
- DB 鎖的壓測數據 → 第 18 篇(PG vs MySQL 鎖競爭差 40%)
- Cache stampede 的 mutex lock → 第 33 篇(Redis SET NX 防踩踏)
- Saga 的補償機制 → 第 46 篇(扣庫存失敗怎麼 rollback)
- Event-Driven 異步處理 → 第 35 篇(搶購成功後異步建訂單)
- Eventual Consistency → 第 47 篇(庫存必須強一致)
下一篇
樂觀鎖 vs 悲觀鎖:實作與踩坑 — 選型講完了,接下來講實作——ORM 怎麼寫、deadlock 怎麼避免、retry 怎麼做。
本系列文章
完整 68 篇目錄見 系列首頁
← 上一篇:微服務通訊協定:REST vs gRPC vs GraphQL,什麼時候用什麼 → 下一篇:樂觀鎖 vs 悲觀鎖:實作細節與踩坑指南