在上一篇文說到了:三種讀取異常,也介紹了四種隔離等級,如果是第一次接觸到這些概念的人一定會有疑問:
Q:既然 RR 已經保證『可重複讀』了,為什麼搶票系統還是會超賣?資料庫不是隔離了嗎?
A:隔離級別(透過 MVCC)通常只保證「你看得到的資料」是一致的(快照),但它擋不住多個人同時對同一筆資料進行「寫入」
回到搶票案例:
A 讀取 (Select):MVCC 給了 A 一張「剩餘 1 張」的照片。
B 讀取 (Select):MVCC 給了 B 一張「剩餘 1 張」的照片。
A 寫入 (Update):資料庫檢查鎖,沒人鎖,A 成功扣款。
B 寫入 (Update):資料庫檢查鎖,雖然 A 改過了,但 B 的 SQL 邏輯如果是 UPDATE ... SET count = count - 1,它會基於最新的實體資料去扣,結果變成 -1。
因此:
什麼是鎖?
在資料庫的世界裡,鎖並不是只有「鎖住」跟「沒鎖住」這麼簡單。為了效能,資料庫將鎖分成了兩大類
共享鎖
排他鎖
鎖的範圍
在資料庫中,其實鎖是有範圍的,總不能 A進入試衣間,結果整個Uniqlo把鐵門拉下來叫大家在外面排隊,對吧?
鎖「鎖住了多大範圍」也是影響效能的關鍵。這稱為 鎖的粒度 (Lock Granularity)。
表鎖 (Table Lock)
定義: 一次鎖住整張資料表 (Table)。
情境: 有人在用這張表,其他人全部卡住。
優點: 簡單,由資料庫管理開銷小,不會有死鎖的問題。
缺點: 併發度極低。只要一個人改某一行資料,整張表幾萬行資料別人都不能動。
觸發時機: 通常在做 ALTER TABLE (修改欄位結構) 或沒有使用索引 (Index) 更新時發生。
2. 行鎖 (Row Lock)
定義: 只鎖住你正在操作的那 「一行 (Row)」 資料。
情境: A 修改第 1 筆資料,B 修改第 2 筆資料,互不影響。
優點: 併發度高,大家各改各的,除非剛好改同一行。
缺點: 鎖的開銷大(資料庫要紀錄每一行誰鎖了),且容易發生 死鎖。
觸發時機: 大多數現代資料庫引擎 (如 MySQL InnoDB) 預設使用行鎖。
警告 :Row Lock 是依賴「索引 (Index)」實現的! 如果你下的 UPDATE 指令 沒有用到索引,資料庫不知道你要鎖哪一行變成DB需要做 full table scan,在掃描每一筆資料時都必須上 行鎖 與 gap lock(間隙鎖) 結果就是「實際效果接近鎖全表」。 例子: UPDATE users SET age = 30 WHERE name = 'Tom'; 如果 name 欄位沒有加 Index,這行指令會鎖住整張 users 表
鎖的運用策略
上面講完鎖的概念以及我們極少碰到的東西之後,就要進入常見的策略應用啦!
😭 悲觀鎖 —Pessimistic Lock
悲觀鎖的核心觀點是:資料很可能被其他交易同時修改,因此先鎖起來以避免衝突。
當一筆 SQL 指令取得悲觀鎖後,其他交易在該範圍內的修改操作會被阻塞,直到鎖被釋放。
之前提到的搶票系統,如果避免超賣,通常都會使用悲觀鎖策略。
在 InnoDB 中常見的悲觀鎖包含:
行鎖 (Record Lock)
間隙鎖 (Gap Lock)
Next-Key Lock
表鎖
最常用的悲觀鎖語法:
SELECT ... FOR UPDATE;
這代表:
你要更新的那一行或範圍會被排他鎖定,阻擋其他帶鎖讀與寫入,但不會阻擋普通 SELECT。
Laravel 實作方式:
DB::table('users')
->where('votes', '>', 100)
->lockForUpdate()
->get();
注意:lockForUpdate() 只能在 Transaction 中使用
優缺點分析
😃 樂觀鎖 — Optimistic Lock
樂觀鎖假設資料不會常被衝突更新,因此不提前加鎖。
它允許多個交易同時讀寫資料,但在更新時需要進行版本比對:
常見實作方式:新增 version 或 updated_at 欄位。
流程:
讀取資料時一併讀取 version
更新時加入條件
UPDATE table
SET ... , version = version + 1
WHERE id = ? AND version = ?
若回傳列數 = 1 → 更新成功
若回傳列數 = 0 → 有其他交易修改過 → 驗證失敗,通常需 retry
特性:
大部分場景,優先使用樂觀鎖: 例如:後台小編編輯文章、使用者修改個人資料。這些情況「同時兩個人改同一欄位」的機率很低,用樂觀鎖可以讓系統更輕量,不用一直佔用資料庫連線資源。
真的要搶購 (秒殺),考慮悲觀鎖或 Redis: 如果是「限量 10 張票,1 萬人同時搶」,用樂觀鎖會導致 9999 人不斷 Retry,把 CPU 和資料庫打爆。這時候用悲觀鎖(讓 DB 強制排隊)或者更進階的 Redis 會是更好的選擇。
注意索引 (再次提醒): 如果你決定用悲觀鎖 (FOR UPDATE),千萬要記得 Lesson 6 前半段提到的警告:WHERE 條件一定要有索引,否則會讓系統瞬間癱瘓!\
Laravel中其實有一個寫法
DB::table('users')->where('votes', '>', 100)->sharedLock()->get();
在實務上用得比悲觀鎖少很多,但在以下幾種情境中非常必要——特別是當你需要「讀取時保證資料不會被其他交易修改」時。
讀取後要用資料做「依賴性判斷」
想避免讀到「被其他交易修改到一半的資料」
多交易同時讀取,但需阻止別人更新資料(報表 / 分析)
當你希望「其他交易不能用 FOR UPDATE 搶先鎖住」時
| 使用情境 | 是否會更新資料? | 是否允許他人讀? | 建議使用 |
| 讀取 → 判斷 → 更新資料 | 會 | 不允許其他人鎖住 | lockForUpdate()(悲觀鎖) |
| 讀取 → 判斷 → 不更新 | 不會 | 允許他人讀但不允許他人改 | sharedLock() |
| 純讀,不需要強一致性 | 不會 | 允許 | 普通 SELECT |
| 希望別人完全不能讀與寫 | 會 | 不允許 | 改用 SERIALIZABLE 隔離級別 |
其實單純 UPDATE 就會觸發 Row Lock!
這是很多人的誤區:以為只有寫出 FOR UPDATE 資料庫才會鎖。 事實是:任何的資料修改指令 (INSERT, UPDATE, DELETE),資料庫都會自動加上排他鎖 (X Lock)。
那為什麼我們還需要 SELECT ... FOR UPDATE?
這兩者的差別在於 「鎖的時機點」:
情境 A:一般的 UPDATE (隱式鎖)
SQL
- 假設我想把餘額 +100 UPDATE accounts SET balance = balance + 100 WHERE id = 1;
鎖定時機: 只有在這一行 SQL 執行當下,資料庫會鎖住 id=1 這行。
結果: 這行執行完立刻釋放(Transaction 結束)。這對於「原子更新」是安全的(例如上面的 SQL 不會因為並發而出錯)。
情境 B:先查再改 (Read-Modify-Write) 這就是我們搶票系統的問題所在:
SQL
- 1. 先讀取 (沒有鎖!) -> 這裡讀到 剩餘 1 張 SELECT count FROM tickets WHERE id = 1; -- (應用程式花 0.5 秒運算,發現還有票) -- ⚠️ 風險:在這 0.5 秒內,別人可能已經把票買走了! -- 2. 再更新 (這時候才加鎖,已經太晚了!) UPDATE tickets SET count = 0 WHERE id = 1;
問題: 雖然第 2 步 UPDATE 當下有鎖,但在第 1 步到第 2 步之間的 「空窗期」 是沒有保護的。
解決: 所以我們才需要 SELECT ... FOR UPDATE,把鎖的時機 「提早」 到讀取階段,把那個空窗期也保護起來。
小結:
鎖的副作用 - Deadlock (死鎖)
- 什麼是 Deadlock?
簡單來說,就是 「兩個 Transaction 互相拿著對方想要的鑰匙,且誰也不肯放手。」
Transaction A: 手上有 資源 1 的鎖,正在等 資源 2。
Transaction B: 手上有 資源 2 的鎖,正在等 資源 1。
結果: 兩邊都在等對方釋放,程式就會永久卡死在這裡,直到天荒地老(或被資料庫強制踢掉)。
- 經典案例:互相轉帳
最容易發生死鎖的情境就是「互相操作」兩筆資料。 假設:小明 (id:1) 要轉帳給小華 (id:2),同時小華也要轉帳給小明。

這時候,資料庫偵測到這個「循環等待 (Circular Wait)」,為了保護系統,它會強制殺掉 (Rollback) 其中一個 Transaction(通常是工作量較小的那個),並回傳 Error 給應用程式。
- 如何避免 Deadlock?
身為後端工程師,我們很難 100% 避免死鎖(因為它是機率問題),但我們可以透過良好的程式習慣來大幅降低機率:
策略 A:固定順序 不管你的業務邏輯是什麼,只要涉及多筆資料修改,永遠依照相同的順序(例如 ID 大小)來加鎖。
策略 B:縮短交易時間 (Keep it Short) Transaction 範圍越小越好。不要在 Transaction 裡面做 HTTP 請求、寄信、讀檔等耗時操作。持有鎖的時間越短,發生衝突的機率就越低。
策略 C:大鎖 (不推薦) 直接鎖住整張表 (Table Lock),雖然絕對不會死鎖(因為只有一把鑰匙),但效能會極差,通常不建議使用。