Skip to main content

Command Palette

Search for a command to run...

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

Updated
3 min read
B

一個很忙的工程師,比起寫code更喜歡股票,QQQ買起來,謝謝!

身為後端的我們,很常聽到:這裡要用交易、這不符合ACID原則…等。這篇文章主要來探討:Transition的用法與ACID的概念。

ACID

ACID 原則是Transition時應具備的四個特性縮寫,分別是原子性(Atomicity)、一致性(Consistency)、隔離性(Isolation)和持久性(Durability)

原子性

「要就全部成功,否則全部失敗」。 如果交易中有 10 個步驟,執行到第 9 步失敗了,資料庫必須 Rollback(回滾) 到第 1 步還沒執行前的狀態,不能只剩下一半的資料。

筆記: 這裡的「原子性」跟資料庫正規化(1NF)的原子性不一樣:

  • 1NF 的原子性: 指的是「欄位不可再分割」(例如地址欄位不能塞 JSON)。

  • Transaction 的原子性: 指的是「操作步驟不可分割」(All or Nothing,類似股票的 FOK - Fill or Kill 指令)。

一致性

交易執行前後,資料庫都必須維持在「合法」的狀態。 所有的資料變動都必須符合資料庫預設的約束(Constraints),例如:

  • 欄位的資料型態(Type)

  • 主鍵約束(Primary Key)

  • 外鍵約束(Foreign Key)

  • 檢查約束(CHECK Constraints,例如餘額不能小於 0)

如果交易導致資料違反了這些約束,資料庫就會報錯並回滾,確保資料永遠符合規則。

隔離性

多併發的交易之間互相不會影響。 理想狀態下,交易 A 在 COMMIT 之前,交易 B 應該看不到交易 A 所做的修改。

  • 舉例: 同一筆資料 X,請求 A 修改了它但還沒 Commit,請求 B 此時讀取 X,應該要讀到舊的資料,而不是 A 改到一半的髒資料(Dirty Read)。

注意: 隔離性是有分級別的,級別越高越安全,但效能越差。大多數資料庫預設並非最高級別,這也是導致 Race Condition 的主因。

持久性

transition commit之後的更新是永久變更的。即使資料庫立刻崩潰、斷電或重啟,這筆寫入的資料也不會消失(通常是透過 Write-Ahead Logging, WAL 機制寫入硬碟來保證)

Race Condition

雖然 ACID 的「隔離性」聽起來很美好,但在高併發的現實世界中,如果不做額外處理,還是會發生 Race Condition

經典案例:搶票系統 假設某演唱會門票只剩 1 張

  1. 使用者 A 讀取資料庫,看到剩餘票數 = 1。

  2. 使用者 B 讀取資料庫,也看到剩餘票數 = 1(因為 A 還沒買完)。

  3. 使用者 A 執行購買:UPDATE tickets SET count = 0

  4. 使用者 B 執行購買:UPDATE tickets SET count = 0

結果: 兩個人都覺得自己買到了,但實際上票只有一張。這就是「資料打架」。

通常解決資料打架的方法就是採取Lock的方式,Lock相關概念會在L6提到。

為了解決上述提到的「Race Condition」以及平衡「資料安全」與「效能」,資料庫定義了四種隔離級別。但在了解級別之前,我們先看看如果隔離做得不好,會發生哪些靈異現象(讀取異常)。

讀取異常

髒讀 (Dirty Read)

定義: 讀取到別人「還沒 Commit」的髒資料。

  • 情境:

    1. A 修改了某筆資料(將餘額 100 改為 200),但還沒 Commit。

    2. B 讀取該筆資料,讀到了 200

    3. 結果 A 因為發生錯誤 Rollback 了(餘額變回 100)。

    4. 後果: B 拿著這筆不存在的「200」去做了後續操作,導致系統帳務錯誤。

不可重複讀 (Non-Repeatable Read)

定義: 在同一個 Transaction 內,兩次讀取同一筆資料,結果不一樣。

  • 重點: 針對的是資料的 更新 (UPDATE)

  • 情境:

    1. A 讀取庫存,看到 10 個。

    2. B 執行購買並 Commit,將庫存改為 9

    3. A 再次讀取庫存,發現變成 9 了。

    4. 後果: A 會覺得很困惑:「我明明還在同一個交易裡,怎麼資料憑空改變了?」這會影響到後續邏輯的一致性。

幻讀 (Phantom Read)

定義: 在同一個 Transaction 內,兩次查詢「符合條件的筆數」,結果不一樣(像是產生幻覺)。

  • 重點: 針對的是資料的 新增 (INSERT) 或 刪除 (DELETE)

  • 情境:

    1. A 查詢「所有未付款訂單」,共有 5 筆。

    2. B 此時 新增 了一筆未付款訂單並 Commit。

    3. A 再次查詢,發現變成了 6 筆。

    4. 後果: A 明明鎖定了範圍,卻突然多出(或少了)一筆資料,這在統計報表或批次處理時會造成嚴重錯誤。

四種隔離等級

為了防止上述問題,SQL 標準定義了四種級別。級別越高,資料越安全,但效能越差(因為鎖越多)。

Level 1: Read Uncommitted (讀取未提交)

  • 特性: 最寬鬆的級別,幾乎沒有隔離。別人還沒 Commit 的資料你也讀得到。

  • 解決問題: 無。

  • 可能發生: 髒讀、不可重複讀、幻讀。

  • 應用場景: 極少使用,除非完全不在乎數據正確性只求最快速度(例如某些 Log 分析)。

  • 傳統底層行為:幾乎不加鎖。讀取時不加鎖,甚至無視別人寫入加的鎖。所以最快,但也最髒。

Level 2: Read Committed (讀取已提交)

  • 特性: 只能讀到別人「已經 Commit」的資料。這是大多數資料庫(如 PostgreSQL, Oracle, SQL Server)的預設值

  • 解決問題: 解決了 髒讀

  • 可能發生: 不可重複讀、幻讀。

  • 應用場景: 絕大多數的一般網頁應用。

  • 傳統底層行為:短暫的鎖。讀取時會加鎖,但讀完立刻釋放,不用等到 Transaction 結束。所以別人可以在你交易途中修改資料(導致不可重複讀)。

Level 3: Repeatable Read (可重複讀)

  • 特性: 保證在同一個 Transaction 中,不管讀幾次,資料都跟第一次讀到的一樣。這是 MySQL (InnoDB) 的預設值。

  • 解決問題: 解決了 髒讀不可重複讀

  • 可能發生: 依 SQL 標準仍可能發生 幻讀

    • (補充:雖然標準說會發生幻讀,但 MySQL 的 InnoDB 引擎透過 Next-Key Lock 技術,在這個級別下其實已經能解決大部分的幻讀問題。)
  • 傳統底層行為:長效的鎖。讀取時加鎖,且一直握住直到 Transaction 結束 (Commit/Rollback) 才釋放。這保證了這段期間沒人能改這筆資料。

Level 4: Serializable (序列化)

  • 特性: 最高級別。強制 Transaction 必須「排隊」執行(序列化),完全不允許併發。

  • 解決問題: 解決所有問題(髒讀、不可重複讀、幻讀)。

  • 可能發生: 效能極低,容易發生 Timeout 或 Deadlock。

  • 應用場景: 涉及金錢且完全不能容忍一絲誤差的極端場景。

  • 傳統底層行為:範圍鎖 (Range Lock)。不只鎖住你讀的那一行,連「這行資料附近的範圍」都鎖住,防止別人在此範圍內 新增 (Insert) 資料。

現代資料庫的變革:MVCC (多版本控制)

這裡要特別補充一個關鍵知識點,這也是為什麼很多人覺得「我都開了 Repeatable Read,為什麼不用等鎖?」的原因。

現代資料庫(MySQL InnoDB, PostgreSQL, Oracle)為了效能,在「讀取」時,通常不直接用 Lock,而是改用 MVCC (Multi-Version Concurrency Control)。

  • 這是什麼意思? 當你在 Level 3 (Repeatable Read) 讀取資料時,資料庫不是去「鎖住」那行資料,而是給了你一張「當下的快照 (Snapshot)」。

    • Lock 的作法: 把門鎖起來,別人進不來。

    • MVCC 的作法: 給你一張照片看,別人去改原本的房間沒關係,反正你看的是照片。

所以,現代資料庫的關聯變成了:

  • 隔離級別 決定了 快照 (Snapshot) 的生成時機。例如:

    • RC 級別: 每執行一個 SQL 語句,就重新拍一張快照(所以會看到別人剛改的)。

    • RR 級別: 第一次 SELECT 時拍一張快照,後面都看這一張(所以資料永遠一樣)。

  • Lock (鎖): 主要用於 「寫入 (Write)」「顯式鎖定 (Explicit Locking, 如 FOR UPDATE)」

MVCC 雖然解決了讀取的效能與一致性問題,但因為我們看的是「照片」,所以在需要「搶票、扣款」這種基於最新數據進行修改的場景中,光靠 MVCC 是不夠的!我們需要手動加鎖。

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

Part 25 of 31

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

Up next

Lesson 4: 效能殺手與救星-Index 索引的原理、複合索引與 Explain 分析

我們很常聽到索引(Index),大部分工程師也都知道 RDBMS最基本的加速方式就是加上Index,但Index是個雙面刃,加的不好反而會嚴重拖累DB的速度,我們這個章節就來探討,Index的概念以及實務上的用法吧! 為什麼我們需要索引? 想像一本厚達 1,000 頁的字典。 沒有索引 (Full Table Scan): 如果你想查「Apple」這個字,卻沒有目錄也沒有頁碼順序,你必須從第一頁

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

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