目錄
什麼是 MVCC?
MVCC(Multi-Version Concurrency Control,多版本並行控制) 是一種資料庫並行控制機制:每次對資料列的修改不會就地覆蓋舊值,而是建立一個新版本,並保留舊版本。每個交易讀取時,看到的是符合自身時間點的某個版本。
核心目標:讓讀與寫互不阻塞。
傳統鎖機制:讀要等寫、寫要等讀(互相阻塞)
MVCC: 讀看舊版本、寫建新版本(讀不擋寫、寫不擋讀)
核心特點
- 多版本共存:同一筆資料在不同時間點可有多個版本
- 快照隔離:交易看到的是某一時間點的一致性快照
- 讀寫分離:讀操作不需要對資料加鎖
- 代價:需要額外空間存舊版本,且要定期清理
為什麼需要 MVCC?
純鎖機制的問題
只用讀寫鎖(共享鎖 / 排他鎖)來保證隔離時,會遇到:
- 讀寫互斥:一個交易在讀,另一個想寫就得等;反之亦然
- 吞吐量低:高並行下大量交易卡在等鎖
- 死鎖風險:交易互相持有對方需要的鎖
MVCC 的解法
| 情境 | 純鎖機制 | MVCC |
|---|---|---|
| A 讀、B 寫同一列 | B 必須等 A 讀完 | B 直接寫新版本,A 仍讀舊版本 |
| 長時間讀取報表 | 阻塞所有寫入 | 寫入照常進行 |
| 讀操作加鎖 | 需要 | 不需要 |
MVCC 不是完全取代鎖:寫-寫衝突仍需要鎖(兩個交易同時改同一列時,後者要等)。MVCC 解決的是讀-寫之間的阻塞。
核心原理
三個關鍵元素
- 版本(Version):每次
UPDATE/DELETE產生新版本,舊版本保留 - 交易 ID(Transaction ID, XID):單調遞增,標記「誰在何時建立 / 刪除了這個版本」
- 可見性判斷(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:建立此版本的交易 IDxmax:刪除 / 更新此版本的交易 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:最後修改此列的交易 IDDB_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 下會有死鎖嗎?
仍可能。死鎖來自鎖,而當前讀(UPDATE、SELECT ... 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