Lesson 6: 悲觀與樂觀:資料庫鎖 (Locking) 機制與並發處理
在上一篇文說到了:三種讀取異常,也介紹了四種隔離等級,如果是第一次接觸到這些概念的人一定會有疑問:
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。
因此:
隔離級別解決的是 「讀」 的一致性問題(我看的時候不要變)。
Lock 解決的是 「寫 」 的競爭問題(我要改的時候別人不能動)。
什麼是鎖?
在資料庫的世界裡,鎖並不是只有「鎖住」跟「沒鎖住」這麼簡單。為了效能,資料庫將鎖分成了兩大類
共享鎖
口語解釋: 「我只是要讀這份資料,大家都可以一起讀,但誰都不准改。」
行為:
當事務 A 對資料加上 S 鎖,事務 B 可以 也加 S 鎖(一起讀)。
但是,事務 B 不能 加 X 鎖(不能改)。
情境: 就像大家在美術館看畫,可以 100 個人同時看(讀),但沒人可以拿筆上去畫(寫)。
排他鎖
口語解釋: 「我要修改這份資料了!閒雜人等全部退下,不准讀也不准改。」
行為:
當事務 A 對資料加上 X 鎖,事務 B 什麼鎖都不能加。
B 想要讀 (S 鎖)?不行,要排隊。
B 想要改 (X 鎖)?不行,要排隊。
情境: 就像進去試衣間,一次只能一個人,其他人不能進來,連看一眼都不行。
鎖的範圍
在資料庫中,其實鎖是有範圍的,總不能 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 中使用
優缺點分析
優點: 強一致性。保證絕對不會發生 Race Condition,不會超賣。
缺點:
併發度低: 所有想買票的人都要排隊(序列化執行)。
效能風險: 如果中間的業務邏輯執行很久(例如呼叫第三方金流 API 等了 5 秒),這張表的這行資料就會被鎖死 5 秒,後面的人全部 Timeout。
死鎖風險: 容易因互相鎖定資源而導致 Deadlock。
😃 樂觀鎖 — Optimistic Lock
樂觀鎖假設資料不會常被衝突更新,因此不提前加鎖。
它允許多個交易同時讀寫資料,但在更新時需要進行版本比對:
常見實作方式:新增 version 或 updated_at 欄位。
流程:
讀取資料時一併讀取
version更新時加入條件
UPDATE table SET ... , version = version + 1 WHERE id = ? AND version = ?若回傳列數 = 1 → 更新成功
若回傳列數 = 0 → 有其他交易修改過 → 驗證失敗,通常需 retry
特性:
不會阻塞其他交易
適合高讀、多寫,但寫入衝突機率低的情境
必須由應用程式端實作(MySQL 本身沒有 optimistic lock)
大部分場景,優先使用樂觀鎖: 例如:後台小編編輯文章、使用者修改個人資料。這些情況「同時兩個人改同一欄位」的機率很低,用樂觀鎖可以讓系統更輕量,不用一直佔用資料庫連線資源。
真的要搶購 (秒殺),考慮悲觀鎖或 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,把鎖的時機 「提早」 到讀取階段,把那個空窗期也保護起來。
小結:
單純的
UPDATE:會鎖,但只保護那一瞬間。
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 大小)來加鎖。
修改後的情境:
規定:永遠先鎖 ID 小的,再鎖 ID 大的。
A (1 -> 2):先鎖 1。
B (2 -> 1):因為規定要先鎖小 ID,所以 B 也會試著先鎖 1。
結果: B 在第一步就被擋住了(等待 A),不會拿到鎖 2。這樣 A 就可以順利鎖完 1 和 2,做完事情釋放。B 再接著做。死鎖解除!
策略 B:縮短交易時間 (Keep it Short) Transaction 範圍越小越好。不要在 Transaction 裡面做 HTTP 請求、寄信、讀檔等耗時操作。持有鎖的時間越短,發生衝突的機率就越低。
策略 C:大鎖 (不推薦) 直接鎖住整張表 (Table Lock),雖然絕對不會死鎖(因為只有一把鑰匙),但效能會極差,通常不建議使用。

