支付中心的錢包類業務應該怎么設計
錢包類業務在支付行業里有一些比較固定的模式(無論是支付寶余額寶、微信零錢,還是 Stripe Balance / Paytm Wallet),基本設計目標是:
-
余額和資金安全:必須有嚴格的賬實一致、冪等和防篡改能力。
-
高并發讀寫:充值/消費/退款頻繁,要求快速的扣減和回滾能力。
-
清晰的流水:任何一筆資金變動必須有對應的流水,支持對賬和審計。
?? 表設計(MySQL 示例)
1. 錢包賬戶表(核心賬戶信息)
CREATE TABLE wallet_account ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL UNIQUE COMMENT '用戶ID', balance DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '可用余額', frozen_balance DECIMAL(18,2) NOT NULL DEFAULT 0 COMMENT '凍結余額', status TINYINT NOT NULL DEFAULT 1 COMMENT '賬戶狀態 1=正常 0=凍結', -- 棄用 version BIGINT NOT NULL DEFAULT 0 COMMENT '樂觀鎖版本號', hash_sign VARCHAR(128) COMMENT '數據校驗簽名(防篡改水印)', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user (user_id) ) ENGINE=InnoDB;
要點:
-
balance和frozen_balance一般放在 同一個表,保證原子性更新。 -
version做樂觀鎖,避免并發扣減錯誤。 -
hash_sign是水印字段(例如MD5(user_id + balance + frozen_balance + secret_salt)),在數據校驗任務中比對,防止 DBA/黑客直接篡改。
2. 錢包流水表(賬務流水)
CREATE TABLE wallet_ledger ( id BIGINT PRIMARY KEY AUTO_INCREMENT, wallet_id BIGINT NOT NULL, txn_id VARCHAR(64) NOT NULL COMMENT '關聯的交易單號/支付單號', biz_type VARCHAR(32) NOT NULL COMMENT '業務類型: RECHARGE, PAY, REFUND, FREEZE, UNFREEZE', change_amount DECIMAL(18,2) NOT NULL COMMENT '變動金額(正=增加,負=減少)', balance_after DECIMAL(18,2) NOT NULL COMMENT '變動后的可用余額', frozen_after DECIMAL(18,2) NOT NULL COMMENT '變動后的凍結余額', remark VARCHAR(255), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, INDEX idx_wallet (wallet_id), UNIQUE KEY uk_txn (txn_id, biz_type) -- 保證冪等 ) ENGINE=InnoDB;
要點:
-
每筆操作必須落流水,賬隨流轉。
-
biz_type用來區分充值、支付、退款、凍結、解凍。 -
uk_txn保證冪等(相同交易單號不會重復入賬)。
3. 錢包交易訂單表(業務訂單)
CREATE TABLE wallet_transaction ( id BIGINT PRIMARY KEY AUTO_INCREMENT, txn_id VARCHAR(64) NOT NULL UNIQUE COMMENT '錢包交易單號', user_id BIGINT NOT NULL, type VARCHAR(32) NOT NULL COMMENT '類型: RECHARGE, CONSUME, REFUND', amount DECIMAL(18,2) NOT NULL COMMENT '訂單金額', status VARCHAR(16) NOT NULL COMMENT 'INIT, SUCCESS, FAILED, PROCESSING', channel VARCHAR(32) COMMENT '支付通道(僅充值時有)', related_order_id VARCHAR(64) COMMENT '業務訂單ID, 如電商訂單號', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB;
要點:
-
錢包交易訂單表 和 流水表 是分開的:
-
訂單表:對外業務維度(如一筆充值、一筆消費)。
-
流水表:資金原子動作(可能一筆訂單拆成多條流水,比如凍結→支付→解凍)。
-
-
狀態機驅動(INIT → PROCESSING → SUCCESS/FAILED)。
?? 流程邏輯
1. 充值(RECHARGE)
-
用戶提交充值請求,生成
wallet_transaction(type=RECHARGE, status=INIT)。 -
調用支付通道(如銀行卡/支付寶充值),異步回調成功后:
-
開啟事務:
-
更新
wallet_account.balance = balance + amount。 -
寫入
wallet_ledger。 -
更新
wallet_transaction.status=SUCCESS。
-
-
2. 消費(CONSUME)
-
用戶下單支付,生成
wallet_transaction(type=CONSUME, status=INIT)。 -
開啟事務:
-
校驗余額夠不夠。
-
更新
wallet_account.balance = balance - amount。 -
寫入
wallet_ledger(change_amount = -amount)。 -
更新
wallet_transaction.status=SUCCESS。
-
3. 凍結 & 解凍(如押金、待支付訂單)
-
凍結:
balance - X,同時frozen_balance + X,寫流水。 -
解凍:反向操作,寫流水。
-
消費凍結金額:
frozen_balance - X,直接扣減,落賬。
4. 退款(REFUND)
-
生成
wallet_transaction(type=REFUND, related_order_id=原消費單)。 -
開啟事務:
-
balance = balance + refund_amount。 -
寫入
wallet_ledger。 -
更新
wallet_transaction.status=SUCCESS。
-
?? 數據庫邏輯
條件更新示例(不需要 version)
比如扣款:
UPDATE wallet_account SET balance = balance - 100 WHERE user_id = 123 AND balance - frozen_balance >= 100;
-
保證了賬戶可用余額足夠再扣。
-
如果余額不足,更新行數 = 0,表示失敗,業務層處理異常。
-
不存在并發覆蓋的問題,因為每條語句都是 原子執行。
3. 凍結/解凍邏輯擴展
凍結金額需要同時更新 balance 和 frozen_balance,例如:
凍結資金(比如下單時鎖定 200 元):
UPDATE wallet_account SET balance = balance - 200, frozen_balance = frozen_balance + 200 WHERE user_id = 123 AND balance - frozen_balance >= 200;
解凍資金(比如訂單取消):
UPDATE wallet_account SET balance = balance + 200, frozen_balance = frozen_balance - 200 WHERE user_id = 123 AND frozen_balance >= 200;
消費凍結資金(比如訂單支付成功):
UPDATE wallet_account SET frozen_balance = frozen_balance - 200 WHERE user_id = 123 AND frozen_balance >= 200;
?? java代碼實現(不使用version)
1 import java.sql.*; 2 import javax.sql.DataSource; 3 4 public class WalletServiceConditional { 5 private final DataSource ds; 6 private final int MAX_RETRIES = 5; 7 8 public WalletServiceConditional(DataSource ds) { this.ds = ds; } 9 10 // 直接消費(不通過凍結) 11 public boolean deductAvailable(long userId, long amount, String txnId) throws SQLException { 12 // txnId 用于 ledger 的冪等約束 13 for (int attempt=0; attempt<MAX_RETRIES; attempt++) { 14 try (Connection conn = ds.getConnection()) { 15 conn.setAutoCommit(false); 16 try { 17 // 1) 原子更新余額:保證可用余額充足 (balance - frozen_balance >= amount) 18 String updSql = "UPDATE wallet_account " + 19 "SET balance = balance - ? " + 20 "WHERE user_id = ? AND (balance - frozen_balance) >= ?"; 21 try (PreparedStatement upd = conn.prepareStatement(updSql)) { 22 upd.setLong(1, amount); 23 upd.setLong(2, userId); 24 upd.setLong(3, amount); 25 int affected = upd.executeUpdate(); 26 if (affected == 0) { 27 conn.rollback(); 28 return false; // 余額不足或其它原因 29 } 30 } 31 32 // 2) 寫流水(注意冪等) 33 String insertLedger = "INSERT INTO wallet_ledger(txn_id, wallet_user_id, change_amount, balance_after, frozen_after, biz_type, created_at) " + 34 "VALUES (?, ?, ?, (SELECT balance FROM wallet_account WHERE user_id=?), (SELECT frozen_balance FROM wallet_account WHERE user_id=?), ?, NOW())"; 35 try (PreparedStatement ps = conn.prepareStatement(insertLedger)) { 36 ps.setString(1, txnId); 37 ps.setLong(2, userId); 38 ps.setLong(3, -amount); 39 ps.setLong(4, userId); 40 ps.setLong(5, userId); 41 ps.setString(6, "CONSUME"); 42 ps.executeUpdate(); 43 } 44 45 conn.commit(); 46 return true; 47 } catch (SQLException ex) { 48 conn.rollback(); 49 // 死鎖重試:MySQL 錯誤碼1213 或 SQLState "40001" 50 if (isDeadlock(ex) && attempt < MAX_RETRIES-1) { 51 backoffSleep(attempt); 52 continue; 53 } 54 throw ex; 55 } finally { 56 conn.setAutoCommit(true); 57 } 58 } 59 } 60 throw new SQLException("deductAvailable failed after retries"); 61 } 62 63 // 凍結資金(下單時) 64 public boolean freezeAmount(long userId, long amount, String txnId) throws SQLException { 65 for (int attempt=0; attempt<MAX_RETRIES; attempt++) { 66 try (Connection conn = ds.getConnection()) { 67 conn.setAutoCommit(false); 68 try { 69 String sql = "UPDATE wallet_account SET balance = balance - ?, frozen_balance = frozen_balance + ? " + 70 "WHERE user_id = ? AND balance >= ?"; 71 try (PreparedStatement ps = conn.prepareStatement(sql)) { 72 ps.setLong(1, amount); 73 ps.setLong(2, amount); 74 ps.setLong(3, userId); 75 ps.setLong(4, amount); 76 int affected = ps.executeUpdate(); 77 if (affected == 0) { conn.rollback(); return false; } 78 } 79 80 // ledger: freeze record 81 String lsql = "INSERT INTO wallet_ledger(txn_id, wallet_user_id, change_amount, balance_after, frozen_after, biz_type, created_at) " + 82 "VALUES (?, ?, ?, (SELECT balance FROM wallet_account WHERE user_id=?), (SELECT frozen_balance FROM wallet_account WHERE user_id=?), ?, NOW())"; 83 try (PreparedStatement ps2 = conn.prepareStatement(lsql)) { 84 ps2.setString(1, txnId); 85 ps2.setLong(2, userId); 86 ps2.setLong(3, -amount); // 把可用余額減少 87 ps2.setLong(4, userId); 88 ps2.setLong(5, userId); 89 ps2.setString(6, "FREEZE"); 90 ps2.executeUpdate(); 91 } 92 93 conn.commit(); 94 return true; 95 } catch (SQLException ex) { 96 conn.rollback(); 97 if (isDeadlock(ex) && attempt < MAX_RETRIES-1) { 98 backoffSleep(attempt); 99 continue; 100 } 101 throw ex; 102 } finally { 103 conn.setAutoCommit(true); 104 } 105 } 106 } 107 throw new SQLException("freezeAmount failed after retries"); 108 } 109 110 private boolean isDeadlock(SQLException ex) { 111 return ex.getErrorCode() == 1213 || "40001".equals(ex.getSQLState()); 112 } 113 private void backoffSleep(int attempt) { 114 try { Thread.sleep(50L * (attempt+1)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } 115 } 116 }
?? 防篡改與對賬
-
hash_sign字段:每次更新賬戶時,重新計算并寫入。每天跑 一致性校驗任務,比對簽名是否正確。 -
雙錄:流水和訂單表都必須記錄,且可相互對賬。
-
異地冷備:賬務數據需要跨機房復制,防止單點損壞。
-
定期對賬:錢包系統內部對賬(賬戶余額 = 所有流水累加),與支付通道對賬(充值/提現)。
?? 錢包訂單表的合并問題
-
充值訂單 和 消費訂單 可以放在一個
wallet_transaction表里(用type區分)。 -
優點:查詢用戶交易歷史簡單。
-
缺點:表會很大,可能需要分表(按 user_id/hash 分庫分表)。
大廠(支付寶、微信、Paytm)一般也是用 一個訂單表 + 一張流水表,而不是分成兩張。
? 總結:
-
余額、凍結金額 → 放在
wallet_account,原子更新。 -
流水 → 必須單獨表,保證賬隨流轉。
-
交易訂單 → 一張表統一管理充值/消費/退款,配合狀態機。
-
防篡改 → 用
hash_sign或日志落地校驗。
-
定期對賬:錢包系統內部對賬(賬戶余額 = 所有流水累加),與支付通道對賬(充值/提現)。
在支付行業的錢包資金類場景里,更推薦使用 數據庫事務 + 條件更新(悲觀鎖/行級鎖/原子更新語句)來保障資金一致性,而不是依賴表里單獨的 version 字段來做樂觀鎖。原因如下:
?? 為什么事務比 version 更適合資金類場景
-
資金安全 > 并發性能
錢包、支付賬戶類場景,最重要的是 不能錯扣、不能多扣。事務+原子條件更新(如update ... set balance = balance - ? where balance >= ?)可以天然保證一致性。
樂觀鎖(version)的設計適合“沖突少,讀多寫少”的業務,比如商品資料修改。
但在資金類業務中,沖突概率高、金額精確要求極嚴,用 version 反而會導致:-
大量失敗重試(因為 version 不匹配)
-
并發下吞吐下降
-
邏輯復雜(尤其凍結/解凍/退款等鏈路)
-
-
數據庫本身提供的 ACID 能力已經能保證數據正確性:
-
InnoDB支持行級鎖 -
原子條件更新語句保證只有滿足條件的數據能被修改
-
事務回滾可以保障失敗時不產生“臟扣款”
-
所以在資金類系統中,更常見的方式是依賴事務,不依賴 version。
?? 凍結字段是必須的么
?? 下單和支付在一個事務內時,凍結字段其實不是必須
-
一個事務內完成
-
下單寫訂單表
-
扣減余額(UPDATE wallet_account SET balance = balance - 200 WHERE balance >= 200)
-
寫支付單/流水表
-
提交事務
?? 在事務提交前,其他并發事務看不到扣減結果,自然不會發生余額超支。
-
-
資金狀態明確
-
因為下單和支付合并了,訂單一旦創建,就已經完成扣款,不存在“鎖定但沒付款”的狀態。
-
?? 那么凍結字段什么時候才是必須的?
凍結字段的存在,是為了應對 下單和支付不是同時完成 的情況,比如:
-
下單和支付解耦(絕大多數電商)
-
用戶下單時 → 只是鎖定商品和優惠券,不強制立即支付。
-
如果不凍結資金,用戶下單多次可能超額。
-
所以需要凍結余額,等支付成功再核銷,支付失敗則解凍。
-
-
預授權場景(打車、酒店、理財等)
-
用戶占用一筆資金,但最后實際消費金額不確定(比如押金/預估車費)。
-
必須凍結,否則最后要扣款時,可能用戶余額不足。
-
-
提現申請
-
用戶點了提現申請,但實際清算到賬可能要 1~2 天。
-
必須凍結,避免提現期間用戶再消費導致資金不夠。
-
?? 對比方案
-
方案 1:下單 + 支付合并(事務內扣款)
-
不需要凍結字段
-
風險:靈活性不足,用戶沒法“先下單、后付款”
-
適用:快捷支付、小額消費、錢包余額支付場景
-
-
方案 2:下單與支付解耦(凍結字段必需)
-
需要凍結字段,保證資金占用
-
靈活性強,支持購物車下單后選擇支付方式
-
適用:電商購物、預授權場景、大多數真實支付鏈路
-

浙公網安備 33010602011771號