返回文章列表
mysql

MVCC 多版本併發控制

解析 InnoDB 如何透過隱藏欄位、Undo Log 版本鏈、Read View 實現非鎖定讀,以及在 RC 與 RR 下的行為差異

Aaron

為什麼需要 MVCC

最單純的併發控制是「讀加 S lock、寫加 X lock」,但讀寫互斥會讓吞吐直接砍半。MVCC(Multi-Version Concurrency Control)的核心想法是:寫歸寫,讀不加鎖,讀的是歷史版本。寫入時建立新版本,讀取時根據自己的快照看對應的舊版本,讀寫不互相等待。

InnoDB 在 Read Committed 和 Repeatable Read 兩個隔離等級下使用 MVCC。Read Uncommitted 不需要(直接讀最新值),Serializable 不使用(所有讀加鎖)。

三個隱藏欄位

InnoDB 每一行資料都有三個隱藏欄位:

欄位大小用途
DB_TRX_ID6 bytes最後一次修改這行的交易 ID
DB_ROLL_PTR7 bytes指向 undo log 中這行的上一個版本(回滾指標)
DB_ROW_ID6 bytes隱藏主鍵(只有在沒有定義 PK 也沒有 NOT NULL UNIQUE 時才生成)

DB_TRX_IDDB_ROLL_PTR 是 MVCC 的基礎——前者標記「誰改的」,後者串成版本鏈。

版本鏈(Version Chain)

每次 UPDATE 一行,InnoDB 做三件事:

  1. 把舊值寫入 undo log
  2. DB_ROLL_PTR 指向剛寫入的 undo log 記錄
  3. 更新行資料,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_idm_ids 中的最小值
max_trx_id系統即將分配的下一個交易 ID(不是 m_ids 的最大值)
creator_trx_id建立這個 Read View 的交易自己的 ID

可見性判斷規則

對於版本鏈上某個版本的 DB_TRX_ID

  1. trx_id == creator_trx_id:自己改的,可見
  2. trx_id < min_trx_id:在 Read View 建立前就已 COMMIT,可見
  3. trx_id >= max_trx_id:在 Read View 建立後才開始,不可見
  4. 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 的只有快照讀:

模式觸發方式讀取版本是否加鎖
快照讀一般 SELECTRead View 可見版本
當前讀SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETEINSERT最新已 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。對於跨行的一致性約束(如外鍵、唯一索引衝突),仍需要鎖來保證。