支付平台余额系统数据库设计,扣减余额、事务防止超扣
支付平台余额系统最怕出现超扣,也就是用户余额只剩 1 元,却成功扣掉 5 元。
要避免这个问题,不能只靠应用层判断,更要把防超扣规则落到数据库设计上。
核心做法是:余额只存一行,扣减必须在一个事务里执行 UPDATE ... SET balance = balance - ?,再根据影响行数决定是否提交。
WHERE user_id = ?
AND balance >= ?
本文用 MySQL 8.0 的 InnoDB 引擎演示,适合准备自研支付、钱包或积分系统的开发者参考。
先分清余额表和流水表的关系
设计余额系统时至少需要两张表:一张用于保存用户当前可用余额,另一张用于记录每笔资金的增加、扣减和冻结流水。
这两张表要分开,原因很实际:如果只靠一张流水表实时汇总余额,每次扣款都要全表计算,数据量一大就会变慢,事务锁范围也会扩大。
余额表中的金额字段代表“当前结果”,是频繁更新的目标。
流水表则用于审计、对账和故障恢复,任何余额变动都要有对应流水。
两张表必须在同一个数据库事务里同时写入,避免余额变了却没有记录。
防超扣的余额表结构
建议使用 InnoDB 引擎,键是 user_id,这样每一行都会被行锁保护。
金额字段必须使用 DECIMAL(30,8),不要用 FLOAT 或 DOUBLE,否则浮点数误差会在多次扣减后放大。
建表示例:
CREATE TABLE account_balance (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id VARCHAR(64) NOT NULL,
balance DECIMAL(30,8) NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 0,
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
UNIQUE KEY uk_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
流水表则要包含业务单号、用户标识、变动前余额、变动后余额和变动类型。
业务单号必须有唯一约束,这是防止重复扣款的关键。
扣减余额的正确事务写法
下面是一段可以直接套用的伪 SQL 样例,实际项目请替换成自己使用的编程语言和连接方式:
START TRANSACTION;
UPDATE account_balance
SET balance = balance - 5.00
WHERE user_id = 'U10001' AND balance >= 5.00;
-- 如果影响行数为 0,说明余额不足或用户不存在,直接回滚
SELECT ROW_COUNT();
INSERT INTO balance_flow (
flow_no, user_id, change_amount, before_balance,
after_balance, change_type, status
) VALUES (
'F20250101001', 'U10001', -5.00, 10.00,
5.00, 'PAY', 'SUCCESS'
);
COMMIT;
写代码时需要注意应用层不能先 SELECT balance 判断是否足够,再执行 UPDATE。
因为两个请求可能同时读到同一余额,然后都通过判断,最终导致超扣。
必须把“扣减+余额充足判断”合成一条 UPDATE 语句,由数据库原子地检查并更新。
另外一个容易忽略的点是事务隔离级别。
建议使用默认的 REPEATABLE READ,但真正防止超扣主要靠条件更新里的行锁,而不是隔离级别。
即使把事务降到 READ COMMITTED,上面的 SQL 仍然能防止额度被扣穿。
这些坑会让事务防超扣失效
第一,流水表没有唯一业务单号。
用户点击两次支付,应用层判断“支付成功”后重复发起扣款,如果流水表没有唯一约束,同一笔订单可能被插入两条记录。
解决办法是在流水表的业务单号上建立唯一索引,插入时若出现唯一键冲突就按重复请求处理。
第二,余额字段使用了浮点型。
金额是离散的精确小数,建议使用高精度数字类型 DECIMAL。
浮点型在累计扣减后可能产生极小误差,让对账变得困难。
第三,更新余额和写流水不在同一个事务。
有些开发者为了“降低锁时间”,先更新余额,再单独插入流水。
一旦插入失败,余额已经扣了,后续对账会很被动。
更稳妥的做法是放在同一个事务里,保持两者同时成功或同时失败。
第四,用乐观锁版本的代码在高低并发下经常出现“更新 0 行但不知道原因”。
如果扣减金额是负数,或者发生了整数边界溢出,数据库也会拒绝更新。
建议在回滚前重新查询余额并记录错误日志。
这样验证你的系统不会超扣
验证防超扣效果,可以把一个用户的初始余额设为 100,然后用 50 个并发请求同时扣减 10。
预期结果应该是:10 个请求成功,40 个请求返回余额不足,用户最终余额为 0。
如果最终余额为负数,说明扣减 SQL 没有包含 balance >= 扣减金额 条件,或者扣减操作不在事务中。
再确认资金流水总数等于成功扣减次数,且每条流水的变动前后金额能连成连续序列。
只要余额表和流水表完全由数据库事务控制,并保留足够的日志,支付平台余额系统就能在真实交易中稳定运行。
排查问题时,优先检查应用日志中是否有唯一键冲突、ROW_COUNT() 是否为 0,以及事务是否被异常回滚。