MVCC 用「每次寫都產生新版本、讀只看自己開始時的快照」換來讀不擋寫、寫不擋讀。它是 Snapshot Isolation 的底層引擎,代價是要持續回收沒人看得到的舊版本。
🧠 Intuition
TIP
不要把資料庫想成「一格一格會被覆蓋的記憶體」,要想成「append-only 的版本鏈」。寫不是改值、是疊一個新版本;讀只是沿著鏈往回找「在我這個時間點看得到的那一版」。
- 寫產生新版本:UPDATE 不就地覆蓋,而是寫一筆新 tuple 並標記舊版本的死亡時間,舊版本暫時保留。
- 讀看開始時的快照:交易啟動時取得一個一致的視圖,整段交易期間都讀這個視圖,與後續其他交易的 commit 隔離。
- 讀寫互不阻塞:讀者沿版本鏈找舊版本,不需等寫者放鎖;寫者疊新版本,也不需等讀者讀完。這是 MVCC 最大的價值。
- 代價是空間與回收:舊版本會累積,需要背景程序(vacuum)清掉「已經沒有任何活躍交易看得到」的版本。
⚖️ Tradeoffs
版本可見性規則 — 用 txid 判斷誰看得到哪一版
- 每個交易拿一個單調遞增的 transaction id (txid);每個 tuple 記錄
created_by(產生它的 txid)與deleted_by(刪除/更新它的 txid)。 - 交易啟動時拍一張快照:記下「此刻已 commit 的 txid 集合」與「仍在進行中的 txid」。
- 一個版本對我可見的條件:產生它的交易在我的快照中已 commit 且不在進行中,且刪除它的交易對我而言尚未 commit(或根本不存在)。
- 自己交易內的寫入對自己一律可見——讀到自己未提交的修改。
讀不擋寫、寫不擋讀 — MVCC 的核心紅利
- 純鎖式(如 2PL)下讀者要持有共享鎖、寫者要等讀者放鎖,讀寫互相阻塞,吞吐受限。
- MVCC 把「過去的版本」留著,讀者永遠有一份可讀的舊版本,因此讀者與寫者可以並行。
- 但寫寫仍會衝突:兩個交易要更新同一列時,後者得等前者 commit/abort(行鎖仍在)——MVCC 不消除寫寫競爭。
垃圾回收 (Vacuum) — 舊版本不會自己消失
- 當某個舊版本「已經比所有活躍交易的快照都早被刪除」時,它對任何人都不可見,可以安全回收。
- PostgreSQL 用
VACUUM(與 autovacuum)回收死 tuple;長交易會卡住回收界線,導致版本膨脹 (table bloat)。 - 實務痛點:一個忘了結束的長交易(如閒置的 analytics 連線)會讓 vacuum 無法推進,磁碟與索引持續膨脹。
與 Snapshot Isolation 的關係 — MVCC 是引擎,SI 是合約
- Snapshot Isolation 幾乎都用 MVCC 實作:SI 的「整段交易讀同一份一致快照」正是靠版本可見性規則達成。
- SI 擋掉髒讀、不可重複讀(PostgreSQL 的 SI 連幻讀也擋),但擋不住寫偏斜——因為各交易讀同一份舊快照、寫不同列,彼此看不到。
- 要在 MVCC 之上達成 Serializable,需再加偵測機制(SSI:偵測危險的讀寫相依,衝突才 abort 重試)。
🔑 Takeaways
- MVCC 的本質:寫疊新版本、讀看開始時的快照,用空間換來讀不擋寫、寫不擋讀。
- 可見性靠 txid + 快照 判定:產生版本的交易已 commit、刪除它的交易尚未 commit,該版本才對我可見。
- MVCC 是 Snapshot Isolation 的底層實作;SI 因此擋不住寫偏斜,要 Serializable 得在 MVCC 上加 SSI。
- 別忘了 vacuum:舊版本需回收,長交易會卡住回收界線造成膨脹——監控長交易是 MVCC 系統的運維重點。
2026-07-01
- Q: MVCC 既然讀不擋寫,為什麼還會有寫寫衝突卡住的情況?
- A: 因為 MVCC 解決的是「讀」與「寫」之間的隔離——讀者去翻舊版本,不必動到寫者。但兩個交易要更新同一列時,資料庫只能保留一條有效的最新版本鏈,必須序列化這兩個寫入,所以後到的寫者要在那一列的行鎖上等前者 commit 或 abort。MVCC 消除的是讀寫阻塞,不是寫寫競爭;寫寫永遠需要某種仲裁(行鎖或 first-committer-wins)。