Database·2026年6月17日·12 分鐘閱讀

資料庫交易隔離等級:髒讀、不可重複讀、幻讀的實戰

L
Louis Wu

Louis Wu,台灣資深後端工程師,主力 Go,做過交易所撮合、金流與高併發系統。

做交易所撮合跟金流這幾年,我修過最痛、最難重現的 bug,幾乎都跟交易隔離等級脫不了關係。這類 bug 有個共通特性:在你電腦上跑單測都對、在 staging 也對、QA 點一整天也對,結果一上線,併發量一上來,餘額對不上、庫存超賣、對帳差幾塊錢。然後你看 log 看到天亮,因為它根本不是程式邏輯錯,是你對資料庫在併發下的行為有錯誤的假設。

這篇我想把隔離等級這件事講清楚。不是背定義,而是用我實際踩過的金流扣款、庫存扣減場景,講每個異常會怎麼咬你,以及為什麼 PostgreSQL 跟 MySQL 預設不同會害你帶著錯誤的肌肉記憶換系統就出事。

先講清楚:隔離等級到底在隔離什麼

交易有 ACID 四個性質,其中 I 就是 Isolation(隔離性)。隔離性講的是:當多個交易同時跑的時候,彼此之間應該要互相看不到對方做到一半的狀態,理想上每個交易都應該像是「整個資料庫只有我一個人在用」。

但完全的隔離(也就是讓所有交易變成像排隊一個一個跑)效能太差,沒人受得了。所以 SQL 標準定義了四個等級,本質上是在「隔離程度」跟「併發效能」之間給你選不同的取捨點。等級越高,看到的異常越少,但你付出的鎖跟衝突成本越高。

SQL 標準用三個經典異常來定義這四個等級:髒讀(dirty read)、不可重複讀(non-repeatable read)、幻讀(phantom read)。四個等級就是「允不允許這三個異常發生」的排列組合。

我先把這張表放這,後面每一項都會用具體場景拆開講:

  • Read Uncommitted:允許髒讀、不可重複讀、幻讀
  • Read Committed:擋掉髒讀,但允許不可重複讀、幻讀
  • Repeatable Read:擋掉髒讀、不可重複讀,但(標準上)允許幻讀
  • Serializable:三個都擋掉

注意我寫「標準上」。這四個字很關鍵,因為實際的資料庫實作跟 SQL 標準有落差,後面講 MySQL 跟 PostgreSQL 的時候你會看到,這個落差正是坑的來源。

三個經典異常,用金流和庫存講

定義抽象,場景具體。我用兩個我最熟的場景:一個是錢包扣款,一個是商品庫存扣減。

髒讀:你看到了一筆根本沒成立的扣款

髒讀的定義是:交易 A 讀到了交易 B 還沒提交(commit)的資料。如果 B 後來 rollback 了,那 A 讀到的就是一筆從未存在過的幻影資料。

具體場景。使用者錢包餘額 1000 元,同時發生兩件事:

  • 交易 B:使用者申請提領 1000 元,B 先把餘額更新成 0,但還沒提交,正在等風控系統回應
  • 交易 A:另一個請求要查餘額決定能不能下單,在 B 還沒提交時讀到餘額 0

接著風控系統判定這筆提領有問題,B 被 rollback,餘額其實還是 1000。但交易 A 已經拿著「餘額 0」這個假資料去做決策了,它可能拒絕了一筆本來該成功的下單,甚至更糟,如果 A 是另一個扣款流程,它讀到 0 然後做了錯誤的補償邏輯。

髒讀的恐怖在於:你讀到的是一個從來沒有真正存在於資料庫的狀態。任何基於這個狀態做的決策都站在流沙上。所以絕大多數正經資料庫的預設等級都至少從 Read Committed 起跳,根本不讓你碰到髒讀。實務上 Read Uncommitted 我從來沒在生產環境用過,也建議你別用。

不可重複讀:同一筆查詢,前後讀到不同的餘額

不可重複讀的定義是:在同一個交易裡,你讀同一筆資料(同一個 row)兩次,結果兩次不一樣,因為中間有另一個交易修改了它並提交了。

這個跟髒讀的差別在於:這次讀到的是已提交的真實資料,不是幻影。但問題是「在我這個交易進行中,我以為固定不變的東西變了」。

金流場景。我有一段對帳邏輯,在一個交易裡要做兩件事:

  • 第一步:讀使用者餘額,記下來是 1000
  • 第二步:做一些計算(可能跨好幾個 query),然後再讀一次餘額準備寫入差額

如果在這兩步中間,另一個交易扣了 200 並提交,第二步讀到的就是 800。我這個交易裡同一個欄位讀到兩個值,後面的計算如果混用了這兩個值,帳就錯了。

庫存場景更直接。一個下單流程:

  • 先讀庫存,看到 10 個,判斷「夠,可以賣」
  • 中間別的訂單把庫存扣到 0 並提交
  • 我這邊基於「庫存 10」這個過期判斷繼續往下扣,超賣就發生了

這就帶到一個重點:判斷跟扣減之間的時間差,是所有超賣 bug 的溫床。後面講 select for update 跟樂觀鎖就是在解這個問題。

幻讀:你以為的範圍,憑空多了幾筆

幻讀比較細微,它針對的不是某一筆已存在的 row 變了值,而是針對「一個查詢條件涵蓋的那組 row」變多或變少了。

定義是:同一個交易裡,你用同一個條件查兩次(例如「查所有狀態為 pending 的提領單」),第二次查出來多了幾筆,是別的交易新 insert 並提交的。這些新出現的 row 就像幽靈一樣憑空冒出來,所以叫幻讀。

不可重複讀是「同一筆變了」,幻讀是「筆數變了」。這個區別在實作上很重要,因為擋「某一筆變」只要鎖住那筆 row 就好,但擋「範圍內憑空多筆」你得鎖住整個範圍,連還不存在的 row 都要鎖,這就是後面會講的間隙鎖(gap lock)在做的事。

金流場景。風控做日結,一個交易裡:

  • 統計某使用者今天所有提領單總額,第一次查是 5 萬
  • 同時使用者又送了一筆 3 萬的提領,insert 並提交
  • 同個交易裡再查一次「今天所有提領」,變成 8 萬

如果風控的限額判斷邏輯依賴這個總額在交易內保持一致,幻讀就會讓它做出矛盾的決策。

PostgreSQL 預設 RC,MySQL InnoDB 預設 RR,這個差異會咬人

這是我最想強調的一段,因為它害過我,也害過我帶的人。

PostgreSQL 的預設隔離等級是 Read Committed。MySQL InnoDB 的預設是 Repeatable Read。兩個業界最常用的開源資料庫,預設等級就差了一級。

這意味著什麼?意味著同一段「先查再改」的程式碼,你在 MySQL 上跑跟在 PostgreSQL 上跑,併發行為是不一樣的。我看過團隊原本用 MySQL,邏輯靠著 RR 的特性沒出事,後來有個服務搬到 PostgreSQL,同樣的程式碼在 RC 下不可重複讀就冒出來了,餘額對帳開始飄。反過來,原本在 PG 寫慣 RC 的人,到 MySQL 用 RR,又會被 RR 下「快照讀看不到別人剛提交的資料」這個特性搞糊塗。

更深一層的坑是:MySQL 的 RR 跟 PostgreSQL 的 RR 不是同一個東西

  • MySQL InnoDB 的 RR,透過 MVCC 加上間隙鎖(gap lock),在很多情況下連幻讀都擋掉了,比 SQL 標準要求的更強。
  • PostgreSQL 的 RR(它叫 Repeatable Read,實作上是 snapshot isolation)也擋掉了大部分幻讀,但行為細節跟 MySQL 不同,尤其在寫衝突時 PG 會直接報 serialization failure 要你重試,MySQL 則傾向用鎖去等。

所以「我用 RR」這句話在兩個資料庫上的實際語意是不一樣的。換系統的時候,不能假設隔離等級名字一樣行為就一樣。這是我吃過虧才學到的。

實務建議:明確知道你在哪個資料庫、哪個隔離等級下寫程式,不要靠預設值帶來的「剛好沒事」。尤其團隊有多個服務、多種資料庫的時候,把隔離等級當成一個要被明確設定跟 review 的東西,而不是背景噪音。

RR 下的快照讀與當前讀:MySQL 的關鍵細節

既然 MySQL 預設 RR,這段我講 InnoDB 在 RR 下兩種讀的差別,因為這直接決定你的扣款扣庫存對不對。

InnoDB 在 RR 下有兩種讀:

快照讀(snapshot read / consistent read):就是普通的 select。在 RR 下,交易第一次做快照讀的那一刻會建立一個一致性快照,之後同一個交易裡的普通 select 都看這個快照,所以同一筆查詢讀幾次都一樣(這就是 RR 擋住不可重複讀的機制)。重點是:快照讀看不到別的交易在你快照建立之後提交的修改。

這裡有個非常容易踩的坑。你以為 RR 保證你讀到最新資料,其實不是。普通 select 讀的是快照,可能是過期的。如果你拿這個過期的餘額去判斷「夠不夠扣」,然後用 update 去扣,問題就來了。

當前讀(current read / locking read):select for update、select for share,以及 update、delete 這些。當前讀讀的是最新的、已提交的版本,而且會加鎖。

關鍵差異用一個扣庫存的例子講清楚:

  • 你先用普通 select 讀庫存,快照顯示 10
  • 別的交易把庫存扣到 0 並提交
  • 你用 update stock set qty = qty - 1 where id = ? 去扣

注意這裡的 update 是當前讀,它看到的是最新的 0,不是你快照裡的 10。所以 update 本身扣的是正確的最新值。但問題在於:如果你的業務邏輯是「先用普通 select 判斷夠不夠,再 update」,那個判斷是基於過期快照 10 做的,你可能放行了一筆其實不該放行的扣減。

這就是為什麼判斷跟扣減要嘛合併成一個原子操作,要嘛用當前讀來讀判斷依據。下面講的 select for update 跟「把判斷寫進 update 的 where」就是兩種解法。

為什麼不要無腦開 Serializable

每次講到這裡,總有人問:那我全部開 Serializable 不就沒事了?三個異常都擋掉,最安全嘛。

理論上對,實務上這是個會讓你半夜被叫起來的決定。

Serializable 保證所有交易的執行結果,等價於它們以某種順序一個一個跑完的結果。要做到這件事,資料庫要嘛大量加鎖(像 MySQL 那樣傾向用鎖),要嘛用樂觀的衝突偵測(像 PostgreSQL 的 SSI,serializable snapshot isolation),在提交時發現衝突就讓其中一個交易失敗。兩種做法在高併發下都有代價:

  • 鎖的做法:鎖的範圍變大、持有時間變長,併發度直接掉下來。熱點資料(例如平台的手續費歸集帳戶、熱門商品的庫存)會變成所有交易排隊的瓶頸。死鎖機率也大幅上升,因為鎖多了,互相等的機會就多。
  • 衝突偵測的做法:PG 的 SSI 不太鎖你,但會在提交時把有衝突的交易直接 abort,丟給你一個 serialization failure。這代表你的應用層必須有完整的重試邏輯,而且高衝突場景下重試率高,等於白做了很多工。

我做撮合跟金流的經驗是:Serializable 不是不能用,而是要用在刀口上。某些對一致性要求極高、併發量又沒那麼可怕的關鍵交易(例如某些清算、對帳的關鍵步驟),開 Serializable 並配好重試是合理的。但把整個系統的預設拉到 Serializable,期待它幫你擋掉所有併發 bug,結果通常是吞吐量崩掉、死鎖滿天飛、或重試風暴。

正確的心態是:隔離等級解決的是「讀的一致性」問題,但併發寫的正確性,更多要靠你主動設計的鎖策略跟原子操作來保證,而不是把隔離等級拉到頂就一勞永逸。

樂觀鎖 vs 悲觀鎖:兩種對待衝突的態度

講到主動的併發控制,繞不開樂觀鎖跟悲觀鎖。這不是資料庫等級的設定,而是你在應用層選的策略。差別在於對「衝突會不會發生」的假設。

悲觀鎖:假設衝突很常發生,所以先鎖住再說。在資料庫裡最典型就是 select for update,讀的時候就把那筆 row 鎖住,其他想動它的交易得排隊等。

  • 優點:邏輯直觀,鎖住期間資料不會被別人動,扣款扣庫存很安全。
  • 缺點:鎖持有期間別人都在等,併發度低。鎖的順序沒控制好容易死鎖。如果你在持鎖期間還去呼叫外部 API(例如風控、第三方支付),鎖被你抓著等網路,那是災難。

樂觀鎖:假設衝突很少,所以不先鎖,等到要寫的時候才檢查「我讀到之後,這筆有沒有被別人改過」。最常見的實作是版本號(version)或時間戳。

樂觀鎖扣款的典型寫法是:

  • 讀出餘額 1000,同時讀出 version = 5
  • 算出扣款後餘額 800
  • 更新時帶條件:update wallet set balance = 800, version = 6 where id = ? and version = 5
  • 檢查更新影響的筆數。如果是 1,成功;如果是 0,代表 version 已經被別人改掉了,這次更新落空,你要重讀重算重試

樂觀鎖的精髓在那個 where version = 5。它把「我讀的時候是這個狀態」這個假設寫進了更新條件,資料庫保證 update 是原子的,所以如果有人在你之間插了一手,你的 update 就會匹配不到任何 row,影響筆數為 0,你就知道衝突了。

  • 優點:不持有長鎖,併發度高,特別適合衝突其實不頻繁的場景。沒有死鎖問題。
  • 缺點:衝突一旦發生就要重試,高衝突場景下重試成本高,甚至會餓死(一直搶輸)。需要應用層配合做重試邏輯。

我的選擇原則很簡單:衝突頻率高就悲觀,衝突頻率低就樂觀。平台的全域熱點帳戶(大家都往裡面打錢的歸集帳戶)衝突極高,用樂觀鎖會重試到爆,這種我傾向悲觀鎖甚至排隊序列化。一般使用者各自的錢包,彼此井水不犯河水,衝突很低,樂觀鎖配版本號又快又好。

select for update:怎麼用,以及怎麼害到自己

select for update 是悲觀鎖在 SQL 裡的主要工具,我用得很多,但它也是最容易把系統搞死的工具之一。講清楚怎麼用對。

基本用法是在交易裡讀的時候加 for update,把讀到的 row 鎖住直到交易結束:

  • 開交易
  • select balance from wallet where id = ? for update(這裡讀的是當前讀,最新值,並鎖住這筆)
  • 在應用層判斷餘額夠不夠
  • update 扣款
  • 提交,鎖釋放

這個寫法為什麼安全?因為 for update 用的是當前讀,讀到的是最新已提交的餘額,不是快照;而且鎖住之後,其他交易要動同一筆得等你提交。所以「讀出來判斷」跟「扣款」之間,那筆 row 不會被別人偷改,前面講的不可重複讀和判斷扣減時間差問題就被堵住了。

但 select for update 有幾個我親眼看過出事的坑:

第一,持鎖期間絕對不要做慢的事。我看過最慘的案例是在 for update 鎖住之後,去同步呼叫第三方支付 API,那個 API 偶爾要等好幾秒。結果這筆熱點 row 被鎖好幾秒,後面排隊的交易全部卡住,連鎖反應到連線池被佔滿,整個服務雪崩。原則是:鎖的範圍要小、持有時間要短,持鎖期間只做資料庫的事,不做網路 IO

第二,加鎖順序不一致會死鎖。如果交易 A 先鎖帳戶 1 再鎖帳戶 2,交易 B 先鎖帳戶 2 再鎖帳戶 1,兩邊各鎖到一半互相等,就死鎖了。轉帳這種要同時動兩個帳戶的場景特別容易中。解法是約定一個固定的加鎖順序,例如永遠照帳戶 id 由小到大鎖,這樣就不會有環狀等待。

第三,鎖到比你想的更多。在 MySQL InnoDB 的 RR 下,如果你的 for update where 條件沒走索引,可能會鎖住整張表的 row 甚至更多;走了索引但匹配範圍大,間隙鎖會把你查詢範圍的間隙也鎖起來,擋住別人在這個範圍 insert。這常常是「我只想鎖一筆,怎麼別人連 insert 都被擋」的元兇。確保 for update 的條件走在合適的索引上、盡量用唯一鍵精準命中,是控制鎖範圍的關鍵。

第四,搭配 nowait 或 skip locked 處理搶不到鎖的情況。預設 for update 搶不到鎖會一直等到逾時,有時你不想等,PostgreSQL 跟新版 MySQL 都支援 for update nowait(搶不到馬上報錯)跟 for update skip locked(跳過已被鎖的 row)。skip locked 在做任務佇列「搶任務」的場景特別好用,多個 worker 各自搶不同的 row,不會卡在同一筆上。

把這些湊起來:一個扣庫存的完整取捨

最後用一個我實際會這樣決策的庫存扣減場景,把前面的東西串起來。

需求:商品庫存扣減,不能超賣。

最差的寫法是「先 select 查庫存夠不夠,應用層判斷後再 update 扣」。前面講過了,這中間有時間差,不可重複讀或基於過期快照判斷都會讓你超賣。這個寫法在低併發測不出問題,一上量就爆,是最經典的坑。

比較好的幾種做法:

做法一,把判斷寫進 update 的 where(樂觀路線)

  • update stock set qty = qty - 1 where id = ? and qty >= 1
  • 檢查影響筆數。1 代表扣成功,0 代表庫存不足(或被搶光),這時回報賣完

這個寫法的精髓是把「夠不夠」跟「扣」合併成一個原子的 update,資料庫保證原子性,根本沒有中間的時間差。我很喜歡這種寫法,簡單、無鎖等待、又正確。庫存這種「只要保證不扣到負數」的場景特別適合。

做法二,select for update(悲觀路線)

  • 開交易
  • select qty from stock where id = ? for update
  • 應用層判斷 qty 夠不夠,做一些別的庫存以外的邏輯
  • update 扣減
  • 提交

當你的扣減判斷比「大於等於 1」複雜很多、需要在持鎖期間讀寫多張相關的表時,悲觀鎖比較好寫。代價是併發度跟前面講的那些風險。

做法三,樂觀鎖版本號:適合扣減涉及一整個聚合狀態、用單一 where 條件不好表達的情況,靠 version 偵測衝突加重試。

我的實際選擇:純粹的庫存數量扣減,我幾乎都用做法一,把判斷塞進 update 的 where,最簡單也最不容易出錯。需要在扣庫存的同時做複雜聯動(鎖定多張表、複雜業務判斷)才上 select for update,而且嚴格控制持鎖時間跟加鎖順序。

收尾

隔離等級這東西,背定義五分鐘就會,但真正理解它在你的併發場景下怎麼咬人,是要靠線上事故喂出來的。我把幾個重點再壓縮一次:

  • 髒讀讀到沒提交的幻影、不可重複讀是同一筆前後不同、幻讀是筆數憑空變動。
  • PostgreSQL 預設 RC、MySQL InnoDB 預設 RR,而且兩家的 RR 行為還不一樣,換系統別想當然。
  • RR 下普通 select 是快照讀可能過期,select for update 跟 update 是當前讀看最新值,判斷要嘛用當前讀要嘛塞進 update 的 where。
  • 別無腦開 Serializable,它的鎖跟重試成本會在高併發下反咬你。
  • 衝突頻率高用悲觀鎖,低用樂觀鎖;select for update 持鎖要短、順序要一致、條件要走索引。

說到底,併發正確性不是靠把隔離等級拉到最高就能買到的保險,而是你對「讀到什麼、什麼時候會變、寫的時候怎麼保證原子」這幾件事想清楚之後,主動設計出來的結果。

#資料庫#交易隔離#併發控制#PostgreSQL#MySQL#樂觀鎖

相關文章