MVCC 多版本並行控制完全指南

理解資料庫如何用多版本快照取代讀寫鎖,達成「讀不擋寫、寫不擋讀」的並行控制


目錄


什麼是 MVCC?

MVCC(Multi-Version Concurrency Control,多版本並行控制) 是一種資料庫並行控制機制:每次對資料列的修改不會就地覆蓋舊值,而是建立一個新版本,並保留舊版本。每個交易讀取時,看到的是符合自身時間點的某個版本。

核心目標:讓互不阻塞。

傳統鎖機制:讀要等寫、寫要等讀(互相阻塞)
MVCC:     讀看舊版本、寫建新版本(讀不擋寫、寫不擋讀)

核心特點

  • 多版本共存:同一筆資料在不同時間點可有多個版本
  • 快照隔離:交易看到的是某一時間點的一致性快照
  • 讀寫分離:讀操作不需要對資料加鎖
  • 代價:需要額外空間存舊版本,且要定期清理

為什麼需要 MVCC?

純鎖機制的問題

只用讀寫鎖(共享鎖 / 排他鎖)來保證隔離時,會遇到:

  • 讀寫互斥:一個交易在讀,另一個想寫就得等;反之亦然
  • 吞吐量低:高並行下大量交易卡在等鎖
  • 死鎖風險:交易互相持有對方需要的鎖

MVCC 的解法

情境 純鎖機制 MVCC
A 讀、B 寫同一列 B 必須等 A 讀完 B 直接寫新版本,A 仍讀舊版本
長時間讀取報表 阻塞所有寫入 寫入照常進行
讀操作加鎖 需要 不需要

MVCC 不是完全取代鎖:寫-寫衝突仍需要鎖(兩個交易同時改同一列時,後者要等)。MVCC 解決的是讀-寫之間的阻塞。


核心原理

三個關鍵元素

  1. 版本(Version):每次 UPDATE / DELETE 產生新版本,舊版本保留
  2. 交易 ID(Transaction ID, XID):單調遞增,標記「誰在何時建立 / 刪除了這個版本」
  3. 可見性判斷(Visibility):交易讀取時,依規則決定哪個版本對自己可見

版本可見性的基本規則

每個資料列版本記錄兩個關鍵欄位:

  • 建立交易 ID:哪個交易建立了這個版本
  • 刪除交易 ID:哪個交易讓這個版本失效(更新或刪除)

一個版本對「當前交易」可見的條件大致是:

建立它的交易已提交,且在我開始之前就提交
AND
(沒有被刪除)或(刪除它的交易在我之後才發生 / 尚未提交)
時間軸 →
版本 v1 (XID=10 建立, XID=20 刪除)
版本 v2 (XID=20 建立)

交易 XID=15 讀取:v1 對它可見(v2 還沒生效)
交易 XID=25 讀取:v2 對它可見(v1 已被刪除)

快照(Snapshot)

交易開始(或每個語句開始,視隔離級別而定)時會取得一份快照,記錄「此刻有哪些交易已提交、哪些還在進行」。之後所有讀取都依這份快照判斷可見性,因此能看到一致的資料狀態。


交易隔離級別

MVCC 是實作隔離級別的常見手段。SQL 標準定義四個級別:

隔離級別 髒讀 不可重複讀 幻讀
Read Uncommitted 可能 可能 可能
Read Committed 不會 可能 可能
Repeatable Read 不會 不會 可能(標準)
Serializable 不會 不會 不會

異常現象說明

  • 髒讀(Dirty Read):讀到別的交易尚未提交的資料
  • 不可重複讀(Non-repeatable Read):同一交易內兩次讀同一列,值不同(被別人 UPDATE)
  • 幻讀(Phantom Read):同一交易內兩次相同查詢,回傳的「列數」不同(被別人 INSERT / DELETE)

MVCC 如何對應

  • Read Committed:每個「語句」開始時取新快照 → 避免髒讀,但同交易內不同語句可能看到不同結果
  • Repeatable Read:「交易」開始時取一次快照,整個交易沿用 → 避免不可重複讀

PostgreSQL 的實作

PostgreSQL 把版本直接存在資料表的實體列(tuple)中。

系統欄位

每筆 tuple 都有隱藏欄位:

  • xmin:建立此版本的交易 ID
  • xmax:刪除 / 更新此版本的交易 ID(0 表示尚未刪除)
-- 可以查出隱藏欄位
SELECT xmin, xmax, * FROM users WHERE id = 1;

更新流程

UPDATE users SET name = 'B' WHERE id = 1;

舊 tuple: (name='A', xmin=10, xmax=20)  ← xmax 被填上當前交易 ID
新 tuple: (name='B', xmin=20, xmax=0)   ← 新增一筆

舊版本不會馬上消失,而是變成死亡元組(dead tuple)

VACUUM 清理

死亡元組會佔用空間,需要由 VACUUM 回收:

VACUUM users;            -- 標記死亡元組空間可重用
VACUUM FULL users;       -- 重寫整個表,真正釋放空間(會鎖表)
  • autovacuum:背景自動執行,是 PostgreSQL 維運的重點
  • 若 autovacuum 跟不上,會出現表膨脹(table bloat)

注意事項

  • 長交易會「卡住」VACUUM:只要有舊交易還活著,它可能還需要看舊版本,這些死亡元組就無法被清理 → 膨脹的主因之一

MySQL InnoDB 的實作

InnoDB 的做法與 PostgreSQL 不同:新版本就地寫在資料頁,舊版本放到 undo log

關鍵結構

每筆列有隱藏欄位:

  • DB_TRX_ID:最後修改此列的交易 ID
  • DB_ROLL_PTR:回滾指標,指向 undo log 中的上一個版本
目前列: (name='B', TRX_ID=20, ROLL_PTR → undo)
undo log: (name='A', TRX_ID=10, ROLL_PTR → ...)  ← 沿指標往回串成版本鏈

讀取時若當前版本不可見,就沿 ROLL_PTR 往回走 undo log,找到對自己可見的版本。

Read View

InnoDB 用 Read View 來判斷可見性,內含一份「建立快照當下仍活躍(未提交)的交易 ID 清單」。判斷邏輯:

  • 列的 DB_TRX_ID 在我之前就提交 → 可見
  • 在活躍清單內(還沒提交)或比我晚 → 不可見,往 undo log 找舊版本

隔離級別差異

  • Read Committed:每次 SELECT 都建立新的 Read View
  • Repeatable Read(InnoDB 預設):交易第一次 SELECT 時建立一次 Read View,整個交易沿用

InnoDB 的幻讀處理

InnoDB 在 Repeatable Read 下,用 Next-Key Lock(記錄鎖 + 間隙鎖 Gap Lock) 額外防止幻讀,因此比 SQL 標準更嚴格。

與 PostgreSQL 的對比

項目 PostgreSQL MySQL InnoDB
舊版本存放 直接留在資料表(dead tuple) undo log
清理機制 VACUUM / autovacuum purge 執行緒清理 undo
膨脹位置 資料表本身 undo log(長交易會撐大)
主鍵更新 產生新 tuple 就地更新 + undo

快照讀 vs 當前讀

在 MVCC 下,讀取分兩種,行為完全不同:

快照讀(Snapshot Read / Consistent Read)

一般的 SELECT,讀的是 MVCC 快照中的版本,不加鎖

SELECT * FROM users WHERE id = 1;   -- 快照讀,可能讀到舊版本

當前讀(Current Read / Locking Read)

讀取最新已提交版本,並加鎖,避免其他交易修改:

SELECT * FROM users WHERE id = 1 FOR UPDATE;     -- 排他鎖
SELECT * FROM users WHERE id = 1 FOR SHARE;      -- 共享鎖
UPDATE / DELETE / INSERT                          -- 都屬於當前讀

常見誤區:以為 Repeatable Read 下絕對讀不到別人的新資料。其實若用 FOR UPDATE 或執行 UPDATE,走的是當前讀,會看到最新版本,並可能因此產生與快照讀不一致的結果。


優缺點分析

優點

  • 高並行讀寫:讀不加鎖,讀寫互不阻塞
  • 一致性讀取:長查詢 / 報表能讀到穩定快照,不被寫入干擾
  • 避免讀鎖死鎖:讀操作不參與鎖競爭

缺點 / 代價

  • 儲存開銷:需保留舊版本(dead tuple / undo log)
  • 清理成本:需要 VACUUM / purge,維運上要監控
  • 長交易傷害大:阻擋舊版本回收,造成膨脹、效能下降
  • 寫-寫仍需鎖:MVCC 不解決寫衝突
  • 無法完全避免所有異常:純 MVCC 的 Repeatable Read 仍可能有寫偏斜(write skew),需 Serializable 或顯式鎖處理

常見問題

問題 1:MVCC 能取代鎖嗎?

不能完全取代。MVCC 處理的是讀-寫並行(讀不擋寫)。寫-寫衝突(兩交易同時改同一列)仍需要鎖,後到的交易必須等待。

問題 2:為什麼 PostgreSQL 的表會越來越大?

UPDATE / DELETE 產生的舊版本(dead tuple)若沒被 VACUUM 及時清理就會累積,造成表膨脹。常見原因是長交易卡住 autovacuum,或 autovacuum 設定太保守。

-- 查看死亡元組數量
SELECT relname, n_dead_tup, n_live_tup
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;

問題 3:長交易為什麼危險?

只要交易還活著,它的快照就可能還需要某些舊版本,導致這些舊版本無法回收。在 PostgreSQL 造成表膨脹,在 InnoDB 造成 undo log 撐大、purge 卡住。應避免「開著交易去等使用者輸入」這類長交易。

問題 4:Read Committed 和 Repeatable Read 用 MVCC 差在哪?

差在取快照的時機

  • Read Committed:每個語句重新取快照 → 同交易內可能讀到別人新提交的資料
  • Repeatable Read:交易開始時取一次快照,整個交易沿用 → 同交易內讀取結果穩定

問題 5:MVCC 下會有死鎖嗎?

仍可能。死鎖來自,而當前讀(UPDATESELECT ... FOR UPDATE)會加鎖。多個交易以不同順序鎖多列時,依然可能死鎖。


總結

核心要點

  • MVCC 用多版本 + 快照取代讀寫鎖,達成「讀不擋寫、寫不擋讀」
  • 每個版本帶交易 ID,靠可見性規則決定交易看哪個版本
  • 隔離級別的差異主要在「取快照的時機」(每語句 vs 每交易)
  • PostgreSQL 把舊版本留在表中(靠 VACUUM 清),InnoDB 放 undo log(靠 purge 清)
  • 代價是儲存與清理開銷,且長交易會嚴重妨礙舊版本回收

快速參考

概念 說明
MVCC 多版本並行控制,讀寫不互斥
快照讀 一般 SELECT,讀版本、不加鎖
當前讀 UPDATE / FOR UPDATE,讀最新版本並加鎖
PostgreSQL xmin / xmax + VACUUM
MySQL InnoDB DB_TRX_ID / ROLL_PTR + undo log + Read View
寫-寫衝突 MVCC 不解決,仍需鎖
長交易 阻擋舊版本回收,造成膨脹

建立日期:2026-06-17

🔗相關文章