Lesson 5: 避免資料打架-Transaction 交易處理 (ACID) 與 Race Condition
身為後端的我們,很常聽到:這裡要用交易、這不符合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 張。
使用者 A 讀取資料庫,看到剩餘票數 = 1。
使用者 B 讀取資料庫,也看到剩餘票數 = 1(因為 A 還沒買完)。
使用者 A 執行購買:
UPDATE tickets SET count = 0。使用者 B 執行購買:
UPDATE tickets SET count = 0。
結果: 兩個人都覺得自己買到了,但實際上票只有一張。這就是「資料打架」。
通常解決資料打架的方法就是採取Lock的方式,Lock相關概念會在L6提到。
為了解決上述提到的「Race Condition」以及平衡「資料安全」與「效能」,資料庫定義了四種隔離級別。但在了解級別之前,我們先看看如果隔離做得不好,會發生哪些靈異現象(讀取異常)。
讀取異常
髒讀 (Dirty Read)
定義: 讀取到別人「還沒 Commit」的髒資料。
情境:
A 修改了某筆資料(將餘額 100 改為 200),但還沒 Commit。
B 讀取該筆資料,讀到了 200。
結果 A 因為發生錯誤 Rollback 了(餘額變回 100)。
後果: B 拿著這筆不存在的「200」去做了後續操作,導致系統帳務錯誤。
不可重複讀 (Non-Repeatable Read)
定義: 在同一個 Transaction 內,兩次讀取同一筆資料,結果不一樣。
重點: 針對的是資料的 更新 (UPDATE)。
情境:
A 讀取庫存,看到 10 個。
B 執行購買並 Commit,將庫存改為 9。
A 再次讀取庫存,發現變成 9 了。
後果: A 會覺得很困惑:「我明明還在同一個交易裡,怎麼資料憑空改變了?」這會影響到後續邏輯的一致性。
幻讀 (Phantom Read)
定義: 在同一個 Transaction 內,兩次查詢「符合條件的筆數」,結果不一樣(像是產生幻覺)。
重點: 針對的是資料的 新增 (INSERT) 或 刪除 (DELETE)。
情境:
A 查詢「所有未付款訂單」,共有 5 筆。
B 此時 新增 了一筆未付款訂單並 Commit。
A 再次查詢,發現變成了 6 筆。
後果: 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 是不夠的!我們需要手動加鎖。

