上一章講的是「資料怎麼被找到」,這一章講「很多人同時讀寫同一筆資料時,會發生什麼事」。
先看一個真實會發生的場景:
使用者 A 查詢帳戶餘額 → 顯示 1000 元
(同一秒,使用者 B 轉走 300 元並提交)
使用者 A 在同一個交易裡再查一次 → 顯示 700 元
同一個交易裡,同一句 SQL,讀到兩個不同的答案。這叫不可重複讀。它是不是 bug,取決於你的隔離等級設定——在某些等級這是預期行為,在某些等級不允許發生。
問題是:要讓 A 兩次都讀到 1000,最直覺的做法是「A 在讀的時候不准 B 改」,也就是加鎖。但如果每次讀都要鎖,整個資料庫就退化成單執行緒了。
MVCC(多版本並行控制)就是為了解決這個矛盾而生的:讓讀取完全不加鎖,卻依然看得到一致的資料。它的做法是——每一列資料同時存在很多個「歷史版本」,不同的交易看到不同的版本。
這一章要拆的東西:
@Transactional 包錯範圍就能讓表空間爆掉。前置知識:這章預設你已經知道索引與聚簇索引的結構(ch01),因為 undo log 的版本鏈是掛在聚簇索引的資料列上的。對「交易、ACID」還完全陌生的話,先看 IT 基礎知識 ch07 資料庫基礎。
ACID 四個字母裡,A(原子性)、D(持久性)靠 redo/undo log 就能解決,C(一致性)是結果。真正難的是 I —— Isolation(隔離性),因為它要處理的是「很多人同時動同一筆資料」。
教科書會給你三個名詞的定義,但定義背了也用不出來。直接看它們長什麼樣:
交易 A:UPDATE account SET balance = 700 WHERE id = 1; (還沒 COMMIT)
交易 B:SELECT balance FROM account WHERE id = 1; → 讀到 700
交易 A:ROLLBACK; ← 回滾了!
結果:B 拿到一個「從來沒有真正存在過」的值。交易 A:SELECT balance WHERE id = 1; → 1000
交易 B:UPDATE balance = 700 WHERE id = 1; COMMIT;
交易 A:SELECT balance WHERE id = 1; → 700 ← 同一個交易裡變了針對的是 UPDATE:同一列的內容變了。
交易 A:SELECT COUNT(*) WHERE age > 20; → 10 筆
交易 B:INSERT INTO users (age) VALUES (25); COMMIT;
交易 A:SELECT COUNT(*) WHERE age > 20; → 11 筆 ← 多出一筆「幻影」針對的是 INSERT / DELETE:列數變了。
最直覺的隔離做法是加鎖:我在讀的時候,不准你改。
交易 A:SELECT ... (加讀鎖,鎖住整個交易期間)
交易 B:UPDATE ... (被擋住,等 A 提交)這確實正確,而且就是 SERIALIZABLE 的做法。問題在代價。
一個報表查詢跑 30 秒,這 30 秒內所有想改到那些列的交易全部卡住。在一個讀多寫多的系統裡,吞吐量會直接崩掉。
兩個人同時「讀」同一筆資料,彼此完全沒有影響。但為了防範「可能有人來改」,就讓所有讀取都付出加鎖的成本——這是拿 99% 的正常情況去遷就 1% 的衝突。
與其阻止 B 修改,不如讓 A 繼續看到修改前的那個版本。既然 A 想要的是「整個交易期間看到一致的資料」,那給它一份「交易開始那一刻的快照」就好了。
接下來三張卡就是在講:這些「歷史版本」存在哪裡、怎麼判斷該給你看哪一個。
MVCC 不是一個演算法,是三個零件配合出來的效果。
DB_TRX_ID(6 bytes):最後一次修改這一列的交易 ID。交易 ID 是遞增的,所以它同時代表「新舊」。DB_ROLL_PTR(7 bytes):回滾指標,指向 undo log 裡的上一個版本。(上一章提過的 row_id 是第三個隱藏欄位,只有在完全沒主鍵時才出現。)
每次 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當你執行一個快照讀(普通的 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)
↓
★ 回傳這個版本的資料前面鋪了那麼多零件,就是為了這一句:
交易 A 開始
SELECT ... → 建立 Read View #1 → 讀到 1000
(交易 B 改成 700 並提交)
SELECT ... → 建立 Read View #2 → 讀到 700 ← 值變了因為每次都重新拍快照,所以看得到別人剛提交的東西——這正是「不可重複讀」的成因,也正是「已提交讀」這個名字的意思。
交易 A 開始
SELECT ... → 建立 Read View(就這一次) → 讀到 1000
(交易 B 改成 700 並提交)
SELECT ... → 沿用同一個 Read View → 還是 1000 ← 值不變快照凍結在第一次讀取的那一刻,所以整個交易期間看到的世界是一致的。
很多人以為 RR 的快照是「交易 BEGIN 的那一刻」建立的。不是——是第一次執行快照讀的時候才建立。所以:
BEGIN;
-- (這裡什麼都還沒讀,別人怎麼改都無所謂)
SELECT ... ← 快照在這一刻才凍結如果需要在 BEGIN 當下就凍結,要明確寫 START TRANSACTION WITH CONSISTENT SNAPSHOT。
MVCC 只作用在快照讀上。有一類操作它管不到,叫當前讀。
SELECT。走 MVCC,讀歷史版本,不加鎖。SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT。必須讀最新版本並加鎖——因為你要改它,不可能改在一個舊版本上。UPDATE),就會看到最新狀態,於是「幻影」出現了。當前讀時,InnoDB 鎖的不只是那一列,還包含列與列之間的空隙:
索引上現有值:10, 20, 30
SELECT * FROM t WHERE id BETWEEN 15 AND 25 FOR UPDATE;
→ 鎖住 20 這一列,也鎖住 (10,20) 與 (20,30) 這兩個空隙
→ 別人想 INSERT id = 18 或 22 都會被擋住
所以再查一次,筆數保證不變 → 幻讀被擋掉了Deadlock found when trying to get lock,而且 SQL 看起來完全沒有共用的列。SHOW ENGINE INNODB STATUS 的 LATEST DETECTED DEADLOCK 區塊,看兩邊各自等的是什麼鎖。這是實務上 MVCC 最常見、也最容易被誤診的災難。
ibdata1 或 undo tablespace)持續膨脹,而且不會自己縮回去。@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;@TransactionalEventListener(phase = AFTER_COMMIT)。@Transactional(timeout = 5) 當作保險,讓失控的交易自己死掉而不是拖著大家。一個常被忽略的事實:MySQL 預設 REPEATABLE READ,但 PostgreSQL、Oracle、SQL Server 都預設 READ COMMITTED。連資料庫廠商都沒有共識,代表這是取捨而不是對錯。
早期 MySQL 的主從複製用 statement-based binlog(把 SQL 語句複製過去執行)。在 RC 下,主從兩邊執行同一批語句可能得到不同結果,會導致資料不一致。RR 加上 Gap Lock 才能保證順序一致。換句話說,這個預設值是為了複製的正確性,不是為了應用程式好用。
幾乎不需要。它會把所有普通 SELECT 變成加鎖讀,並行度崩潰。真的需要嚴格一致性時,比較好的做法是:
version 欄位,更新時 WHERE version = ?,失敗就重試(JPA 的 @Version)SELECT ... FOR UPDATE| 隔離等級 | 髒讀 | 不可重複讀 | 幻讀 | 誰的預設 |
|---|---|---|---|---|
| READ UNCOMMITTED | ❌ 可能發生 | ❌ 可能發生 | ❌ 可能發生 | 沒有人(幾乎不用) |
| READ COMMITTED | ✅ 擋掉 | ❌ 可能發生 | ❌ 可能發生 | PostgreSQL、Oracle、SQL Server |
| REPEATABLE READ | ✅ 擋掉 | ✅ 擋掉 | 快照讀擋掉,當前讀靠 Next-Key Lock | MySQL / InnoDB |
| SERIALIZABLE | ✅ 擋掉 | ✅ 擋掉 | ✅ 擋掉 | 沒有人(並行度崩潰) |
| 面向 | 快照讀(Snapshot Read) | 當前讀(Current Read) |
|---|---|---|
| 哪些操作 | 普通的 SELECT | SELECT FOR UPDATE、LOCK IN SHARE MODE、UPDATE、DELETE、INSERT |
| 讀到哪個版本 | Read View 允許看到的歷史版本 | 永遠是最新版本(要改它,不能改在舊版本上) |
| 加不加鎖 | 完全不加鎖 | 加鎖(Record / Gap / Next-Key) |
| 會不會擋住別人 | 不會 | 會,這是死鎖的來源 |
| MVCC 有沒有作用 | 有,這就是 MVCC 的舞台 | 沒有,靠鎖來保證正確性 |
| 面向 | READ COMMITTED | REPEATABLE READ |
|---|---|---|
| Read View 何時建立 | 每一次 SELECT 都建新的 | 第一次快照讀時建立,整個交易重用 |
| 同一交易兩次讀同一列 | 可能不同(看得到別人剛提交的) | 保證相同 |
| Gap Lock | 幾乎不用,鎖範圍小 | 大量使用,鎖範圍大 |
| 死鎖機率 | 較低 | 較高(Gap Lock 互卡) |
| 適合什麼情況 | 高並發寫入、單次請求只讀一次的 Web 應用 | 同一交易要多次讀取並保證一致的報表、對帳 |
點擊卡片翻面查看答案,共 14 張。