結論先講

樂觀鎖適合「衝突少」的場景,悲觀鎖適合「絕對不能錯」的場景。 但實作時的細節決定了成敗——樂觀鎖的 retry 要有上限和退避策略、悲觀鎖的鎖順序要一致不然 deadlock、ORM 的 .save() 可能不帶 version check 讓樂觀鎖形同虛設。


樂觀鎖深入

核心機制

讀取時: SELECT id, stock, version FROM products WHERE id = 123
        → stock=10, version=5

更新時: UPDATE products SET stock=9, version=6
        WHERE id=123 AND version=5
        → 影響 1 行 = 成功
        → 影響 0 行 = 被別人改了,衝突!

不加鎖、不排隊。只在寫入那一刻檢查「我讀的時候的狀態還在嗎」。

各 ORM 的實作

Sequelize (Node.js)

// 方法一:手動 version check
const product = await Product.findByPk(123);
const [affectedRows] = await Product.update(
  { stock: product.stock - 1, version: product.version + 1 },
  { where: { id: 123, version: product.version } }
);
if (affectedRows === 0) throw new Error('版本衝突');
 
// 方法二:Sequelize 內建 optimisticLocking(需要 version 欄位)
// model 定義時加 version: true
const Product = sequelize.define('Product', { ... }, { version: true });
// .save() 會自動帶 version check

SQLAlchemy (Python/FastAPI)

from sqlalchemy.orm import configure_mappers
from sqlalchemy import Column, Integer
 
class Product(Base):
    __tablename__ = 'products'
    id = Column(Integer, primary_key=True)
    stock = Column(Integer)
    version_id = Column(Integer, nullable=False, default=0)
    
    __mapper_args__ = {
        "version_id_col": version_id  # SQLAlchemy 內建樂觀鎖
    }
 
# 使用:session.commit() 時自動檢查 version
# 衝突會拋 StaleDataError

TypeORM (NestJS)

@Entity()
class Product {
  @PrimaryGeneratedColumn()
  id: number;
 
  @Column()
  stock: number;
 
  @VersionColumn()  // TypeORM 內建樂觀鎖
  version: number;
}
 
// 使用:repo.save(product) 自動帶 version check
// 衝突拋 OptimisticLockVersionMismatchError

GORM (Go)

// GORM 沒有內建樂觀鎖,手動做
result := db.Model(&Product{}).
    Where("id = ? AND version = ?", productID, currentVersion).
    Updates(map[string]interface{}{
        "stock":   gorm.Expr("stock - 1"),
        "version": gorm.Expr("version + 1"),
    })
if result.RowsAffected == 0 {
    return errors.New("版本衝突")
}

樂觀鎖的陷阱

陷阱 1:ORM 的 .save() 不一定帶 version check

// 危險!直接 save 可能覆蓋別人的修改
const product = await Product.findByPk(123);
product.stock -= 1;
await product.save();  // 如果 ORM 沒設 version: true,這裡不會檢查

解法:用 .update() + WHERE version 條件,或確認 ORM 的 version 功能有開。

陷阱 2:衝突時直接報錯,沒有 retry

// 差的寫法:衝突就報 500
if (affectedRows === 0) throw new Error('衝突');
 
// 好的寫法:自動重試
async function decrementStock(productId, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const product = await Product.findByPk(productId);
    if (product.stock <= 0) throw new Error('售完');
    
    const [affected] = await Product.update(
      { stock: product.stock - 1, version: product.version + 1 },
      { where: { id: productId, version: product.version } }
    );
    
    if (affected > 0) return; // 成功
    
    // 衝突:指數退避後重試
    await sleep(Math.pow(2, i) * 50); // 50ms, 100ms, 200ms
  }
  throw new Error('系統繁忙,請稍後再試');
}

陷阱 3:ABA 問題

Thread A: 讀 version=5, stock=10
Thread B: 改 version=6, stock=9
Thread C: 改 version=7, stock=10(補貨)
Thread A: UPDATE WHERE version=5 → 失敗(version 已經是 7)

version 從 5 → 6 → 7,A 的更新會失敗。這通常是正確行為(stock 被改過了就該重新讀取)。但如果用 timestamp 替代 version,可能出現 ABA 問題——兩次修改剛好在同一毫秒。建議用遞增整數 version,不用 timestamp。


悲觀鎖深入

SELECT FOR UPDATE 的行為

-- Session A
BEGIN;
SELECT * FROM products WHERE id = 123 FOR UPDATE;
-- 拿到鎖,其他 session 碰這一行會等
 
-- Session B(同時)
BEGIN;
SELECT * FROM products WHERE id = 123 FOR UPDATE;
-- 被擋住,等 Session A commit 或 rollback

Deadlock:最常見的悲觀鎖 bug

Session A: 鎖 product 123 → 試圖鎖 product 456
Session B: 鎖 product 456 → 試圖鎖 product 123
→ A 等 B 放 456,B 等 A 放 123 → 永遠等不到 = deadlock

DB 會偵測到 deadlock 並殺掉其中一個 session。但你的 application 會收到一個 error。

解法:鎖的順序要一致

-- 永遠按 ID 小到大的順序鎖
-- 不管業務邏輯要先操作哪個
SELECT * FROM products WHERE id IN (123, 456) ORDER BY id FOR UPDATE;

FOR UPDATE 的作用範圍

DB行為
PostgreSQL只鎖匹配的行(row-level lock)
MySQL (InnoDB)鎖匹配的行 + 可能加 gap lock(鎖一個範圍)

MySQL 的 gap lock 在 REPEATABLE READ 隔離等級下特別積極。如果你的 WHERE 條件用了範圍查詢(WHERE price > 100),MySQL 可能鎖住整個範圍內的行,包括還不存在的行。

解法:用 WHERE id = ?(精確匹配)而非範圍查詢做 FOR UPDATE。

各 ORM 的悲觀鎖寫法

# SQLAlchemy
product = session.query(Product).filter_by(id=123).with_for_update().first()
 
# Sequelize
const product = await Product.findByPk(123, { lock: true, transaction: t });
 
# TypeORM
const product = await repo.findOne({ where: { id: 123 }, lock: { mode: "pessimistic_write" } });
 
# GORM
var product Product
db.Clauses(clause.Locking{Strength: "UPDATE"}).First(&product, 123)

Isolation Level 的影響

Level讀到髒資料不可重複讀幻讀效能
Read Uncommitted可能可能可能最快
Read Committed不會可能可能PG 預設
Repeatable Read不會不會可能MySQL 預設
Serializable不會不會不會最慢
  • PG 預設 Read Committed:每次 SELECT 看到當下最新的 committed 資料。適合大部分場景。
  • MySQL 預設 Repeatable Read:同一個 transaction 內多次 SELECT 看到相同的 snapshot。加上 gap lock 防止幻讀。

不要為了防 race condition 改 isolation level 到 Serializable——效能會掉很多。用樂觀鎖或悲觀鎖才是正確做法。


實務選型

情境衝突頻率推薦理由
一般 CRUD(編輯用戶資料)極低樂觀鎖幾乎不衝突,不需要排隊
庫存扣減(一般電商)低~中樂觀鎖 + retry偶爾衝突,重試就好
搶購秒殺極高Redis DECR(上一篇不用鎖
帳戶餘額扣款悲觀鎖不能錯,寧可慢
批次匯入樂觀鎖衝突時重跑整批


下一篇

DB 讀寫分離:值不值得做? — 鎖的問題解決了,接下來看 DB 擴展——讀寫分離的 ROI 真的比 Redis cache 高嗎?

本系列文章

完整 68 篇目錄見 系列首頁

← 上一篇:Race Condition 與分散式鎖:微服務裡的搶購怎麼不超賣 → 下一篇:DB 讀寫分離:值不值得做?什麼時候做?