交易與 ACID 原則
交易(Transaction)是資料庫保證資料一致性的核心機制。本篇將深入解析 ACID 原則,並介紹 PostgreSQL 的隔離等級。
一、 什麼是交易?
交易是一組 SQL 操作的邏輯單元,要麼全部成功,要麼全部失敗。
經典範例:轉帳
sql
-- A 帳戶轉 1000 元給 B
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE user_id = 'A';
UPDATE accounts SET balance = balance + 1000 WHERE user_id = 'B';
COMMIT;如果第二個 UPDATE 失敗,第一個也必須回滾。否則錢就「消失」了。
基本語法
sql
BEGIN; -- 或 START TRANSACTION
-- SQL 操作...
COMMIT; -- 成功,永久儲存
BEGIN;
-- SQL 操作...
ROLLBACK; -- 失敗,全部撤銷二、 ACID 原則
ACID 是交易必須滿足的四個特性:
A - Atomicity(原子性)
交易內的操作要麼全部執行,要麼全部不執行。
sql
BEGIN;
INSERT INTO orders (id, total) VALUES (1, 1000);
INSERT INTO order_items (order_id, product) VALUES (1, 'iPhone');
-- 如果第二個 INSERT 失敗...
ROLLBACK; -- 第一個 INSERT 也會撤銷C - Consistency(一致性)
交易完成後,資料庫必須從一個一致狀態轉換到另一個一致狀態。
sql
-- 約束確保一致性
ALTER TABLE accounts ADD CONSTRAINT positive_balance CHECK (balance >= 0);
BEGIN;
UPDATE accounts SET balance = balance - 1000 WHERE user_id = 'A';
-- 如果這導致 balance < 0,交易會失敗
COMMIT;I - Isolation(隔離性)
並行交易之間互不干擾。
sql
-- Session 1
BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 1;
-- 尚未 COMMIT
-- Session 2(同時執行)
SELECT stock FROM products WHERE id = 1;
-- 看到的是舊值還是新值?取決於隔離等級D - Durability(持久性)
交易 COMMIT 後,資料永久保存,即使系統崩潰也不會丟失。
PostgreSQL 透過 WAL(Write-Ahead Log)確保持久性。
三、 隔離等級
PostgreSQL 支援三種隔離等級(SQL 標準定義四種):
| 隔離等級 | 髒讀 | 不可重複讀 | 幻讀 |
|---|---|---|---|
| Read Uncommitted | ❌ | ⚠️ | ⚠️ |
| Read Committed | ❌ | ⚠️ | ⚠️ |
| Repeatable Read | ❌ | ❌ | ⚠️ |
| Serializable | ❌ | ❌ | ❌ |
TIP
PostgreSQL 的 Read Uncommitted 實際上等同於 Read Committed(不會真的允許髒讀)。
設定隔離等級
sql
-- 設定當前交易
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 或
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 設定 Session 預設
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ;四、 隔離問題詳解
髒讀 (Dirty Read)
讀取到其他交易尚未 COMMIT 的資料。
sql
-- Session 1
BEGIN;
UPDATE accounts SET balance = 0 WHERE user_id = 'A';
-- 尚未 COMMIT
-- Session 2
SELECT balance FROM accounts WHERE user_id = 'A';
-- 髒讀:看到 0
-- 如果 Session 1 ROLLBACK,這個 0 是無效的
-- PostgreSQL 不允許髒讀,Session 2 看到的是修改前的值不可重複讀 (Non-Repeatable Read)
同一交易內,兩次讀取同一資料結果不同。
sql
-- Session 1 (Read Committed)
BEGIN;
SELECT balance FROM accounts WHERE user_id = 'A'; -- 結果:1000
-- Session 2
UPDATE accounts SET balance = 500 WHERE user_id = 'A';
COMMIT;
-- Session 1(繼續)
SELECT balance FROM accounts WHERE user_id = 'A'; -- 結果:500(不同了!)
COMMIT;幻讀 (Phantom Read)
同一交易內,兩次查詢結果的「列數」不同。
sql
-- Session 1 (Repeatable Read)
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- 結果:10
-- Session 2
INSERT INTO orders (status) VALUES ('pending');
COMMIT;
-- Session 1(繼續)
SELECT COUNT(*) FROM orders WHERE status = 'pending'; -- Repeatable Read 下仍是 10
-- Read Committed 下變成 11
COMMIT;五、 Read Committed(預設)
每個 SELECT 都會看到該瞬間已 COMMIT 的資料。
sql
-- 設定(預設就是這個)
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;特點:
- 每個 SELECT 建立新的快照
- 可能遇到不可重複讀
- 效能最好
- 適合:大多數 OLTP 場景
六、 Repeatable Read
整個交易看到的是開始時的快照。
sql
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM accounts WHERE user_id = 'A'; -- balance = 1000
-- 即使其他交易修改了 A 的 balance 並 COMMIT
-- 這裡看到的仍然是 1000
COMMIT;特點:
- 整個交易共用一個快照
- 不會遇到不可重複讀
- 可能遇到序列化失敗
- 適合:報表、資料匯出
序列化失敗
sql
-- Session 1
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A';
-- Session 2(同時)
BEGIN ISOLATION LEVEL REPEATABLE READ;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 'A';
COMMIT;
-- Session 1
COMMIT; -- 錯誤:could not serialize access due to concurrent update處理方式:捕捉錯誤並重試。
javascript
async function transferWithRetry() {
for (let i = 0; i < 3; i++) {
try {
await db.query("BEGIN ISOLATION LEVEL REPEATABLE READ");
await db.query(
"UPDATE accounts SET balance = balance - 100 WHERE user_id = $1",
["A"]
);
await db.query("COMMIT");
return;
} catch (e) {
await db.query("ROLLBACK");
if (e.code !== "40001") throw e; // 40001 = serialization_failure
}
}
throw new Error("Max retries exceeded");
}七、 Serializable
最嚴格的隔離等級,保證交易串行執行的效果。
sql
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 操作...
COMMIT;特點:
- 完全避免所有並行問題
- 序列化失敗機率更高
- 效能開銷較大
- 適合:金融交易、需要完美一致性的場景
八、 SAVEPOINT
在交易內設置「存檔點」,可以部分回滾。
sql
BEGIN;
INSERT INTO orders (id) VALUES (1);
SAVEPOINT sp1;
INSERT INTO order_items (order_id, product) VALUES (1, 'A');
SAVEPOINT sp2;
INSERT INTO order_items (order_id, product) VALUES (1, 'B');
-- 發現錯誤,只回滾到 sp2
ROLLBACK TO SAVEPOINT sp2;
-- 繼續其他操作
INSERT INTO order_items (order_id, product) VALUES (1, 'C');
COMMIT; -- order 1 和 item A, C 被保存九、 鎖等待與超時
sql
-- 設定鎖等待超時
SET lock_timeout = '5s';
BEGIN;
UPDATE accounts SET balance = 0 WHERE user_id = 'A';
-- 如果 5 秒內拿不到鎖,報錯
COMMIT;查看鎖等待
sql
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_locks blocking_locks
ON blocking_locks.locktype = blocked_locks.locktype
AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;十、 最佳實踐
1. 保持交易簡短
sql
-- 不好:交易內做大量處理
BEGIN;
-- 複雜計算...
-- API 呼叫...
-- 更多處理...
COMMIT;
-- 好:只在必要時開啟交易
-- 先做計算
-- 然後
BEGIN;
INSERT INTO results ...;
COMMIT;2. 避免在交易中等待使用者輸入
javascript
// 不好
await db.query("BEGIN");
await waitForUserConfirmation(); // 使用者可能離開
await db.query("COMMIT");
// 好
const confirmed = await waitForUserConfirmation();
if (confirmed) {
await db.query("BEGIN");
await db.query("INSERT ...");
await db.query("COMMIT");
}3. 處理錯誤時記得 ROLLBACK
javascript
try {
await db.query("BEGIN");
await db.query("INSERT ...");
await db.query("COMMIT");
} catch (e) {
await db.query("ROLLBACK"); // 重要!
throw e;
}4. 選擇適當的隔離等級
| 場景 | 推薦隔離等級 |
|---|---|
| 一般 CRUD | Read Committed(預設) |
| 報表、資料匯出 | Repeatable Read |
| 金融交易 | Serializable |
總結
| 概念 | 說明 |
|---|---|
| 原子性 | 全部成功或全部失敗 |
| 一致性 | 約束永遠滿足 |
| 隔離性 | 並行交易互不干擾 |
| 持久性 | COMMIT 後永久保存 |
| Read Committed | 每個 SELECT 看最新 COMMIT 的資料 |
| Repeatable Read | 整個交易看開始時的快照 |
| Serializable | 完全串行化執行 |
進階挑戰
- 模擬一個「轉帳」場景,確保交易的原子性(要嘛全成功,要嘛全失敗)
- 在 Repeatable Read 隔離等級下,觸發一個序列化失敗(Serialization Failure),並實作重試邏輯
- 比較 Read Committed 和 Serializable 隔離等級在高併發下的效能差異