Atomicity · Consistency · Isolation · Durability
從事故現場出發,再回到資料庫與 Spring 交易實作
2026 — ACID Deep Dive
先看沒有 ACID 時,系統怎麼把錢、訂單與信任一起弄丟
把事故拆回 Atomicity / Consistency / Isolation / Durability 各自負責的保護
用 Before / After 與交易時間軸看懂 Race Condition、不可重複讀、幻讀
補上可動手驗證的練習:轉帳故障、雙終端機隔離等級、Crash Recovery
最後才進到 Spring Boot 程式碼:@Transactional、rollbackFor、AOP 規則
用除錯題收尾,讓學生自己找出交易管理中的坑
UPDATE account SET balance = balance - 300 WHERE id = 1;
業務感受:使用者看到「轉帳失敗」,但帳上少了 $300。客服、對帳、稽核三邊都會炸鍋。
Lucas = $1,000 | Emma = $500 | 總額 = $1,500
Lucas = $700 | Emma = $500 | 總額 = $1,200
畫面顯示失敗,但資料庫停在半成品狀態
少掉的不是 SQL,是信任。
小明讀到庫存 = 1
小華也讀到庫存 = 1
兩張訂單都成立,庫存卻只剩 0,看起來像沒超賣
Race Condition 不是偶發 UI bug,而是隔離不足造成的資料競態。
應用程式收到 COMMIT OK
主機立刻掉電,資料頁還沒刷回磁碟
重啟後查詢:剛剛那筆成功交易消失
如果 Commit 不能被信任,整個系統的 SLA、金流與審計都失去意義。
防止「扣款成功、入帳失敗」這種半完成狀態留在資料庫。
防止違反 FK、NOT NULL、商業規則的髒資料混進正式帳務。
防止併發交易互相踩踏,讓 Race Condition 變成可預期、可選擇的成本。
保證系統一旦回覆 Commit 成功,重開機後這筆資料還在。
接下來每個特性都會先回答一個問題:它到底擋住了哪一種事故?
Lucas $1,000 → 扣 $300 後剩 $700
Emma 本該 $500 → $800,但第 2 條 SQL 沒跑到
總額從 $1,500 變成 $1,200,$300 憑空消失
扣款後遇到例外,交易進入 rollback
Undo Log 把 Lucas 餘額從 $700 還原回 $1,000
Emma 維持 $500,總額仍是 $1,500
底層對應:InnoDB 會先記錄 Undo Log,必要時反向操作,把資料恢復到交易開始前。
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 版本的預設值可能不同,值得確認一次。
所以你在 Spring 寫 @Transactional 時,真正幫你做事的是 InnoDB;Spring 只是在對的時機呼叫 BEGIN / COMMIT / ROLLBACK。
@Transactional 是什麼?@Transactional 是 Spring 用來宣告「這段方法要放在同一筆資料庫交易裡」的註解。
它幫你畫出交易邊界:方法開始 = begin,方法正常結束 = commit,途中丟例外 = rollback。
扣款與入帳先被包成同一個工作單位
中間任何一步失敗,都不准留下半成品資料
只有全部成功,Spring 才會替你提交這筆交易
誰負責?
Spring Transaction Manager
保護什麼?
同方法內的多個 SQL 要一起成功 / 失敗
常見誤解
它不是魔法;邊界畫錯、例外處理錯,仍然會出事故
@Transactional,先觀察半完成資料真的會留下來。
account 表。
@Transactional
比較兩次執行後 Lucas / Emma 餘額差異,感受 rollback 的價值。
public void transfer(Long fromId, Long toId, BigDecimal amount) {
accountRepository.debit(fromId, amount);
if (true) {
throw new IllegalStateException("模擬中途故障");
}
accountRepository.credit(toId, amount);
}
預期結果:例外有拋出,但扣款已經寫進資料庫,這就是沒有 Atomicity 的世界。
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 的意思不是「不會出錯」,而是「錯的資料進不去」。
場景:兩個交易同時修改同一個帳戶,正確答案應該是 $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。後寫入的人把前一個人的結果蓋掉了。
時間
T1
T2
t1
SELECT balance → 1000
—
t2
同一交易內等待
UPDATE balance = 1200; COMMIT;
t3
再次 SELECT → 1200
同一列、同一條查詢,結果變了
時間
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 壓低幻讀風險。
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM account WHERE id = 1;
-- 先不要 commit,保持交易開著
SELECT balance FROM account WHERE id = 1;
COMMIT;
UPDATE account
SET balance = balance + 200
WHERE id = 1;
COMMIT;
-- 再回到 Terminal A 重跑第二次 SELECT
觀察 1:READ COMMITTED 下,Terminal A 兩次 SELECT 可能看到不同餘額。
觀察 2:改成 REPEATABLE READ 再做一次,比較第二次 SELECT 是否維持同一個 snapshot。
BEGIN → UPDATE account SET balance = 700 WHERE id = 1
執行 COMMIT,等應用收到成功回應
立刻模擬斷電 / Crash,再重啟 MySQL
重新查詢同一筆資料,確認 700 還在不在
一句話記:Commit 成功 = 可以從 log 恢復,不是剛好來得及把每個 data page 都刷盤。
@Transactional
@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);
}
rollbackFor = Exception.class,不然預設不會回滾。rollbackFor = Exception.class?
@Transactional
public void transfer(...) throws Exception {
accountRepository.debit(fromId, amount);
throw new Exception("核心銀行回傳 checked exception");
}
事故結果:畫面有 exception,但因為它是 checked exception,Spring 預設可能照樣 commit 扣款。
@Transactional(rollbackFor = Exception.class)
public void transfer(...) throws Exception {
accountRepository.debit(fromId, amount);
throw new Exception("核心銀行回傳 checked exception");
}
修正後:同樣拋出例外,但 Lucas 仍維持 $1,000,不會留下「系統說失敗、資料卻已提交」的生產事故。
namedMap.put("save*", requiredAttribute);
namedMap.put("update*", requiredAttribute);
namedMap.put("find*", readOnlyAttribute);
namedMap.put("get*", readOnlyAttribute);
requiredAttribute.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
requiredAttribute.setRollbackRules(List.of(new RollbackRuleAttribute(RuntimeException.class)));
readOnlyAttribute.setReadOnly(true);
attributeSource.setNameMap(namedMap);
return new TransactionInterceptor(transactionManager, attributeSource);
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 的哪一個環節?