# Lesson 6: 悲觀與樂觀：資料庫鎖 (Locking) 機制與並發處理

在上一篇文說到了：三種讀取異常，也介紹了四種隔離等級，如果是第一次接觸到這些概念的人一定會有疑問：

Q：既然 RR 已經保證『可重複讀』了，為什麼搶票系統還是會超賣？資料庫不是隔離了嗎？

A：隔離級別（透過 MVCC）通常只保證「你看得到的資料」**是一致的（快照），但它**擋不住**多個人同時對同一筆資料進行**「寫入」

回到搶票案例：

1. **A 讀取 (Select)**：MVCC 給了 A 一張「剩餘 1 張」的照片。
    
2. **B 讀取 (Select)**：MVCC 給了 B 一張「剩餘 1 張」的照片。
    
3. **A 寫入 (Update)**：資料庫檢查鎖，沒人鎖，A 成功扣款。
    
4. **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
    
* 表鎖
    

最常用的悲觀鎖語法：

```sql
SELECT ... FOR UPDATE;
```

這代表：

你要更新的那一行或範圍會被排他鎖定，阻擋其他帶鎖讀與寫入，但不會阻擋普通 SELECT。

Laravel 實作方式：

```php
DB::table('users')
    ->where('votes', '>', 100)
    ->lockForUpdate()
    ->get();
```

注意：`lockForUpdate()` 只能在 Transaction 中使用

### 優缺點分析

* 優點： 強一致性。保證絕對不會發生 Race Condition，不會超賣。
    
* 缺點：
    
    * 併發度低： 所有想買票的人都要排隊（序列化執行）。
        
    * 效能風險： 如果中間的業務邏輯執行很久（例如呼叫第三方金流 API 等了 5 秒），這張表的這行資料就會被鎖死 5 秒，後面的人全部 Timeout。
        
    * 死鎖風險： 容易因互相鎖定資源而導致 Deadlock。
        

## **😃** 樂觀鎖 — Optimistic Lock

樂觀鎖假設資料不會常被衝突更新，因此**不提前加鎖**。

它允許多個交易同時讀寫資料，但在更新時需要進行**版本比對**：

常見實作方式：新增 `version` 或 `updated_at` 欄位。

流程：

1. 讀取資料時一併讀取 `version`
    
2. 更新時加入條件
    
    ```plaintext
    UPDATE table
    SET ... , version = version + 1
    WHERE id = ? AND version = ?
    ```
    
3. 若回傳列數 = 1 → 更新成功
    
    若回傳列數 = 0 → 有其他交易修改過 → 驗證失敗，通常需 retry
    

特性：

* 不會阻塞其他交易
    
* 適合高讀、多寫，但寫入衝突機率低的情境
    
* 必須由應用程式端實作（MySQL 本身沒有 optimistic lock）
    

> **大部分場景，優先使用樂觀鎖：** 例如：後台小編編輯文章、使用者修改個人資料。這些情況「同時兩個人改同一欄位」的機率很低，用樂觀鎖可以讓系統更輕量，不用一直佔用資料庫連線資源。
> 
> **真的要搶購 (秒殺)，考慮悲觀鎖或 Redis：** 如果是「限量 10 張票，1 萬人同時搶」，用樂觀鎖會導致 9999 人不斷 Retry，把 CPU 和資料庫打爆。這時候用悲觀鎖（讓 DB 強制排隊）或者更進階的 **Redis** 會是更好的選擇。
> 
> **注意索引 (再次提醒)：** 如果你決定用悲觀鎖 (`FOR UPDATE`)，**千萬要記得 Lesson 6 前半段提到的警告**：`WHERE` 條件一定要有索引，否則會讓系統瞬間癱瘓！\\

---

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">補充-Laravel 中的共享鎖使用</div>
</div>

Laravel中其實有一個寫法

```php
DB::table('users')->where('votes', '>', 100)->sharedLock()->get();
```

在實務上用得比悲觀鎖少很多，但在以下幾種情境中非常必要——特別是當你需要「讀取時保證資料不會被其他交易修改」時。

1. 讀取後要用資料做「依賴性判斷」
    
2. 想避免讀到「被其他交易修改到一半的資料」
    
3. 多交易同時讀取，但需阻止別人更新資料（報表 / 分析）
    
4. 當你希望「其他交易不能用 FOR UPDATE 搶先鎖住」時
    

---

| 使用情境 | 是否會更新資料？ | 是否允許他人讀？ | 建議使用 |
| --- | --- | --- | --- |
| 讀取 → 判斷 → 更新資料 | 會 | 不允許其他人鎖住 | `lockForUpdate()`（悲觀鎖） |
| 讀取 → 判斷 → 不更新 | 不會 | **允許他人讀**但**不允許他人改** | `sharedLock()` |
| 純讀，不需要強一致性 | 不會 | 允許 | 普通 SELECT |
| 希望別人完全不能讀與寫 | 會 | 不允許 | 改用 SERIALIZABLE 隔離級別 |

---

<div data-node-type="callout">
<div data-node-type="callout-emoji">💡</div>
<div data-node-type="callout-text">概念補充</div>
</div>

其實單純 `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`：是為了保護 **「讀取 -&gt; 判斷 -&gt; 寫入」** 這整個完整的業務邏輯過程。
>     

---

### 鎖的副作用 - Deadlock (死鎖)

1. 什麼是 Deadlock？
    

簡單來說，就是 **「兩個 Transaction 互相拿著對方想要的鑰匙，且誰也不肯放手。」**

* **Transaction A：** 手上有 **資源 1** 的鎖，正在等 **資源 2**。
    
* **Transaction B：** 手上有 **資源 2** 的鎖，正在等 **資源 1**。
    
* **結果：** 兩邊都在等對方釋放，程式就會永久卡死在這裡，直到天荒地老（或被資料庫強制踢掉）。
    

2. 經典案例：互相轉帳
    

最容易發生死鎖的情境就是「互相操作」兩筆資料。 假設：小明 (id:1) 要轉帳給小華 (id:2)，同時小華也要轉帳給小明。

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1765445002501/2d302ede-76d5-4864-871e-c42932e06d4a.png align="center")

這時候，資料庫偵測到這個「循環等待 (Circular Wait)」，為了保護系統，它會強制**殺掉 (Rollback)** 其中一個 Transaction（通常是工作量較小的那個），並回傳 Error 給應用程式。

3. 如何避免 Deadlock？
    

身為後端工程師，我們很難 100% 避免死鎖（因為它是機率問題），但我們可以透過良好的程式習慣來大幅降低機率：

**策略 A：固定順序** 不管你的業務邏輯是什麼，**只要涉及多筆資料修改，永遠依照相同的順序（例如 ID 大小）來加鎖。**

* **修改後的情境：**
    
    * 規定：永遠先鎖 ID 小的，再鎖 ID 大的。
        
    * A (1 -&gt; 2)：先鎖 1。
        
    * B (2 -&gt; 1)：因為規定要先鎖小 ID，所以 B 也會試著先鎖 1。
        
    * **結果：** B 在第一步就被擋住了（等待 A），不會拿到鎖 2。這樣 A 就可以順利鎖完 1 和 2，做完事情釋放。B 再接著做。死鎖解除！
        

**策略 B：縮短交易時間 (Keep it Short)** Transaction 範圍越小越好。不要在 Transaction 裡面做 HTTP 請求、寄信、讀檔等耗時操作。持有鎖的時間越短，發生衝突的機率就越低。

**策略 C：大鎖 (不推薦)** 直接鎖住整張表 (Table Lock)，雖然絕對不會死鎖（因為只有一把鑰匙），但效能會極差，通常不建議使用。
