Java 後端底層 ch02 交易與 MVCC:隔離等級的真實代價
下一章→
CH 02 儲存引擎

交易與 MVCC:隔離等級的真實代價

為什麼不能全部加鎖隱藏欄位+undo log+Read ViewRC 與 RR 的差別只有一句話幻讀與 Next-Key Lock長事務如何拖垮資料庫

上一章講的是「資料怎麼被找到」,這一章講「很多人同時讀寫同一筆資料時,會發生什麼事」。

先看一個真實會發生的場景:

使用者 A 查詢帳戶餘額 → 顯示 1000 元
(同一秒,使用者 B 轉走 300 元並提交)
使用者 A 在同一個交易裡再查一次 → 顯示 700 元

同一個交易裡,同一句 SQL,讀到兩個不同的答案。這叫不可重複讀。它是不是 bug,取決於你的隔離等級設定——在某些等級這是預期行為,在某些等級不允許發生。

問題是:要讓 A 兩次都讀到 1000,最直覺的做法是「A 在讀的時候不准 B 改」,也就是加鎖。但如果每次讀都要鎖,整個資料庫就退化成單執行緒了。

MVCC(多版本並行控制)就是為了解決這個矛盾而生的:讓讀取完全不加鎖,卻依然看得到一致的資料。它的做法是——每一列資料同時存在很多個「歷史版本」,不同的交易看到不同的版本。

這一章要拆的東西:

  • 為什麼不能靠鎖解決一切——先看清楚代價,才知道 MVCC 在省什麼。
  • MVCC 的三個零件:隱藏欄位、undo log 版本鏈、Read View,以及可見性到底怎麼判斷。
  • RC 與 RR 的差別,其實只有一句話——這是整章最值得記住的一件事。
  • 幻讀與 Next-Key Lock:MVCC 沒有解決的那一半問題,以及它帶來的死鎖。
  • 長事務如何拖垮整個資料庫:一個 @Transactional 包錯範圍就能讓表空間爆掉。

前置知識:這章預設你已經知道索引與聚簇索引的結構(ch01),因為 undo log 的版本鏈是掛在聚簇索引的資料列上的。對「交易、ACID」還完全陌生的話,先看 IT 基礎知識 ch07 資料庫基礎。

ACID 四個字母裡,A(原子性)、D(持久性)靠 redo/undo log 就能解決,C(一致性)是結果。真正難的是 I —— Isolation(隔離性),因為它要處理的是「很多人同時動同一筆資料」。

教科書會給你三個名詞的定義,但定義背了也用不出來。直接看它們長什麼樣:

1. 髒讀(Dirty Read)—— 讀到「還沒提交」的資料

交易 A:UPDATE account SET balance = 700 WHERE id = 1;  (還沒 COMMIT)
交易 B:SELECT balance FROM account WHERE id = 1;  →  讀到 700
交易 A:ROLLBACK;   ← 回滾了!

結果:B 拿到一個「從來沒有真正存在過」的值。

2. 不可重複讀(Non-Repeatable Read)—— 同一筆資料,前後讀到不同值

交易 A:SELECT balance WHERE id = 1;  →  1000
交易 B:UPDATE balance = 700 WHERE id = 1;  COMMIT;
交易 A:SELECT balance WHERE id = 1;  →  700   ← 同一個交易裡變了

針對的是 UPDATE:同一列的內容變了。

3. 幻讀(Phantom Read)—— 同一個範圍,前後讀到不同「筆數」

交易 A:SELECT COUNT(*) WHERE age > 20;  →  10 筆
交易 B:INSERT INTO users (age) VALUES (25);  COMMIT;
交易 A:SELECT COUNT(*) WHERE age > 20;  →  11 筆  ← 多出一筆「幻影」

針對的是 INSERT / DELETE:列數變了。

記法:不可重複讀是「同一列變了」,幻讀是「多/少了一列」。兩者的解法不同——這正是為什麼要分開講。

四種隔離等級就是在選「允許哪幾種」

  • READ UNCOMMITTED:三種都可能發生(幾乎沒人用)
  • READ COMMITTED(RC):擋掉髒讀。PostgreSQL、Oracle、SQL Server 的預設
  • REPEATABLE READ(RR):再擋掉不可重複讀。MySQL 的預設
  • SERIALIZABLE:全部擋掉,代價是幾乎完全序列化

最直覺的隔離做法是加鎖:我在讀的時候,不准你改。

交易 A:SELECT ... (加讀鎖,鎖住整個交易期間)
交易 B:UPDATE ... (被擋住,等 A 提交)

這確實正確,而且就是 SERIALIZABLE 的做法。問題在代價。

代價一:讀寫互相阻塞

一個報表查詢跑 30 秒,這 30 秒內所有想改到那些列的交易全部卡住。在一個讀多寫多的系統裡,吞吐量會直接崩掉。

代價二:讀取本來是最安全的操作

兩個人同時「讀」同一筆資料,彼此完全沒有影響。但為了防範「可能有人來改」,就讓所有讀取都付出加鎖的成本——這是拿 99% 的正常情況去遷就 1% 的衝突。

核心矛盾:要一致性就要擋住別人,但擋住別人就沒有並行度。

MVCC 的思路:不要擋,改成「給你看舊的」

與其阻止 B 修改,不如讓 A 繼續看到修改前的那個版本。既然 A 想要的是「整個交易期間看到一致的資料」,那給它一份「交易開始那一刻的快照」就好了。

  • B 的修改照常進行,不用等 A
  • A 讀到的是舊版本,不用加鎖
  • 兩邊都不用停下來
✅ MVCC 的一句話總結:讀不加鎖、寫不擋讀。代價是資料庫必須同時保存同一列的多個歷史版本。

接下來三張卡就是在講:這些「歷史版本」存在哪裡、怎麼判斷該給你看哪一個。

MVCC 不是一個演算法,是三個零件配合出來的效果。

零件一:每一列都有兩個你看不到的隱藏欄位

  • DB_TRX_ID(6 bytes):最後一次修改這一列的交易 ID。交易 ID 是遞增的,所以它同時代表「新舊」。
  • DB_ROLL_PTR(7 bytes):回滾指標,指向 undo log 裡的上一個版本。

(上一章提過的 row_id 是第三個隱藏欄位,只有在完全沒主鍵時才出現。)

零件二:undo log 串成「版本鏈」

每次 UPDATE,InnoDB 不是就地覆蓋,而是:把舊值寫進 undo log,然後讓新資料的 DB_ROLL_PTR 指向它。

目前的資料列(聚簇索引裡)
  balance = 700 , DB_TRX_ID = 102 , DB_ROLL_PTR ─┐
                                                 ↓
  undo log:  balance = 1000 , DB_TRX_ID = 98 , DB_ROLL_PTR ─┐
                                                            ↓
             balance = 1500 , DB_TRX_ID = 85 , DB_ROLL_PTR ─→ NULL
所以「同一列的多個版本」不是複製了三份資料,而是一條由新到舊串起來的鏈。這條鏈掛在聚簇索引的那一列上——這就是為什麼上一章的聚簇索引是這章的前置知識。

零件三:Read View —— 決定你能看到哪個版本

當你執行一個快照讀(普通的 SELECT)時,InnoDB 會產生一個 Read View,裡面記著「產生這一刻,系統上有哪些交易還沒提交」。它包含四個欄位:

  • m_ids:目前還活躍(未提交)的交易 ID 清單
  • min_trx_id:m_ids 裡最小的那個
  • max_trx_id:下一個要分配的交易 ID(注意:不是目前最大的)
  • creator_trx_id:產生這個 Read View 的交易自己的 ID

有了它,就能沿著版本鏈由新往舊走,找到第一個「你有資格看到」的版本。判斷規則在下一張卡。

拿到一個版本的 DB_TRX_ID(記作 trx_id),依序做三個判斷:

判斷一:trx_id == creator_trx_id?

→ 可見。這是我自己改的,我當然看得到自己的修改。

判斷二:trx_id < min_trx_id?

→ 可見。這個交易在我建立 Read View 之前就已經提交了(因為它比所有活躍交易都舊)。

判斷三:trx_id >= max_trx_id?

→ 不可見。這個交易是在我建立 Read View 之後才開始的,它做的事我不該看到。

判斷四:落在中間(min <= trx_id < max)

→ 看它在不在 m_ids 裡:

  • 在 → 不可見(我建立快照時它還沒提交)
  • 不在 → 可見(我建立快照時它已經提交了)
本質:「我只看得到——在我建立快照那一刻,就已經提交完成的資料」,加上我自己改的東西。整套規則就是在精確地表達這一句話。

不可見的話怎麼辦

沿著 DB_ROLL_PTR 往 undo log 走一格,拿到更舊的版本,再判斷一次。一直往舊的方向走,直到找到可見的版本為止。

目前版本 trx_id=102  → 不可見(在 m_ids 裡)
  ↓ 沿 DB_ROLL_PTR
舊版本   trx_id=98   → 可見(98 < min_trx_id)
  ↓
★ 回傳這個版本的資料
💡 推論:版本鏈越長,讀取要走的路就越長。而版本鏈之所以會變長,是因為舊版本沒有被清理——這就是後面「長事務」那張卡的伏筆。

前面鋪了那麼多零件,就是為了這一句:

READ COMMITTED 與 REPEATABLE READ 的唯一差別是:Read View 什麼時候建立。

READ COMMITTED:每一次 SELECT 都建立新的 Read View

交易 A 開始
  SELECT ...  → 建立 Read View #1  → 讀到 1000
               (交易 B 改成 700 並提交)
  SELECT ...  → 建立 Read View #2  → 讀到 700   ← 值變了

因為每次都重新拍快照,所以看得到別人剛提交的東西——這正是「不可重複讀」的成因,也正是「已提交讀」這個名字的意思。

REPEATABLE READ:只在第一次 SELECT 建立,整個交易重複使用

交易 A 開始
  SELECT ...  → 建立 Read View(就這一次)  → 讀到 1000
               (交易 B 改成 700 並提交)
  SELECT ...  → 沿用同一個 Read View        → 還是 1000  ← 值不變

快照凍結在第一次讀取的那一刻,所以整個交易期間看到的世界是一致的。

✅ 同一套 MVCC 機制、同一套可見性規則,只是換了「快照建立時機」,就得到兩種不同的隔離等級。這是整章最漂亮的一件事。

順帶解掉一個常見誤解

很多人以為 RR 的快照是「交易 BEGIN 的那一刻」建立的。不是——是第一次執行快照讀的時候才建立。所以:

BEGIN;
-- (這裡什麼都還沒讀,別人怎麼改都無所謂)
SELECT ...   ← 快照在這一刻才凍結

如果需要在 BEGIN 當下就凍結,要明確寫 START TRANSACTION WITH CONSISTENT SNAPSHOT。

MVCC 只作用在快照讀上。有一類操作它管不到,叫當前讀。

兩種讀的分界

  • 快照讀(Snapshot Read):普通的 SELECT。走 MVCC,讀歷史版本,不加鎖。
  • 當前讀(Current Read):SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT。必須讀最新版本並加鎖——因為你要改它,不可能改在一個舊版本上。
這就是幻讀在 RR 下依然可能出現的原因:快照讀看不到別人新增的列(MVCC 擋住了),但只要你做一次當前讀(例如 UPDATE),就會看到最新狀態,於是「幻影」出現了。

InnoDB 的解法:Next-Key Lock

當前讀時,InnoDB 鎖的不只是那一列,還包含列與列之間的空隙:

  • Record Lock:鎖住存在的那一列
  • Gap Lock:鎖住兩列之間的區間,防止別人在中間插進來
  • Next-Key Lock = Record Lock + Gap Lock,鎖住「這一列+它前面的空隙」
索引上現有值:10, 20, 30

SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;
→ 鎖住 20 這一列,也鎖住 (10,20) 與 (20,30) 這兩個空隙
→ 別人想 INSERT id = 18 或 22 都會被擋住

所以再查一次,筆數保證不變 → 幻讀被擋掉了
失敗場景:Gap Lock 造成的死鎖
兩個交易各自對相鄰區間做當前讀,然後互相往對方的空隙插入資料——彼此持有對方需要的 Gap Lock,形成死鎖。

徵狀:高並發下偶發 Deadlock found when trying to get lock,而且 SQL 看起來完全沒有共用的列。
診斷:SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 區塊,看兩邊各自等的是什麼鎖。
常見修法:統一交易內的存取順序、縮小掃描範圍(讓 WHERE 走精準索引而不是範圍),必要時降到 RC(RC 幾乎不用 Gap Lock)。

這是實務上 MVCC 最常見、也最容易被誤診的災難。

因果鏈

  1. 一個交易開著沒提交,它的 Read View 一直活著。
  2. 只要有任何一個 Read View 可能需要看到某個舊版本,那個舊版本就不能被清理(purge)。
  3. 其他交易持續在改資料,undo log 版本鏈一直變長。
  4. 其他人的查詢要沿著版本鏈往回走更多格,讀取越來越慢。
  5. undo 表空間(ibdata1 或 undo tablespace)持續膨脹,而且不會自己縮回去。
徵狀:資料庫沒有慢查詢、CPU 也不高,但整體回應時間緩慢惡化;磁碟使用量莫名其妙持續上升;重啟之後短暫變好,過幾天又慢下來。

Spring 裡最常見的三種寫法

@Transactional
public void process(Long orderId) {
    Order order = orderRepo.findById(orderId);

    // ❌ 1. 交易裡呼叫外部 HTTP —— 對方慢 30 秒,交易就開 30 秒
    paymentClient.charge(order);

    // ❌ 2. 交易裡做檔案 I/O 或寄信
    mailService.send(order);

    // ❌ 3. 交易裡跑迴圈處理十萬筆
    for (Item item : items) { ... }

    orderRepo.save(order);
}

診斷指令

-- 找出開最久的交易
SELECT trx_id, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS 已開啟秒數,
       trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

修法

  • 交易只包住真正要一起成功或失敗的 DB 操作。外部呼叫、檔案 I/O、寄信全部移到交易外面。
  • 要在交易成功後才做的事,用 @TransactionalEventListener(phase = AFTER_COMMIT)。
  • 大批次處理切成小批,每批各自一個交易。
  • 設定 @Transactional(timeout = 5) 當作保險,讓失控的交易自己死掉而不是拖著大家。
🚨 判斷準則:如果一段程式碼的執行時間不是由你的資料庫決定的(網路、檔案、第三方 API),它就不該在交易裡面。

一個常被忽略的事實:MySQL 預設 REPEATABLE READ,但 PostgreSQL、Oracle、SQL Server 都預設 READ COMMITTED。連資料庫廠商都沒有共識,代表這是取捨而不是對錯。

MySQL 為什麼選 RR(歷史因素)

早期 MySQL 的主從複製用 statement-based binlog(把 SQL 語句複製過去執行)。在 RC 下,主從兩邊執行同一批語句可能得到不同結果,會導致資料不一致。RR 加上 Gap Lock 才能保證順序一致。換句話說,這個預設值是為了複製的正確性,不是為了應用程式好用。

RR 的代價

  • Gap Lock 多得多 → 鎖的範圍更大 → 並行度更低、死鎖機率更高
  • 快照凍結整個交易 → 長事務造成的版本鏈問題更嚴重

什麼時候該考慮降到 RC

  • 高並發寫入、而且已經被 Gap Lock 死鎖困擾
  • 業務上能接受「同一個交易裡兩次讀到不同值」——大多數 Web 應用其實可以,因為一個請求通常只讀一次
  • 已經改用 row-based binlog(現代 MySQL 的預設),沒有複製一致性的疑慮

什麼時候需要 SERIALIZABLE

幾乎不需要。它會把所有普通 SELECT 變成加鎖讀,並行度崩潰。真的需要嚴格一致性時,比較好的做法是:

  • 樂觀鎖:加一個 version 欄位,更新時 WHERE version = ?,失敗就重試(JPA 的 @Version)
  • 悲觀鎖但範圍極小:只在那一行關鍵操作用 SELECT ... FOR UPDATE
  • 把衝突移出資料庫:用分散式鎖或佇列讓衝突根本不會同時發生
💡 判斷順序:先確認你真的遇到了並行問題(不是想像的)→ 能不能用樂觀鎖解決 → 再考慮調隔離等級。調隔離等級是影響全域的決策,應該是最後一步而不是第一步。
四種隔離等級擋掉哪些異常
隔離等級髒讀不可重複讀幻讀誰的預設
READ UNCOMMITTED❌ 可能發生❌ 可能發生❌ 可能發生沒有人(幾乎不用)
READ COMMITTED✅ 擋掉❌ 可能發生❌ 可能發生PostgreSQL、Oracle、SQL Server
REPEATABLE READ✅ 擋掉✅ 擋掉快照讀擋掉,當前讀靠 Next-Key LockMySQL / InnoDB
SERIALIZABLE✅ 擋掉✅ 擋掉✅ 擋掉沒有人(並行度崩潰)
快照讀 vs 當前讀
面向快照讀(Snapshot Read)當前讀(Current Read)
哪些操作普通的 SELECTSELECT FOR UPDATE、LOCK IN SHARE MODE、UPDATE、DELETE、INSERT
讀到哪個版本Read View 允許看到的歷史版本永遠是最新版本(要改它,不能改在舊版本上)
加不加鎖完全不加鎖加鎖(Record / Gap / Next-Key)
會不會擋住別人不會會,這是死鎖的來源
MVCC 有沒有作用有,這就是 MVCC 的舞台沒有,靠鎖來保證正確性
RC 與 RR 對照:差別只在快照時機
面向READ COMMITTEDREPEATABLE READ
Read View 何時建立每一次 SELECT 都建新的第一次快照讀時建立,整個交易重用
同一交易兩次讀同一列可能不同(看得到別人剛提交的)保證相同
Gap Lock幾乎不用,鎖範圍小大量使用,鎖範圍大
死鎖機率較低較高(Gap Lock 互卡)
適合什麼情況高並發寫入、單次請求只讀一次的 Web 應用同一交易要多次讀取並保證一致的報表、對帳

練習題 點選選項查看解析

0 / 10
01 / 10
「不可重複讀」和「幻讀」的差別是什麼?
A 不可重複讀發生在 RC,幻讀發生在 RR
B 不可重複讀是同一列的內容變了(UPDATE),幻讀是範圍內的列數變了(INSERT/DELETE)
C 不可重複讀讀到未提交的資料,幻讀讀到已提交的資料
D 兩者是同一件事的不同說法
解析
不可重複讀針對 UPDATE——同一列前後讀到不同值;幻讀針對 INSERT/DELETE——同一個範圍前後讀到不同筆數。兩者要分開講,是因為解法不同:MVCC 能解決不可重複讀,但幻讀在當前讀時要靠 Next-Key Lock。
02 / 10
MVCC 的核心思想是什麼?
A 讀取時加共享鎖,寫入時加排他鎖
B 讀不加鎖,讓讀取看到資料的歷史版本,寫入照常進行不必等待
C 把所有交易排成一列依序執行
D 把資料複製多份到不同節點
解析
核心矛盾是「要一致性就要擋住別人,但擋住別人就沒有並行度」。MVCC 的解法是不去擋——讓寫入照常進行,而讀取看到修改前的舊版本。代價是資料庫必須保存同一列的多個歷史版本(undo log 版本鏈)。
03 / 10
MVCC 的「三個零件」是哪三個?
A redo log、binlog、undo log
B 共享鎖、排他鎖、意向鎖
C 隱藏欄位(DB_TRX_ID / DB_ROLL_PTR)、undo log 版本鏈、Read View
D Record Lock、Gap Lock、Next-Key Lock
解析
每列的隱藏欄位記錄「誰改的」與「上一版在哪」;undo log 把歷史版本串成鏈;Read View 決定「這個版本我能不能看」。三者配合才產生 MVCC 的效果。
04 / 10
RC 與 RR 在 MVCC 實作上的唯一差別是什麼?
A RC 用 undo log,RR 用 redo log
B Read View 建立的時機:RC 每次 SELECT 都建新的,RR 只在第一次快照讀建立並重用
C RC 不加鎖,RR 對所有讀取加鎖
D RC 只保存一個版本,RR 保存所有版本
解析
同一套機制、同一套可見性判斷規則,只換了快照建立時機就得到兩種隔離等級。RC 每次重新拍快照所以看得到別人剛提交的(不可重複讀);RR 快照凍結整個交易,所以前後一致。
05 / 10
在 REPEATABLE READ 下,Read View 是在什麼時候建立的?
A 執行 BEGIN 的那一刻
B 第一次執行快照讀(普通 SELECT)的時候
C 交易提交的時候
D 每次執行任何 SQL 的時候
解析
常見誤解是以為 BEGIN 當下就凍結。實際上是第一次快照讀才建立,所以 BEGIN 之後、第一次 SELECT 之前的期間,別人的修改仍然會被看到。要在 BEGIN 當下就凍結,得寫 START TRANSACTION WITH CONSISTENT SNAPSHOT。
06 / 10
下列哪一個操作屬於「當前讀」,不走 MVCC?
A SELECT * FROM users WHERE id = 1
B SELECT COUNT(*) FROM orders
C SELECT * FROM users WHERE id = 1 FOR UPDATE
D 以上皆是快照讀
解析
加了 FOR UPDATE(或 LOCK IN SHARE MODE)就是當前讀,UPDATE / DELETE / INSERT 也是。當前讀必須看到最新版本並加鎖——因為你要修改它,不可能改在一個歷史版本上。
07 / 10
Next-Key Lock 是什麼?它解決什麼問題?
A Record Lock + Gap Lock,鎖住列與它前面的空隙,防止當前讀時發生幻讀
B 鎖住下一個要讀取的資料頁,加速循序掃描
C 一種樂觀鎖,用版本號比對
D 鎖住整張表,等同於表鎖
解析
MVCC 只擋得住快照讀的幻讀。當前讀必須看最新資料,所以 InnoDB 用 Gap Lock 鎖住列與列之間的空隙,讓別人插不進來,筆數才保證不變。代價是鎖範圍變大、死鎖機率上升。
08 / 10
一個交易開了 30 分鐘沒提交,會對資料庫造成什麼影響?
A 沒有影響,只有那個交易自己慢
B 它的 Read View 一直活著,導致舊版本無法被清理,undo log 版本鏈變長、表空間持續膨脹
C 會鎖住整張表,其他人完全無法讀寫
D MySQL 會自動在 10 秒後回滾它
解析
只要有 Read View 可能需要看到某個舊版本,purge 就不能清理它。結果是版本鏈越來越長(其他人的讀取要走更多格)、undo 表空間持續膨脹且不會自己縮回。徵狀很不典型:沒有慢查詢、CPU 不高,但整體回應時間緩慢惡化、磁碟用量莫名上升。
09 / 10
下列哪一段程式碼最不應該放在 @Transactional 方法裡?
A orderRepo.save(order)
B paymentClient.charge(order) —— 呼叫第三方支付 API
C orderRepo.findById(orderId)
D 計算訂單金額的純邏輯運算
解析
判斷準則:如果一段程式碼的執行時間不是由你的資料庫決定的(網路、檔案 I/O、第三方 API),它就不該在交易裡。對方慢 30 秒,你的交易就開 30 秒。要在交易成功後才做的事,改用 @TransactionalEventListener(phase = AFTER_COMMIT)。
10 / 10
MySQL 預設 RR 而 PostgreSQL / Oracle 預設 RC,主要原因是什麼?
A RR 的效能比較好
B MySQL 早期用 statement-based binlog,RC 下主從複製可能不一致,需要 RR + Gap Lock 保證順序
C RR 是 SQL 標準規定的預設值
D InnoDB 的 MVCC 只支援 RR
解析
這個預設值是為了複製的正確性,不是為了應用程式好用。現代 MySQL 已預設 row-based binlog,這個理由不再成立。RR 的代價是 Gap Lock 多、鎖範圍大、死鎖機率高——高並發寫入的系統可以評估降到 RC。

點擊卡片翻面查看答案,共 14 張。

QUESTION
不可重複讀 vs 幻讀,一句話分辨?
點擊翻面
ANSWER
不可重複讀是「同一列變了」(UPDATE);幻讀是「多/少了一列」(INSERT/DELETE)。分開講是因為解法不同:MVCC 解決前者,後者在當前讀時要靠 Next-Key Lock。
點擊翻回
QUESTION
MVCC 一句話總結?
點擊翻面
ANSWER
讀不加鎖、寫不擋讀。代價是資料庫必須同時保存同一列的多個歷史版本(undo log 版本鏈)。
點擊翻回
QUESTION
為什麼不能靠加鎖解決所有隔離問題?
點擊翻面
ANSWER
正確但代價太大:讀寫互相阻塞,一個跑 30 秒的報表會卡住所有寫入。等於拿 99% 的正常情況去遷就 1% 的衝突。
點擊翻回
QUESTION
MVCC 的三個零件?
點擊翻面
ANSWER
① 隱藏欄位 DB_TRX_ID(誰改的)+ DB_ROLL_PTR(上一版在哪)② undo log 串成的版本鏈 ③ Read View(決定哪個版本可見)。
點擊翻回
QUESTION
Read View 的四個欄位?
點擊翻面
ANSWER
m_ids(活躍未提交的交易清單)、min_trx_id(m_ids 最小值)、max_trx_id(下一個要分配的 ID,不是目前最大)、creator_trx_id(自己的交易 ID)。
點擊翻回
QUESTION
可見性判斷的本質是哪一句話?
點擊翻面
ANSWER
「我只看得到——在我建立快照那一刻就已經提交完成的資料,加上我自己改的東西。」四段判斷規則都是在精確表達這句話。
點擊翻回
QUESTION
★ RC 與 RR 的唯一差別?
點擊翻面
ANSWER
Read View 建立的時機。RC 每次 SELECT 都建新的(所以看得到別人剛提交的 → 不可重複讀);RR 只在第一次快照讀建立並重用整個交易(所以前後一致)。
點擊翻回
QUESTION
RR 的快照是在 BEGIN 時建立的嗎?
點擊翻面
ANSWER
不是。是第一次執行快照讀(普通 SELECT)時才建立。要在 BEGIN 當下就凍結,得寫 START TRANSACTION WITH CONSISTENT SNAPSHOT。
點擊翻回
QUESTION
快照讀與當前讀怎麼分?
點擊翻面
ANSWER
普通 SELECT = 快照讀,走 MVCC、不加鎖。SELECT FOR UPDATE / LOCK IN SHARE MODE / UPDATE / DELETE / INSERT = 當前讀,讀最新版本並加鎖——因為要改它,不可能改在舊版本上。
點擊翻回
QUESTION
Next-Key Lock 是什麼組成的?
點擊翻面
ANSWER
Record Lock(鎖住存在的那一列)+ Gap Lock(鎖住列與列之間的空隙,防止別人插進來)。用來擋住當前讀的幻讀,代價是鎖範圍大、死鎖機率高。
點擊翻回
QUESTION
長事務為什麼會拖垮資料庫?徵狀是什麼?
點擊翻面
ANSWER
Read View 活著 → 舊版本不能 purge → 版本鏈變長、undo 表空間膨脹且不會縮回。徵狀很不典型:沒有慢查詢、CPU 不高,但回應時間緩慢惡化、磁碟用量莫名上升。診斷用 information_schema.innodb_trx 看 trx_started。
點擊翻回
QUESTION
什麼東西不該放在 @Transactional 裡?
點擊翻面
ANSWER
執行時間不是由你的資料庫決定的東西:第三方 API 呼叫、檔案 I/O、寄信、大批次迴圈。要在交易成功後才做的,用 @TransactionalEventListener(phase = AFTER_COMMIT)。
點擊翻回
QUESTION
為什麼 MySQL 預設 RR,別人預設 RC?
點擊翻面
ANSWER
歷史因素——早期 statement-based binlog 在 RC 下主從可能不一致,需要 RR + Gap Lock 保證順序。這是為了複製的正確性,不是為了應用程式好用。現代已預設 row-based binlog,理由不再成立。
點擊翻回
QUESTION
需要嚴格一致性時,比 SERIALIZABLE 更好的選擇?
點擊翻面
ANSWER
① 樂觀鎖:加 version 欄位,UPDATE ... WHERE version = ?,失敗重試(JPA 的 @Version)② 範圍極小的悲觀鎖 SELECT FOR UPDATE ③ 把衝突移出資料庫(分散式鎖、佇列)。調隔離等級是影響全域的決策,應該是最後一步。
點擊翻回