資料庫 ACID 原理介紹

Atomicity · Consistency · Isolation · Durability

從事故現場出發,再回到資料庫與 Spring 交易實作

2026 — ACID Deep Dive

這次的學習路線

1

先看沒有 ACID 時,系統怎麼把錢、訂單與信任一起弄丟

2

把事故拆回 Atomicity / Consistency / Isolation / Durability 各自負責的保護

3

用 Before / After 與交易時間軸看懂 Race Condition、不可重複讀、幻讀

4

補上可動手驗證的練習:轉帳故障、雙終端機隔離等級、Crash Recovery

5

最後才進到 Spring Boot 程式碼:@Transactional、rollbackFor、AOP 規則

6

用除錯題收尾,讓學生自己找出交易管理中的坑

如果沒有 ACID:轉帳成功了一半

1
Lucas 轉帳 $300 給 Emma 初始餘額:Lucas = $1,000,Emma = $500,總額 = $1,500。
2
第 1 條 SQL:扣款成功 UPDATE account SET balance = balance - 300 WHERE id = 1;
3
第 2 條 SQL 前,服務噴錯 / JVM 掛掉 收款方還沒入帳,但扣款已經留在資料庫裡。

業務感受:使用者看到「轉帳失敗」,但帳上少了 $300。客服、對帳、稽核三邊都會炸鍋。

餘額現場

Before

Lucas = $1,000 | Emma = $500 | 總額 = $1,500

扣款後

Lucas = $700 | Emma = $500 | 總額 = $1,200

異常後

畫面顯示失敗,但資料庫停在半成品狀態

少掉的不是 SQL,是信任。

沒有 Isolation / Durability:超賣與「Commit 後消失」

超賣:兩個人搶最後 1 張票

T1

小明讀到庫存 = 1

T2

小華也讀到庫存 = 1

結果

兩張訂單都成立,庫存卻只剩 0,看起來像沒超賣

Race Condition 不是偶發 UI bug,而是隔離不足造成的資料競態。

斷電:明明 Commit 了,重開機卻不見

Step 1

應用程式收到 COMMIT OK

Step 2

主機立刻掉電,資料頁還沒刷回磁碟

Step 3

重啟後查詢:剛剛那筆成功交易消失

如果 Commit 不能被信任,整個系統的 SLA、金流與審計都失去意義。

所以 ACID 不是背縮寫,是四道事故防線

Atomicity

防止「扣款成功、入帳失敗」這種半完成狀態留在資料庫。

Consistency

防止違反 FK、NOT NULL、商業規則的髒資料混進正式帳務。

Isolation

防止併發交易互相踩踏,讓 Race Condition 變成可預期、可選擇的成本。

Durability

保證系統一旦回覆 Commit 成功,重開機後這筆資料還在。

接下來每個特性都會先回答一個問題:它到底擋住了哪一種事故?

ACID 四大特性,回到使用者會遇到的問題

A Atomicity 原子性 交易要嘛全部成功,要嘛全部失敗。它解決的是「資料停在中間狀態」的事故。 ALL OR NOTHING
C Consistency 一致性 交易前後都要滿足 schema 與業務規則,資料庫不能接受不可能存在的狀態。 VALID STATE
I Isolation 隔離性 多筆交易同時跑時,也要像各自在自己的世界裡執行,彼此看不到不該看到的中間值。 SAFE CONCURRENCY
D Durability 持久性 Commit 成功後,資料要能穿越斷電、重啟與 Crash Recovery,不能只活在記憶體裡。 SURVIVES CRASH

Atomicity:同一筆轉帳,Before / After 差在哪?

Before:沒有交易邊界

1

Lucas $1,000 → 扣 $300 後剩 $700

2

Emma 本該 $500 → $800,但第 2 條 SQL 沒跑到

3

總額從 $1,500 變成 $1,200,$300 憑空消失

After:扣款失敗就整筆回滾

1

扣款後遇到例外,交易進入 rollback

2

Undo Log 把 Lucas 餘額從 $700 還原回 $1,000

3

Emma 維持 $500,總額仍是 $1,500

底層對應:InnoDB 會先記錄 Undo Log,必要時反向操作,把資料恢復到交易開始前。

UNDO LOG — InnoDB 的回溯路徑 ① SQL 執行 balance 1000 → 700 ② Undo Log 先寫入 舊值 balance = 1000 ③ 例外 / Crash 交易中斷,觸發 rollback ④ Rollback 讀 Undo Log 依序執行反向 SQL ⑤ 資料還原完成 balance 回到 1000 ✓

你用的 MySQL,底層其實是 InnoDB 在跑

MySQL 不是只有一種儲存方式 — 它支援可抽換的 Storage Engine,而大多數人根本沒注意到自己在用哪一個。

InnoDB 預設引擎 ≥ MySQL 5.5

支援 Transaction、外鍵、MVCC、Crash Recovery — ACID 四件事它全包。

MyISAM

速度快、全文搜尋強,但不支援 Transaction,沒有 ACID 保護。

Memory / CSV / Archive…

各有特殊用途,同樣沒有完整的 ACID 支援。

確認你的引擎


-- 看單一資料表用哪個引擎
SHOW TABLE STATUS
  WHERE Name = 'account'\G

-- 建表時指定(通常不需要,預設就是 InnoDB)
CREATE TABLE account (
  id   BIGINT PRIMARY KEY,
  ...
) ENGINE = InnoDB;
                            

舊專案 migration 或手動建表時,若沒指定引擎,不同 MySQL 版本的預設值可能不同,值得確認一次。

InnoDB 怎麼把 ACID 四件事都做進去的?

A — Atomicity Undo Log 每次寫入前先記舊值到 Undo Log。交易失敗時,InnoDB 依序反向執行,把資料還原回原始狀態。 ALL OR NOTHING
D — Durability Redo Log(WAL) Commit 前先把變更寫進 Redo Log(Write-Ahead Log)。斷電重啟後,InnoDB 重播 Redo Log,確保已 Commit 的資料不丟失。 SURVIVES CRASH
I — Isolation MVCC + Locking 透過多版本並行控制(MVCC)讓讀不擋寫,搭配行鎖(Row Lock)管理寫衝突,預設隔離等級為 REPEATABLE READ。 SAFE CONCURRENCY
C — Consistency 約束 + 前兩者合力 外鍵(FK)、CHECK、NOT NULL 由引擎層強制執行;Atomicity 與 Isolation 再確保交易前後都不會留下不合法的中間狀態。 VALID STATE

所以你在 Spring 寫 @Transactional 時,真正幫你做事的是 InnoDB;Spring 只是在對的時機呼叫 BEGIN / COMMIT / ROLLBACK

@Transactional 是什麼?

一句話理解

@Transactional 是 Spring 用來宣告「這段方法要放在同一筆資料庫交易裡」的註解。

它幫你畫出交易邊界:方法開始 = begin,方法正常結束 = commit,途中丟例外 = rollback。

套到轉帳場景

BEGIN

扣款與入帳先被包成同一個工作單位

RUN

中間任何一步失敗,都不准留下半成品資料

END

只有全部成功,Spring 才會替你提交這筆交易

誰負責?
Spring Transaction Manager

保護什麼?
同方法內的多個 SQL 要一起成功 / 失敗

常見誤解
它不是魔法;邊界畫錯、例外處理錯,仍然會出事故

Atomicity 練習:先故意不加 Transaction

1
先跑錯的版本 不要加 @Transactional,先觀察半完成資料真的會留下來。
2
在扣款後故意丟例外 執行到一半中斷,立刻去查 account 表。
3
再補上 @Transactional 比較兩次執行後 Lucas / Emma 餘額差異,感受 rollback 的價值。

反例:沒有 Transaction 的轉帳

public void transfer(Long fromId, Long toId, BigDecimal amount) {
    accountRepository.debit(fromId, amount);

    if (true) {
        throw new IllegalStateException("模擬中途故障");
    }

    accountRepository.credit(toId, amount);
}
                        

預期結果:例外有拋出,但扣款已經寫進資料庫,這就是沒有 Atomicity 的世界。

Consistency:資料庫會直接拒絕不合理的狀態

先定規則


CREATE TABLE transfer_log (
  id BIGINT PRIMARY KEY,
  from_account_id BIGINT NOT NULL,
  to_account_id BIGINT NOT NULL,
  amount DECIMAL(12,2) NOT NULL,
  CONSTRAINT fk_to_account
    FOREIGN KEY (to_account_id) REFERENCES account(id)
);
                        

NOT NULL 與 FOREIGN KEY 不是裝飾,它們是最後一道機械式防呆。

實際錯誤畫面 / 情境


INSERT INTO transfer_log (id, from_account_id, to_account_id, amount)
VALUES (9001, 1, NULL, 300.00);
-- ERROR 1048 (23000): Column 'to_account_id' cannot be null

INSERT INTO transfer_log (id, from_account_id, to_account_id, amount)
VALUES (9002, 1, 9999, 300.00);
-- ERROR 1452 (23000): Cannot add or update a child row:
-- a foreign key constraint fails
                        

Consistency 的意思不是「不會出錯」,而是「錯的資料進不去」。

Isolation:Race Condition / Lost Update 用時間軸看最清楚

場景:兩個交易同時修改同一個帳戶,正確答案應該是 $1,000 - $100 - $50 = $850。

時間

Transaction A

Transaction B

t1

讀到 balance = 1000

t2

A 準備扣 100,暫存新值 900

讀到 balance = 1000

t3

UPDATE balance = 900 並提交

B 仍拿著舊值 1000

t4

A 完成

UPDATE balance = 950 並提交

結果:最後餘額變成 $950,不是 $850。後寫入的人把前一個人的結果蓋掉了。

t₁ t₂ t₃ t₄ T A 扣 $100 T B 扣 $50 READ=1000 WRITE=900 COMMIT READ=1000 ⚠ 讀到舊值 WRITE=950 COMMIT 覆蓋! 950 ≠ 850 ❌

不可重複讀 vs 幻讀:差別在「同一列變了」還是「結果集合變了」

不可重複讀 Non-repeatable Read

時間

T1

T2

t1

SELECT balance → 1000

t2

同一交易內等待

UPDATE balance = 1200; COMMIT;

t3

再次 SELECT → 1200

同一列、同一條查詢,結果變了

幻讀 Phantom Read

時間

T1

T2

t1

SELECT COUNT(*) WHERE amount > 1000 → 3

t2

同一交易內等待

INSERT 新轉帳 5000; COMMIT;

t3

再次查詢 → 4

不是某一列變了,是整個結果集合多出一筆

READ COMMITTED 常見不可重複讀;MySQL InnoDB 的 REPEATABLE READ 會靠 snapshot + next-key lock 壓低幻讀風險。

不可重複讀:同一列值被修改 id=1 balance=1000 T1 第一次讀 T2 UPDATE id=1 balance=1200 T1 再讀,值變了! 同一列(id=1)的值被另一筆交易修改 → 重新讀出現不同結果 幻讀:結果集合行數改變 3 筆 > 1000 T1 COUNT = 3 T2 INSERT 4 筆 > 1000 T1 COUNT = 4 ! 幻影列 不是現有列被修改,是 INSERT 讓查詢結果憑空多出新的列

Isolation 練習:開兩個 MySQL 終端機親手觀察

Terminal A


SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM account WHERE id = 1;
-- 先不要 commit,保持交易開著
SELECT balance FROM account WHERE id = 1;
COMMIT;
                        

Terminal B


UPDATE account
SET balance = balance + 200
WHERE id = 1;
COMMIT;

-- 再回到 Terminal A 重跑第二次 SELECT
                        

觀察 1:READ COMMITTED 下,Terminal A 兩次 SELECT 可能看到不同餘額。

觀察 2:改成 REPEATABLE READ 再做一次,比較第二次 SELECT 是否維持同一個 snapshot。

Durability:Commit 後關機,重開機資料還在嗎?

實驗設計

1

BEGIN → UPDATE account SET balance = 700 WHERE id = 1

2

執行 COMMIT,等應用收到成功回應

3

立刻模擬斷電 / Crash,再重啟 MySQL

4

重新查詢同一筆資料,確認 700 還在不在

Before / After 對比

X
如果沒有 WAL / Redo LogCommit 回覆太早,資料頁還沒刷回磁碟,重開後可能回到舊值。
InnoDB 的承諾先把變更寫進 Redo Log,再回覆 Commit;若中途 Crash,重啟時 replay log 把資料補回來。

一句話記:Commit 成功 = 可以從 log 恢復,不是剛好來得及把每個 data page 都刷盤。

正常路徑 Crash 恢復 SQL 操作 Redo Log 先寫入磁碟 WAL:log before data page Buffer Pool 更新 資料頁暫留記憶體 COMMIT OK ✓ 系統 Crash 重啟讀 Redo Log Replay 操作 資料完整恢復 ✓ 同一份 Log

程式碼進場:用教學模式拆解 @Transactional

TransactionService.java


@Transactional(rollbackFor = Exception.class)
public void transfer(Long fromId, Long toId, BigDecimal amount) throws Exception {
    accountRepository.debit(fromId, amount);
    auditRepository.save(new TransferAudit(fromId, toId, amount));
    if (riskService.shouldFail(fromId, toId)) {
        throw new Exception("simulate checked exception");
    }
    accountRepository.credit(toId, amount);
}
                        
1
第 1 行:畫出交易邊界整個方法在同一個 transaction 內,要嘛一起成功,要嘛一起 rollback。
2
把扣款與稽核紀錄放在同一單位不要讓 audit 先成功、主交易卻失敗,否則對帳時會看到幽靈紀錄。
3
這就是為什麼要寫 rollbackFor = Exception.class,不然預設不會回滾。
4
只有前面都成功,才真正入帳交易在方法正常結束後提交,才算完成這筆轉帳。

為什麼要寫 rollbackFor = Exception.class

預設寫法:事故版


@Transactional
public void transfer(...) throws Exception {
    accountRepository.debit(fromId, amount);
    throw new Exception("核心銀行回傳 checked exception");
}
                        

事故結果:畫面有 exception,但因為它是 checked exception,Spring 預設可能照樣 commit 扣款。

修正版:明確把 checked exception 也回滾


@Transactional(rollbackFor = Exception.class)
public void transfer(...) throws Exception {
    accountRepository.debit(fromId, amount);
    throw new Exception("核心銀行回傳 checked exception");
}
                        

修正後:同樣拋出例外,但 Lucas 仍維持 $1,000,不會留下「系統說失敗、資料卻已提交」的生產事故。

複雜設定別一次貼完:AOP 交易管理拆成 3 步

1
Step 1:先講命名規則,學生才知道誰會吃到交易

namedMap.put("save*", requiredAttribute);
namedMap.put("update*", requiredAttribute);
namedMap.put("find*", readOnlyAttribute);
namedMap.put("get*", readOnlyAttribute);
                            
2
Step 2:再補交易屬性,說清楚 propagation / rollback / readOnly

requiredAttribute.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
requiredAttribute.setRollbackRules(List.of(new RollbackRuleAttribute(RuntimeException.class)));
readOnlyAttribute.setReadOnly(true);
                            
3
Step 3:最後才注入結果,讓大家理解代理物件是怎麼套用規則的

attributeSource.setNameMap(namedMap);
return new TransactionInterceptor(transactionManager, attributeSource);
                            

找坑練習:這段交易程式碼哪裡會出事?

Debug Quiz


public void processBatch(List<TransferCmd> cmds) {
    for (TransferCmd cmd : cmds) {
        try {
            this.saveTransfer(cmd);              // [1]
        } catch (Exception e) {                 // [2]
            log.warn("ignore", e);
        }
    }
}

@Transactional
public void saveTransfer(TransferCmd cmd) {
    accountRepo.lockById(cmd.fromId());        // [3]
    accountRepo.lockById(cmd.toId());
    notificationClient.send(cmd.userId());     // [4]
    // debit + credit ...
}
                        

[1] self-invocation:this.saveTransfer() 不會經過 Spring proxy,@Transactional 可能直接失效。

[2] 吞掉例外:學生常以為「有 log 就好」,但交易是否回滾、上層是否知道失敗,會一起變得不透明。

[3] 鎖順序依輸入而變:A→B 與 B→A 可能互相等待,導致 deadlock。

[4] 長交易:把遠端通知放在交易內,會占住連線、拉長鎖持有時間,放大效能與死鎖風險。

ACID 不是縮寫題,是事故防線

先讓學生感受到痛,再讓他們看到資料庫與框架如何把痛點收斂成可驗證的保護。

Atomicity 防半成功 Consistency 擋髒資料 Isolation 管併發 Durability 保 commit

收尾提問:如果今天線上出現「錢少了、訂單重複了、重啟後資料不見了」三種事故,你會先懷疑 ACID 的哪一個環節?