為什麼總是對不上賬#

賬戶系統最痛的問題:跑批一對賬發現少幾分錢、或多扣一筆款項。原因 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)

問題堆積:

  1. 兩個 update 之間 crash → A 扣了 B 沒加
  2. 兩個 select 後另一個 thread 也 select → 兩邊都按舊值算 → 餘額矛盾
  3. 沒有事務 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)

關鍵改動:

  1. 用事務包起來
  2. 不在應用層算新值,直接讓 DB 做 balance - amount
  3. WHERE 帶 balance >= amount 由 DB 保證原子性

ACID 在實戰中的具體含義#

字母名稱賬戶系統的含義
AAtomicity轉帳兩端要嘛都成功要嘛都失敗
CConsistency任何時候總帳不變
IIsolation並發轉帳不會相互看到中間狀態
DDurability提交後就算機器斷電也不會丟

InnoDB 對 ACID 的保證強度與隔離級別有關。

四個隔離級別與對應的問題#

            髒讀  非重複讀  幻讀
RU          ✓      ✓        ✓     可能發生
RC          ✗      ✓        ✓
RR          ✗      ✗        ✓     (MySQL 預設、間隙鎖避免)
Serializable ✗     ✗        ✗
等級簡稱InnoDB 行為
READ UNCOMMITTEDRU看得到未提交的變更
READ COMMITTEDRC只看得到已提交,每次 SELECT 重新建快照
REPEATABLE READRR事務內 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

避免:

  1. 加鎖順序固定:所有事務都按 id 升冪鎖
  2. 縮短事務:鎖時間越短越不易碰撞
  3. 減少鎖範圍:用 RC 取代 RR、縮窄 WHERE
  4. 重試:應用層 catch deadlock 回滾後重試

大型賬戶系統的取捨#

支付公司、銀行、加密交易所常做的設計:

  • 冷熱分離:當前餘額在 OLTP DB;歷史流水歸檔到列式儲存(ClickHouse、Redshift)
  • 記憶體賬戶:高頻交易用記憶體 + WAL(如 Redis + AOF),定期 flush 到磁碟。但風險高、需要嚴密的審計
  • 嚴格只進不刪:流水表 append-only,永不修改。修錯只能用反向流水沖銷
  • 多級對賬:每分鐘輕對 + 每天重對 + 每月人工抽查

小結#

  • 寫操作的核心:讓 DB 在一條 UPDATE 裡做完檢查 + 改值,避免應用層的 SELECT-then-UPDATE
  • 隔離級別選擇:對賬用 RC、轉帳用 RR
  • 鎖選擇:低衝突樂觀鎖、高衝突悲觀鎖
  • 雙簿記是賬戶系統的最低標準
  • 死鎖:固定加鎖順序 + 重試是基本應對

下章看跨多個系統的事務 ── 分散式事務的多種策略。