交易與隔離層級:併發不打架的分級
· tech
📑 目錄
前面講的都是「一個查詢怎麼跑得快」(索引、EXPLAIN),這篇換個維度:很多交易同時跑,怎麼不互相打架? 這就是交易與隔離層級。(這裡講單機 PostgreSQL 的實務;分散式交易與一致性,交給姊妹作 DDIA 系列往下深入,兩邊不重複。)
ACID:重點其實是 I(隔離)
交易的 ACID 四個字母:Atomicity(全成或全不成)、Consistency(不破壞約束)、Isolation(併發互不干擾)、Durability(commit 了就不會丟)。其中三個相對直覺,真正有趣、也最常出包的是 Isolation(隔離)——因為「完美隔離」很貴,於是它被切成好幾級讓你選。
併發會冒出的三種怪事
兩個交易同時跑,如果隔離不夠,會出現三種經典的讀取異常。用一個具體場景就很有感——你(T1)在查一個帳戶餘額,而另一個人(T2)同時在操作它:
三個放進場景就一目了然:
- 髒讀:T2 把餘額改成 200、但還沒 commit,你就讀到了 200——結果 T2 交易失敗
ROLLBACK,那個 200 從來沒真正存在過。你卻已經拿一個「幽靈數字」做了決定(例如判斷「餘額夠、放行提款」)。 - 不可重複讀:你在同一筆交易裡查了兩次餘額,第一次 100、第二次 200(中間 T2 提交了一筆轉帳)。你的邏輯本來假設「同一交易內、同一列的值不會變」,這下對帳、加總全亂套。
- 幻讀:你統計「今天所有訂單」先算到 3 筆,T2 插了一筆新訂單並提交,你再查變 4 筆。跟不可重複讀的差別很關鍵:不可重複讀是「既有的列被改了值」,幻讀是「憑空冒出/消失了整列」——一個管
UPDATE、一個管INSERT/DELETE,所以要擋的手法也不同(擋幻讀要鎖住「範圍」,而不只是「那幾列」)。
四個隔離層級:一條「安全 vs 效能」光譜
隔離層級,就是「我願意容忍上面哪幾種怪事,換取多少併發效能」的分級。越嚴越安全,但併發能力越低:
Read Committed(不提供 Read Uncommitted)、也是預設;多數 OLTP 用它就夠,真的需要一致性的地方(轉帳、扣庫存)再升到 Repeatable Read 或 SerializableMVCC:PostgreSQL 怎麼做到「讀不擋寫」
你可能會問:要防這些異常,不就是加鎖、讓大家排隊?那併發不就慘了?PostgreSQL 的答案是 MVCC(多版本併發控制),核心一句話:同一列可以同時存在多個版本,每個交易讀取時,看到的是「它的快照」該看到的那一版。
具體怎麼運作?每一列都藏著兩個系統欄位:xmin(這個版本由哪個交易建立)和 xmax(這個版本被哪個交易作廢)。於是:
UPDATE不是就地改,而是新增一個新版本(新 xmin),同時把舊版本標上 xmax(代表「這個交易之後就失效」)。DELETE也不是真的刪,只是把該版本標上 xmax。- 讀取時,交易拿自己的快照去比對每個版本的 xmin / xmax,挑出「對我可見」的那一版。
隔離層級,其實就是「快照的時機」
MVCC 讓前面那張隔離層級表變得很好懂——差別只在你多久拿一次新快照:
- Read Committed(PG 預設):每一句 SQL 都拿一個新快照。所以你讀得到「已提交」的最新資料,但同一交易內前後兩句看到的可能不同 → 於是會有不可重複讀。
- Repeatable Read:整個交易只在開頭拿一次快照,全程沿用。所以同一列讀幾次都一樣(擋掉不可重複讀),連「符合條件的範圍」也凍結在那一刻(PG 順便擋掉幻讀)。
一句話:Read Committed 看「當下的世界」,Repeatable Read 看「交易開始那一刻的世界」。
讀不擋寫,但「寫還是會擋寫」
要注意 MVCC 解的是「讀 vs 寫」的衝突,不是萬能。兩個交易同時要改同一列,還是會互相擋——後到的那個得等前一個 commit 或 rollback(在 Repeatable Read / Serializable 下甚至可能直接被判定衝突而 abort,要你重試)。所以「熱點列」(大家搶改同一列,例如全站共用的計數器、秒殺的庫存)在 MVCC 下依然是效能與衝突的痛點,得靠別的招數(拆分、佇列、樂觀鎖重試)化解。
代價:舊版本會 backlog ,要靠 VACUUM 清
多版本不是免費的。被作廢的舊版本(dead tuples)不會立刻消失,會留在表裡佔空間,直到 PostgreSQL 的 VACUUM(通常是背景的 autovacuum)來回收。如果更新很兇、VACUUM 又跟不上,表會膨脹(bloat)、掃描變慢。這是 MVCC 的隱形帳單——它用「保留多版本」換來高併發,代價是你得讓 VACUUM 追得上寫入。這套「不覆蓋、而是保留多版本」的思路,跟 DDIA 儲存那章的 append-only、跟 SCD Type 2 那種「新增版本而非覆蓋」是同一個家族。
死鎖 Deadlock:循環等待
併發還有一個經典麻煩:死鎖。T1 鎖了 A、想再鎖 B;T2 鎖了 B、想再鎖 A——兩邊互相等對方放手,誰也動不了。資料庫會偵測到這個循環,然後殺掉其中一個交易(回傳 deadlock 錯誤),讓另一個過。避免的關鍵招數很樸素:讓所有交易用固定的順序加鎖(例如永遠先鎖 id 小的),循環就構不成。
反思
隔離層級是一條你要「自己選」的光譜
我以前直覺覺得「當然選最安全的 Serializable」,後來才懂這是錯的——最安全也最慢,併發一高就一堆交易被迫序列化、互相卡。正確的心態是:知道每一級「放行了哪些異常」,然後在真的會出事的地方(轉帳、扣庫存、搶票)才升級,其餘用預設的 Read Committed。這跟 SRE 的 error budget 是同一種思維:不是追求極致,是選一個對得起場景的取捨點。 無腦拉到最高,跟無腦不管,都是沒想清楚。
MVCC 把「衝突」從「當下互斥」變成「版本管理」
MVCC 的「讀不擋寫」第一次看很反直覺,想通之後覺得很漂亮:它沒有用「大家排隊搶一份資料」來解衝突,而是保留多個版本、讓每個交易看自己該看的快照。衝突於是從「當下你死我活的互斥」,變成「井然有序的版本管理」。這個轉念影響很深——很多現代系統的併發設計(包括 DDIA 後面講的分散式一致性)骨子裡都是這套「多版本 + 快照」的變形。把互斥換成版本,是併發設計裡投報率極高的一招。
死鎖不是靠運氣躲,是靠「固定順序」
死鎖的解法讓我學到一個更通用的道理:很多看似隨機、難重現的併發 bug,根源是缺一個一致的順序。 兩個交易以不同順序搶同幾把鎖,遲早撞成循環;而只要全系統約定「永遠照同一個順序加鎖」,循環在數學上就不可能成立。這跟我在 執行順序那篇的體會、甚至跟 團隊需要一把公認的尺是同一件事——一致的順序/標準,常常是把混亂變可控的最省力解法。 併發如此,協作也是如此。