Skip to main content

Command Palette

Search for a command to run...

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

Updated
4 min read

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

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

  • 表鎖

最常用的悲觀鎖語法:

SELECT ... FOR UPDATE;

這代表:

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

Laravel 實作方式:

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

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

優缺點分析

  • 優點: 強一致性。保證絕對不會發生 Race Condition,不會超賣。

  • 缺點:

    • 併發度低: 所有想買票的人都要排隊(序列化執行)。

    • 效能風險: 如果中間的業務邏輯執行很久(例如呼叫第三方金流 API 等了 5 秒),這張表的這行資料就會被鎖死 5 秒,後面的人全部 Timeout。

    • 死鎖風險: 容易因互相鎖定資源而導致 Deadlock。

😃 樂觀鎖 — Optimistic Lock

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

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

常見實作方式:新增 versionupdated_at 欄位。

流程:

  1. 讀取資料時一併讀取 version

  2. 更新時加入條件

     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 條件一定要有索引,否則會讓系統瞬間癱瘓!\


💡
補充-Laravel 中的共享鎖使用

Laravel中其實有一個寫法

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

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

  1. 讀取後要用資料做「依賴性判斷」

  2. 想避免讀到「被其他交易修改到一半的資料」

  3. 多交易同時讀取,但需阻止別人更新資料(報表 / 分析)

  4. 當你希望「其他交易不能用 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 (死鎖)

  1. 什麼是 Deadlock?

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

  • Transaction A: 手上有 資源 1 的鎖,正在等 資源 2

  • Transaction B: 手上有 資源 2 的鎖,正在等 資源 1

  • 結果: 兩邊都在等對方釋放,程式就會永久卡死在這裡,直到天荒地老(或被資料庫強制踢掉)。

  1. 經典案例:互相轉帳

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

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

  1. 如何避免 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),雖然絕對不會死鎖(因為只有一把鑰匙),但效能會極差,通常不建議使用。

從Rookie到Junior,一個後端成長的30堂課

Part 24 of 31

“從 Rookie 到 Junior:一個後端成長的 30 堂課” 是一套專為後端新手所設計的成長型技術系列文章。內容以實務為導向,逐步拆解後端工程的核心能力,包括程式語言基礎、架構思維、框架運作原理、業務邏輯設計、資料庫操作、以及常見的開發模式。 本系列的目標是協助讀者從零散的學習堆疊,建立成體系的後端知識框架。讀者能夠理解各語言背後不變的工程思維與設計原則。這套內容旨在讓學習者從「會寫程式」進入「能理解系統設計」的階段,逐步具備勝任 Junior Backend Engineer 的能力。

Up next

Lesson 5: 避免資料打架-Transaction 交易處理 (ACID) 與 Race Condition

身為後端的我們,很常聽到:這裡要用交易、這不符合ACID原則…等。這篇文章主要來探討:Transition的用法與ACID的概念。 ACID ACID 原則是Transition時應具備的四個特性縮寫,分別是原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)和持久性(Durability) 原子性 「要就全部成功,否則全部失敗」。 如果交易中有 10 個步驟,執行到第 9 步失敗了,資料庫必須 Rollback(回滾) 到第 1 步還沒執行前的狀態,不能...

More from this blog

Lesson 26 : 系統韌性的守護者-限流、熔斷與背壓的設計模式

當這幾個名詞出現後,代表我們進到了一個高併發/大流量的系統了。在這個章節中,我們一起來看看如何透過一些方式來避免高併發導致我們的系統crash掉。 限流(Rate Limiting) 相信大家對這個名詞並不陌生,限流其實就是字面上的含意,限制流量。 限流的目的是保護「接收方」,確保系統不會因為瞬間的高併發請求而癱瘓。 限流通常發生在 API Gateway 或服務的最前端。它像是一個夜店門口的保全

Mar 26, 20262 min read

Lesson 25: 淺談 單體架構、微服務架構與單/多租戶架構

過去我們討論了 要把程式寫在哪、程式要怎麼拆的題目,接下來我們來看看「如何服務不同客戶」,這些架構反映了軟體開發在擴充性與複雜度之間的權衡。 軟體架構深度解析:從系統拆分到商業規模化 在軟體工程的演進中,架構的選擇往往是在「開發效率」、「系統擴充性」與「營運成本」之間尋求平衡。我們可以從兩個核心維度來觀察這些架構:系統如何運行(單體 vs. 分散式) 以及 如何服務客戶(單租戶 vs. 多租戶)。

Mar 26, 20262 min read

Lesson 24: 資料庫擴展術-讀寫分離、複寫機制與快取一致性挑戰

為什麼要讀寫分離? 大多數的 Web 應用都是 「讀多寫少」(例如:看文的人多,發文的人少,Heavy Read System)。當所有的請求都塞給同一台資料庫時,磁碟 I/O 和連線數會成為瓶頸。 Master (主庫): 負責寫入 (Insert/Update/Delete),確保數據一致性。 Slave (從庫): 負責讀取 (Select),可以有多個從庫來分擔讀取壓力。 為什麼讀

Mar 25, 20262 min read

面試經驗談 2025-2026

從2025年3月開始,我陸陸續續參與了從新創到上市櫃公司的Senior - Tech Lead的相關面試,其中有不乏 尊重面試者、展現高度專業的企業(公司),當然也有遇到幾場面試鬼故事,這篇文章主要分享我對於軟體工程師面試的方向分享,以及部分鬼故事,以此警惕自己不要成為這樣的面試官。 AI的洪流,改變了SWE的生態 LLM的發展確確實實的影響到了軟體工程師的生態。 過去受限於算力與資料規模,深度學

Mar 23, 20262 min read

Lesson 23: 系統的緩衝區-Queue 佇列與非同步處理 (Asynchronous)

佇列 佇列的實作工具非常多,舉凡AWS SQS、RabbitMQ、Kafka…等。 佇列的特性,其實是一個非常強大的系統緩衝區,應用層面非常廣。 什麼是佇列? 佇列可以想像成,在既有流程中外,有另一個”水管”,來連接原有的資料流(或邏輯過程),其中 呼叫方將資料 推(Push)到水管中,接受方(監聽) 從水管中將資料拉(Pull)出處理 為什麼佇列是「強大的緩衝區」? 在同步處理中,系統像是一

Mar 23, 20262 min read

Bennett's Tech Blog | 後端架構、系統設計

32 posts

來自台灣的軟體工程師,相信軟體可以改變世界