MVCC 多版本併發控制
解析 InnoDB 如何透過隱藏欄位、Undo Log 版本鏈、Read View 實現非鎖定讀,以及在 RC 與 RR 下的行為差異
為什麼需要 MVCC
最單純的併發控制是「讀加 S lock、寫加 X lock」,但讀寫互斥會讓吞吐直接砍半。MVCC(Multi-Version Concurrency Control)的核心想法是:寫歸寫,讀不加鎖,讀的是歷史版本。寫入時建立新版本,讀取時根據自己的快照看對應的舊版本,讀寫不互相等待。
InnoDB 在 Read Committed 和 Repeatable Read 兩個隔離等級下使用 MVCC。Read Uncommitted 不需要(直接讀最新值),Serializable 不使用(所有讀加鎖)。
三個隱藏欄位
InnoDB 每一行資料都有三個隱藏欄位:
| 欄位 | 大小 | 用途 |
|---|---|---|
DB_TRX_ID | 6 bytes | 最後一次修改這行的交易 ID |
DB_ROLL_PTR | 7 bytes | 指向 undo log 中這行的上一個版本(回滾指標) |
DB_ROW_ID | 6 bytes | 隱藏主鍵(只有在沒有定義 PK 也沒有 NOT NULL UNIQUE 時才生成) |
DB_TRX_ID 和 DB_ROLL_PTR 是 MVCC 的基礎——前者標記「誰改的」,後者串成版本鏈。
版本鏈(Version Chain)

每次 UPDATE 一行,InnoDB 做三件事:
- 把舊值寫入 undo log
- 把
DB_ROLL_PTR指向剛寫入的 undo log 記錄 - 更新行資料,
DB_TRX_ID改為當前交易 ID
多次 UPDATE 就會在 undo log 中形成一條鏈:
當前行(Buffer Pool)
DB_TRX_ID = 100
DB_ROLL_PTR ──→ Undo Log Entry (trx_id=80)
DB_ROLL_PTR ──→ Undo Log Entry (trx_id=50)
DB_ROLL_PTR ──→ Undo Log Entry (trx_id=20)
DB_ROLL_PTR = NULL
Read View 就是沿著這條鏈往回找,找到第一個對自己「可見」的版本。
Read View
Read View 是 MVCC 的核心判斷依據。它是一個在某個時間點建立的快照描述,包含四個關鍵資訊:
| 欄位 | 意義 |
|---|---|
m_ids | 建立 Read View 時所有**活躍(未 COMMIT)**的交易 ID 列表 |
min_trx_id | m_ids 中的最小值 |
max_trx_id | 系統即將分配的下一個交易 ID(不是 m_ids 的最大值) |
creator_trx_id | 建立這個 Read View 的交易自己的 ID |
可見性判斷規則
對於版本鏈上某個版本的 DB_TRX_ID:
trx_id == creator_trx_id:自己改的,可見trx_id < min_trx_id:在 Read View 建立前就已 COMMIT,可見trx_id >= max_trx_id:在 Read View 建立後才開始,不可見min_trx_id <= trx_id < max_trx_id:- 在
m_ids中:該交易建立 Read View 時還沒 COMMIT,不可見 - 不在
m_ids中:建立 Read View 前已 COMMIT,可見
- 在
從當前版本開始,依規則判斷。不可見就沿 DB_ROLL_PTR 往前一個版本再判斷,直到找到可見版本或到達鏈尾(行不存在)。
完整走查範例
假設有一行 orders.amount,經過三次 UPDATE 形成以下版本鏈:
當前行: trx_id=300, amount=600
↓ DB_ROLL_PTR
Undo v2: trx_id=200, amount=500
↓ DB_ROLL_PTR
Undo v1: trx_id=100, amount=400
↓ DB_ROLL_PTR = NULL
此時系統有三個交易:trx_id=100 已 COMMIT、trx_id=200 已 COMMIT、trx_id=300 未 COMMIT。
另一個交易 trx_id=150 執行 SELECT amount FROM orders WHERE id = 1(RR,首次 SELECT)。建立的 Read View:
creator_trx_id = 150
m_ids = [150, 300] ← 活躍交易(150 是自己,300 還沒 COMMIT)
min_trx_id = 150
max_trx_id = 301 ← 下一個即將分配的 ID
走查版本鏈:
第一步:檢查當前行 trx_id=300
- 規則 1:300 == 150(creator)?否
- 規則 2:300 < 150(min)?否
- 規則 3:300 >= 301(max)?否
- 規則 4:150 <= 300 < 301,300 在
m_ids中嗎?在(300 還沒 COMMIT)→ 不可見 - → 沿 DB_ROLL_PTR 往前
第二步:檢查 Undo v2 trx_id=200
- 規則 1:200 == 150?否
- 規則 2:200 < 150?否
- 規則 3:200 >= 301?否
- 規則 4:150 <= 200 < 301,200 在
m_ids中嗎?不在(200 已 COMMIT)→ 可見! - → 返回
amount = 500
最終 trx_id=150 讀到的是 amount=500——跳過了未 COMMIT 的 300,也跳過了雖然已 COMMIT 但被 300 覆蓋的最新值。
如果 trx_id=300 在走查前 COMMIT 了呢?那 m_ids 中不會有 300,規則 4 判斷「不在 m_ids → 已 COMMIT → 可見」,直接讀到 amount=600。這就是 RC 和 RR 的差異——RC 每次 SELECT 重建 Read View,能看到剛 COMMIT 的 300;RR 重用舊 Read View,300 始終在 m_ids 裡。
RC 與 RR 的差異:Read View 的建立時機
MVCC 的機制在 RC 和 RR 下完全一樣,唯一的差別是 Read View 什麼時候建立:
| 隔離等級 | Read View 建立時機 | 效果 |
|---|---|---|
| Read Committed | 每次 SELECT 都建一個新的 | 能看到其他交易最新 COMMIT 的修改 |
| Repeatable Read | 交易第一次 SELECT 建立,之後重用 | 整筆交易看到的快照一致 |
RC 範例
trx_id=100 開始
SELECT → 建 Read View₁ (m_ids=[100])
trx_id=200: UPDATE row SET val='B'; COMMIT;
SELECT → 建 Read View₂ (m_ids=[100])
→ trx_id=200 不在 m_ids 且 < max_trx_id → 可見
→ 讀到 'B'
兩次 SELECT 看到不同結果——不可重複讀。
RR 範例
trx_id=100 開始
SELECT → 建 Read View₁ (m_ids=[100])
trx_id=200: UPDATE row SET val='B'; COMMIT;
SELECT → 重用 Read View₁ (m_ids=[100])
→ trx_id=200 >= max_trx_id(假設 max_trx_id=200)→ 不可見
→ 沿版本鏈找到舊版本 → 讀到 'A'
兩次 SELECT 看到相同結果——可重複讀。
快照讀 vs 當前讀
InnoDB 有兩種讀取模式,使用 MVCC 的只有快照讀:
| 模式 | 觸發方式 | 讀取版本 | 是否加鎖 |
|---|---|---|---|
| 快照讀 | 一般 SELECT | Read View 可見版本 | 否 |
| 當前讀 | SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT | 最新已 COMMIT 版本 | 是 |
當前讀不走 MVCC,直接讀最新版本並加鎖(Record Lock / Gap Lock / Next-Key Lock)。這是 RR 下「快照讀不幻讀、但 UPDATE 後能看到新行」的根本原因——UPDATE 觸發當前讀,跳過了 Read View。詳見 Transaction Isolation Levels 的幻讀邊界情況。
MVCC 與 Undo Log 的生命週期
MVCC 依賴 undo log 的版本鏈,所以 undo log 不能隨便清理。InnoDB 的 purge thread 負責回收不再被任何 Read View 需要的 undo log 記錄。
判斷標準:系統中最老的活躍 Read View 的 min_trx_id。所有 trx_id < min_trx_id 的 undo log 版本在任何活躍 Read View 中都已可見(走不到更舊的版本),可以安全回收。
長交易的災難:一筆 10 分鐘的交易持有一個很舊的 Read View,導致 10 分鐘內所有 update undo 都不能清理。Undo tablespace 膨脹、purge lag 增加、查詢因為要遍歷更長的版本鏈而變慢。監控指標:trx_rseg_history_len(History List Length),正常應在幾千以下,超過百萬就該查是不是有長交易卡住。
-- 查看 History List Length
SHOW ENGINE INNODB STATUS\G
-- 或
SELECT count FROM information_schema.innodb_metrics
WHERE name = 'trx_rseg_history_len';
-- 找出長交易
SELECT * FROM information_schema.innodb_trx
ORDER BY trx_started ASC LIMIT 5;
MVCC 不是銀彈
MVCC 只解決「讀不阻塞寫」,不解決「寫不阻塞寫」。兩個交易同時 UPDATE 同一行,後到的仍然要等 X lock。寫寫衝突只能靠鎖(見 Locking)。
另外 MVCC 的隔離是行級別的——它看的是每一行的 DB_TRX_ID。對於跨行的一致性約束(如外鍵、唯一索引衝突),仍需要鎖來保證。