如何安全向数据库新增资金操作,避免高并发下出现负余额问题?
并发扣款导致余额为负的解决方案
你遇到的是典型的并发读写场景下的数据一致性问题,以下是3种可落地的解决方案,按实现复杂度、可靠性、适用场景分类:
方案1:数据库排他锁(悲观锁,实现简单可靠性高)
- 适用场景:并发冲突概率高,对资金一致性要求极高的场景
- 实现步骤:
- 开启数据库事务
- 执行余额查询SQL并加排他锁,语句如下:
SELECT SUM(amount) FROM fund_operations WHERE user_id = #{用户ID} FOR UPDATE;
- 校验查询得到的余额是否大于等于购买金额,不满足则直接回滚事务,返回余额不足
- 校验通过则插入扣款交易记录(扣款金额统一存为负值)
- 提交事务,自动释放排他锁
原理:排他锁会阻塞其他事务对当前用户交易记录的查询请求,直到当前事务完成,保证同一时间只有一个请求能操作该用户的资金,不会出现并发校验都通过的问题。
方案2:原子插入校验(无锁实现,性能更高)
- 适用场景:并发冲突概率中等,想要更高处理性能的场景
- 直接执行单条原子INSERT语句,将余额校验逻辑嵌入SQL中,利用数据库的原子执行特性避免并发问题:
INSERT INTO fund_operations (user_id, amount, op_type, create_time) SELECT #{用户ID}, -#{购买金额}, 'purchase', NOW() FROM DUAL WHERE (SELECT SUM(amount) FROM fund_operations WHERE user_id = #{用户ID}) >= #{购买金额};
- 执行后判断SQL的影响行数:返回1代表扣款成功,返回0代表余额不足扣款失败
原理:单条DML语句的执行是原子性的,数据库会保证整个校验+插入的过程不会被其他请求打断,不需要额外加锁也不会出现数据不一致。
方案3:冗余余额表+乐观锁(适合大业务量场景)
- 适用场景:用户交易记录量级极大,每次SUM计算余额性能低下的场景
- 实现步骤:
- 新增
user_balance冗余表存储用户当前余额,字段包括user_id(用户ID)、balance(当前余额)、version(版本号) - 用户发起购买请求时,先查询该用户的余额和版本号:
SELECT balance, version FROM user_balance WHERE user_id = #{用户ID};
- 校验余额大于等于购买金额后,执行带版本号校验的更新语句:
UPDATE user_balance SET balance = balance - #{购买金额}, version = version + 1 WHERE user_id = #{用户ID} AND version = #{之前查询到的版本号};
- 若更新影响行数为1,说明更新成功,再插入对应的扣款交易记录;若影响行数为0,说明期间有其他请求修改了用户余额,可选择重试或者直接返回操作失败
注意:余额表的更新和交易记录的插入需要放在同一个本地事务中执行,保证两者数据一致。
通用注意事项
- 所有金额相关字段必须使用定点数类型(如MySQL的DECIMAL)存储,禁止使用浮点型,避免精度丢失
- 交易记录的金额字段统一规则:充值类操作存正值,扣款类操作存负值,汇总时直接SUM即可,无需额外判断操作类型
- 上述所有方案均基于数据库层面实现,天然支持分布式多服务实例部署,不需要额外引入分布式锁组件
内容的提问来源于stack exchange,提问作者Ivan Blohin
相关产品推荐
相关产品推荐

