為什麼總是對不上賬#
賬戶系統最痛的問題:跑批一對賬發現少幾分錢、或多扣一筆款項。原因 99% 是並發控制做錯。本章從常見錯誤出發,展開 InnoDB 隔離級別在實戰中的選擇。
隔離級別、MVCC、間隙鎖的細節在 02-database/03 事務與鎖機制 已寫過。本章聚焦「實戰怎麼用、對賬時怎麼選」。
經典問題:餘額扣減#
A 給 B 轉 100 元:
1. 查 A 餘額
2. 查 B 餘額
3. A.balance -= 100
4. B.balance += 100
5. 寫回 A
6. 寫回 B不在事務裡做 → 中間任何一步失敗,錢就丟了或多了。
錯誤示範:
def transfer(a, b, amount):
a_balance = db.query("SELECT balance FROM accounts WHERE id=%s", a)
b_balance = db.query("SELECT balance FROM accounts WHERE id=%s", b)
db.execute("UPDATE accounts SET balance=%s WHERE id=%s", a_balance - amount, a)
db.execute("UPDATE accounts SET balance=%s WHERE id=%s", b_balance + amount, b)問題堆積:
- 兩個 update 之間 crash → A 扣了 B 沒加
- 兩個 select 後另一個 thread 也 select → 兩邊都按舊值算 → 餘額矛盾
- 沒有事務 boundary → 部分提交
正確做法:
with db.transaction():
db.execute("UPDATE accounts SET balance = balance - %s WHERE id=%s AND balance >= %s",
amount, a, amount)
if affected_rows == 0:
raise InsufficientFunds()
db.execute("UPDATE accounts SET balance = balance + %s WHERE id=%s", amount, b)關鍵改動:
- 用事務包起來
- 不在應用層算新值,直接讓 DB 做
balance - amount - WHERE 帶
balance >= amount由 DB 保證原子性
ACID 在實戰中的具體含義#
| 字母 | 名稱 | 賬戶系統的含義 |
|---|---|---|
| A | Atomicity | 轉帳兩端要嘛都成功要嘛都失敗 |
| C | Consistency | 任何時候總帳不變 |
| I | Isolation | 並發轉帳不會相互看到中間狀態 |
| D | Durability | 提交後就算機器斷電也不會丟 |
InnoDB 對 ACID 的保證強度與隔離級別有關。
四個隔離級別與對應的問題#
髒讀 非重複讀 幻讀
RU ✓ ✓ ✓ 可能發生
RC ✗ ✓ ✓
RR ✗ ✗ ✓ (MySQL 預設、間隙鎖避免)
Serializable ✗ ✗ ✗| 等級 | 簡稱 | InnoDB 行為 |
|---|---|---|
| READ UNCOMMITTED | RU | 看得到未提交的變更 |
| READ COMMITTED | RC | 只看得到已提交,每次 SELECT 重新建快照 |
| REPEATABLE READ | RR | 事務內 SELECT 結果穩定(MVCC + 間隙鎖避免幻讀) |
| SERIALIZABLE | - | SELECT 自動加 share lock,幾乎變成串行 |
實務上:RC 與 RR 是 90% 的選項。
RC:MySQL 之外的多數 DB 預設#
PostgreSQL、Oracle、SQL Server、CockroachDB 預設 RC。優點:
- 較少 lock contention
- 沒有間隙鎖、衝突少
- 「每次讀都是新快照」直觀
缺點:
- 同事務內兩次讀同一行可能不同
- 必須仰賴 SELECT FOR UPDATE 主動鎖
RR:MySQL 預設#
優點:
- 同事務內所有讀一致(snapshot 在第一次讀時建立)
- 配合間隙鎖避免幻讀
缺點:
- 間隙鎖在大範圍掃描時鎖很多 row,contention 高
- 容易死鎖
- 對賬程式長事務可能拉爆 undo log
對賬程式選 RC:把每個賬戶的計算切成短事務,避免長 lock。 轉帳程式選 RR 沒問題:本來就要鎖兩個 row。
寫衝突的兩種應對#
並發寫同一筆資料 → 可能 lock 等待或死鎖。
悲觀鎖:SELECT FOR UPDATE#
BEGIN;
SELECT balance FROM accounts WHERE id = ? FOR UPDATE; -- 加 X 鎖
-- 業務檢查
UPDATE accounts SET balance = balance - ? WHERE id = ?;
COMMIT;讀的時候就加排他鎖。其他事務想動這條會等。
優點:簡單直白。 缺點:吞吐受限於最慢的請求。
樂觀鎖:版本號 / CAS#
UPDATE accounts SET balance = ?, version = version + 1
WHERE id = ? AND version = ?不加鎖,提交時檢查版本。affect = 0 即衝突,重試。
優點:高並發下吞吐大。 缺點:衝突多時重試成本高,可能 starvation。
該怎麼選#
- 寫衝突低(< 5%)→ 樂觀鎖
- 寫衝突高 → 悲觀鎖(讓 DB 排隊)
- 對排隊敏感(想 fail-fast)→ 樂觀鎖
賬戶系統的大多數寫不衝突(每個用戶自己的賬戶),用樂觀鎖;同一筆轉帳的兩個 row 用悲觀鎖(事務內多步驟)。
一個常見錯誤:脫離事務的「先查後改」#
balance = db.query("SELECT balance FROM accounts WHERE id=%s", a)
if balance >= amount:
db.execute("UPDATE accounts SET balance = %s WHERE id=%s", balance - amount, a)中間 SELECT 與 UPDATE 之間,另一個 thread 可能也 SELECT 了相同的 balance、也走進去。兩個都認為夠扣,都扣了。
正解:
-- 直接讓 DB 做檢查 + 扣減
UPDATE accounts SET balance = balance - ?
WHERE id = ? AND balance >= ?或:
-- 鎖住再做
SELECT balance FROM accounts WHERE id = ? FOR UPDATE;
-- 應用層檢查
UPDATE accounts SET balance = balance - ? WHERE id = ?;絕對不要「應用層 SELECT、應用層算、UPDATE 寫回」── 並發下必出錯。
流水表與雙簿記#
對賬靠不住的核心原因:「狀態」是計算結果,沒有審計軌跡。雙簿記(double-entry bookkeeping):
CREATE TABLE accounts (
id BIGINT PK,
balance DECIMAL(15, 2)
);
CREATE TABLE transaction_log (
id BIGINT PK,
account_id BIGINT,
delta DECIMAL(15, 2), -- 正進負出
related_account_id BIGINT, -- 對手方
biz_type VARCHAR(50),
biz_id VARCHAR(64),
created_at TIMESTAMP,
UNIQUE (biz_type, biz_id, account_id) -- 冪等
);每次轉帳:
-- 一個事務裡:
INSERT INTO transaction_log (account_id=A, delta=-100, related=B, biz_id='order_xxx');
INSERT INTO transaction_log (account_id=B, delta=+100, related=A, biz_id='order_xxx');
UPDATE accounts SET balance = balance - 100 WHERE id = A;
UPDATE accounts SET balance = balance + 100 WHERE id = B;對賬時:
SELECT account_id, SUM(delta)
FROM transaction_log
WHERE created_at >= ? AND created_at < ?
GROUP BY account_id每個賬戶的流水加總應等於該期間的 balance 變動。對不上 → 一定是某筆 UPDATE 漏掉、或外部直接改了 balance。
雙簿記的另一個好處:全網 delta 加總應為 0。任何時刻 sum(delta) != 0 → 有資金憑空產生或消失。
Phantom Read 的具體情況#
RR 級別下 InnoDB 用間隙鎖避免幻讀,但…
BEGIN;
SELECT COUNT(*) FROM users WHERE city = 'TPE'; -- 假設 100
-- 另一個 session 插了 city='TPE' 的記錄並提交
SELECT COUNT(*) FROM users WHERE city = 'TPE'; -- 仍是 100(一致快照)
INSERT INTO sample (city, count) SELECT city, COUNT(*) FROM users WHERE city = 'TPE';
-- 但這條 INSERT 可能用 current read(讀新值)!
COMMIT;InnoDB 的「快照讀」與「當前讀」是兩種模式:
- 普通 SELECT → 快照讀
- SELECT FOR UPDATE / FOR SHARE / DML(INSERT/UPDATE/DELETE)→ 當前讀
兩種讀混合在同一事務內就會出現「快照與真實狀態」不一致的尷尬。設計事務時要意識到這點 ── 對需要嚴格一致的計算,全用 FOR UPDATE 或全用 SELECT。
死鎖#
事務 1: UPDATE A → UPDATE B
事務 2: UPDATE B → UPDATE A兩個事務各自鎖了一邊、再想拿另一邊 → 死鎖。InnoDB 偵測到後選 cost 較小的事務 rollback,回 1213 Deadlock。
避免:
- 加鎖順序固定:所有事務都按 id 升冪鎖
- 縮短事務:鎖時間越短越不易碰撞
- 減少鎖範圍:用 RC 取代 RR、縮窄 WHERE
- 重試:應用層 catch deadlock 回滾後重試
大型賬戶系統的取捨#
支付公司、銀行、加密交易所常做的設計:
- 冷熱分離:當前餘額在 OLTP DB;歷史流水歸檔到列式儲存(ClickHouse、Redshift)
- 記憶體賬戶:高頻交易用記憶體 + WAL(如 Redis + AOF),定期 flush 到磁碟。但風險高、需要嚴密的審計
- 嚴格只進不刪:流水表 append-only,永不修改。修錯只能用反向流水沖銷
- 多級對賬:每分鐘輕對 + 每天重對 + 每月人工抽查
小結#
- 寫操作的核心:讓 DB 在一條 UPDATE 裡做完檢查 + 改值,避免應用層的 SELECT-then-UPDATE
- 隔離級別選擇:對賬用 RC、轉帳用 RR
- 鎖選擇:低衝突樂觀鎖、高衝突悲觀鎖
- 雙簿記是賬戶系統的最低標準
- 死鎖:固定加鎖順序 + 重試是基本應對
下章看跨多個系統的事務 ── 分散式事務的多種策略。