結論先講
樂觀鎖適合「衝突少」的場景,悲觀鎖適合「絕對不能錯」的場景。 但實作時的細節決定了成敗——樂觀鎖的 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 checkSQLAlchemy (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
# 衝突會拋 StaleDataErrorTypeORM (NestJS)
@Entity()
class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
stock: number;
@VersionColumn() // TypeORM 內建樂觀鎖
version: number;
}
// 使用:repo.save(product) 自動帶 version check
// 衝突拋 OptimisticLockVersionMismatchErrorGORM (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 或 rollbackDeadlock:最常見的悲觀鎖 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 讀寫分離:值不值得做?什麼時候做?