钱包类业务在支付行业里有一些比较固定的模式(无论是支付宝余额宝、微信零钱,还是 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 = 123AND balance - frozen_balance >= 200;
解冻资金(比如订单取消):
UPDATE wallet_account SET balance = balance + 200,frozen_balance = frozen_balance - 200 WHERE user_id = 123AND frozen_balance >= 200;
消费冻结资金(比如订单支付成功):
UPDATE wallet_account SET frozen_balance = frozen_balance - 200 WHERE user_id = 123AND 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。