結論先講

大部分場景用樂觀鎖(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);
}

注意事項

  1. 一定要設過期時間(EX):持有者掛了,鎖會自動釋放
  2. 解鎖要驗證身分(requestId):不能解別人的鎖
  3. 解鎖用 Lua script:GET + DEL 要原子操作,不然 GET 完還沒 DEL 就過期了
  4. 鎖的粒度要小:鎖 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 悲觀鎖:實作細節與踩坑指南