高并发下怎么做余额扣减?这个话题我太有感触了。无论是做支付清结算、电商钱包,还是游戏币账户、积分系统,迟早都要撞上这道坎。我第一次接手钱包账务系统的时候,以为就是一条 update 语句把 balance 减一减,结果线上刚做了一轮活动,账单负数、用户投诉、对账不平全都涌过来了。这才意识到,余额扣减根本不是“能不能扣”的问题,而是“在流量打过来的时候,怎么保证每一笔都扣得准、扣得住、扣得稳”。
这篇文章不聊教科书理论,我直接把你当成一个正在设计账务系统的开发者,从问题本质讲起,把数据库锁、Redis 原子扣减、Sentinel 流控、幂等和最终一致性整个链路都过一遍。适合钱包、结算、订单、积分等场景的技术负责人和一线开发参考,也适合刚准备做交易系统的同学拿来当设计清单。
1. 余额扣减的问题本质,先想清楚再动手
1.1 高并发场景下的核心矛盾
余额扣减表面上是一个写操作,但它在高并发下会同时踩中三个问题:数据一致性、性能、以及业务正确性。顺序上不能搞反,很多系统是先调性能,结果一致性崩了;有的先保证一致性,结果数据库被打挂。正确姿势应该是先明确一致性的底线,再谈性能。
三个问题具体拆开看是这样的:
- 数据一致性:两个并发请求同时扣同一笔余额,不能两个都成功,除非余额够两笔。
- 性能:单账户热点扣减时,数据库行锁会挡住大部分请求,QPS 上不去。
- 业务正确性:扣减必须与订单、流水、幂等标记绑定,否则重试、超时、对账都会产生重复单。
最容易被低估的是业务正确性。很多人以为加个 synchronized 或者乐观锁就完事了,但一旦引入消息队列、异步流水、重试补偿,重复扣款和丢失扣款的风险反而比并发冲突更可怕。
1.2 余额模型本质上是账户流水模型
很多余额系统的设计误区,是把“余额”当作一个可以随便改的字段。实际上,余额是一个派生值,真正的业务事实是流水。每笔扣减都对应一条扣减流水,余额是流水的累计结果。
所以设计上我强烈建议把账户体系拆成两层:
- 账户余额表:只存储当前可用余额、冻结余额、版本号等汇总字段。
- 账户流水表:每笔扣减、入账、解冻都记录一条流水,包含业务单号、变动类型、变动金额、前余额、后余额。
这样做的价值在于,即使余额字段被并发写坏了,或者 Redis 丢了缓存,仍然可以通过流水重放、对账修复。而且在高并发下,流水表是 append 操作,天然比“读改写”的余额表更适合扛量。
1.3 性能与一致性的平衡点在哪里
先说结论:扣减这条链路里,不能把一致性全部押在“内存锁”或“应用层锁”上,需要靠数据库/存储层保证原子性;也不能把性能全部押在“数据库行锁”上,需要通过缓存、队列、限流来做流量削峰。
我的经验是,按扣减场景分三类处理:
- 低频扣减(如后台调账、退款):直接用数据库乐观锁扣减,业务简单可靠。
- 中频用户扣减(如普通购买、积分扣减):数据库条件更新 + 流水落库,配合 Redis 热点保护。
- 高频热点扣减(如秒杀、直播送礼):Redis Lua 原子扣减 + 异步落库 + 定期对账。
下文会逐个展开,但先记住一个原则:余额扣减不允许出现负数,这是一切设计的底线。
2. 数据库层扣减方案,先立住一致性
2.1 悲观锁,简单但代价大
悲观锁的做法是在扣减前对账户行SELECT ... FOR UPDATE,锁住后,其他事务必须等待,然后再执行更新。
SELECT balance FROM account WHERE account_id = ? FOR UPDATE; UPDATE account SET balance = balance - ? WHERE account_id = ?;这个方案很直观,也能绝对保证一致性。但缺陷也很明显:高并发下所有扣减请求串行排队,单账户 QPS 受制于数据库事务吞吐,一般也就几百到一千多,而且长事务会让连接池耗尽。
现实问题更明显:如果扣减操作还要查订单、校验风控,锁的持有时间就会拉长,系统很容易从“偶发超时”变成“雪崩”。所以悲观锁我只建议用在后台管理系统这种低并发、强管控场景。
2.2 乐观锁,用版本号代替行锁
乐观锁的经典实现是版本号方式:
UPDATE account SET balance = balance - #{amount}, version = version + 1 WHERE account_id = #{accountId} AND balance >= #{amount} AND version = #{version};关键在于balance >= #{amount}这个条件,它从数据库层面拦截了“超扣”。如果更新行数为 0,说明余额不足或版本变动,业务层可以捕获后重试或返回失败。
还有简化版的乐观锁,不用版本号,利用余额本身做条件:
UPDATE account SET balance = balance - #{amount} WHERE account_id = #{accountId} AND balance >= #{amount};这种写法更轻量,行数判断同样有效。但要注意,没有版本号时“覆盖更新”会丢失中间态,所以适合单次扣减流程内使用,不适合多阶段状态流转。
实际项目中我推荐一个技巧:把“校验余额 + 扣减”合并到一条 SQL 里,不要先查后改。先查后改在并发下必然有窗口期,哪怕用版本号,也会大量产生无效重试。
2.3 条件更新在原子上靠不靠谱
有人担心一条带条件的 update 在高并发下是否真的原子。这里说明一下,数据库的更新语句本身是在事务里执行的,行级锁会在更新期间锁住目标行,其他事务必须等待当前更新提交或回滚。所以并发的两个扣减请求,即使同时到达,也只有先拿到行锁的能改成功,后到的会看到新的 balance,再判断是否满足余额条件。
-- 事务A和事务B同时扣100,账户余额150 UPDATE account SET balance = balance - 100 WHERE account_id = 1 AND balance >= 100; -- A成功:balance = 50,影响行数 1 UPDATE account SET balance = balance - 100 WHERE account_id = 1 AND balance >= 100; -- B此时基于balance = 50判断,50 >= 100不成立,影响行数 0这就是条件更新的价值:不需要先 select,不需要应用层锁,数据库底层已经把“并发中的先后顺序”处理好了。
3. Redis 扣减方案,应对热点账户高并发
3.1 Redis 扣减的适用边界
数据库条件更新虽然好,但在“单账户极高并发”的场景下仍有瓶颈。比如秒杀时一个爆款账户同时被几十万请求扣库存、活动账户被大量送礼扣币,数据库单行锁会成为系统堵点。
Redis 扣减方案就是在这种场景下引入的。Redis 是单线程模型,命令执行天然串行,DECRBY、EVAL都是原子操作,在高并发下不会出现并发覆盖。单实例 Redis 可以支撑每秒几万到十几万的扣减请求,远高于数据库。
但是 Redis 扣减不能盲目用,它有几个硬性前提:
- Redis 中的数据是缓存态,可能出现丢失,必须允许异步持久化。
- Redis 扣减结果必须通过消息或日志异步回写数据库,保证最终一致。
- Redis 宕机或脏数据时,必须在恢复后从数据库重新加载余额。
所以 Redis 方案更适合“前端扣减体验要求高、后台可以异步对账兜底”的场景,而不是每次都强一致的真实资金账户。
3.2 用 Lua 脚本实现原子扣减
在 Redis 里做余额扣减,我强烈建议用 Lua 脚本,而不是直接DECRBY后判断负数。原因有两个:一是 Lua 脚本把“读取余额、比较、扣减、返回结果”合并为一个原子操作,中间不会被其他命令打断;二是可以在脚本里做业务逻辑,比如余额不足返回错误码,而不产生负数。
下面是我常用的扣减脚本:
-- KEYS[1]: 余额key -- ARGV[1]: 本次扣减金额 -- ARGV[2]: 本次请求的流水号(用于防重) local key = KEYS[1] local amount = tonumber(ARGV[1]) local requestId = ARGV[2] local dedupKey = key .. ':dedup:' .. requestId -- 先检查幂等,如果已经扣过,直接返回成功状态 if redis.call('SET', dedupKey, '1', 'NX', 'EX', 86400) then local balance = tonumber(redis.call('GET', key) or '0') if balance >= amount then redis.call('DECRBY', key, amount) return 1 -- 扣减成功 end -- 余额不足,删除幂等标记,避免污染后续请求 redis.call('DEL', dedupKey) return 0 -- 余额不足 else return 1 -- 重复请求,返回成功,避免业务重试扣两次 end这段脚本的关键在于把“幂等检查”和“余额校验”放在同一个 Lua 里,因为 Redis 是单线程执行 Lua,不会出现两个相同请求同时通过检查的情况。
调用方式示例(Java 侧):
String luaScript = "local key = KEYS[1]\n" + "local amount = tonumber(ARGV[1])\n" + "local requestId = ARGV[2]\n" + "local dedupKey = key .. ':dedup:' .. requestId\n" + "if redis.call('SET', dedupKey, '1', 'NX', 'EX', 86400) then\n" + " local balance = tonumber(redis.call('GET', key) or '0')\n" + " if balance >= amount then\n" + " redis.call('DECRBY', key, amount)\n" + " return 1\n" + " end\n" + " redis.call('DEL', dedupKey)\n" + " return 0\n" + "else\n" + " return 1\n" + "end"; Long result = redisTemplate.execute( new DefaultRedisScript<Long>(luaScript, Long.class), Collections.singletonList("balance:" + accountId), amount, requestId );3.3 Redis 与数据库的最终一致如何落地
Redis 扣减成功后,不能直接认为账务结束了。需要一个异步落库流程,把扣减流水持久化到数据库,才能保证断电、宕机后还能对账。
落库流程我一般这样设计:
- Redis 扣减成功后,把扣减记录写入本地消息表,状态为“待落库”。
- 后台定时任务扫描待落库消息,在数据库里执行幂等入账,同时写流水表。
- 落库成功后,更新消息状态为“已完成”。
- 定期对账脚本对比 Redis 余额和数据库余额汇总,发现不一致时以数据库为准回刷 Redis。
这里有一个细节容易踩坑,就是“以哪个数据为准”。我建议强制以数据库流水汇总为准,Redis 只作为缓存加速层,即使 Redis 被清空,也可以从数据库重新构建余额缓存。
异步落库时同样要防重复,数据库里用唯一键约束,比如account_id + request_id建唯一索引,重复消息插入时会报错,直接忽略。
4. 流量治理与 Sentinel 限流,别让扣减链路被打垮
4.1 为什么扣减链路需要流量治理
余额扣减通常是交易链路的末端,倒灌进来的流量会先经过下单、验券、风控,再到达扣减服务。一旦前端流量突增,扣减服务会被打满线程池,数据库连接耗尽后引发连环超时。
这时候引入流量治理是必要的。我一直在用的是 Alibaba Sentinel,相比手写限流,它把限流、熔断、降级、系统保护做成了一体化方案,配置规则后可以快速对“扣减接口”进行保护。
4.2 使用 Sentinel 保护扣减接口
在扣减服务上,我通常会配置几类规则:
- QPS 限流:按用户维度限制单条接口的 QPS,防止单个用户脚本刷接口。
- 并发线程数限流:防止慢调用把线程池占满。
- 熔断降级:当扣减服务异常比例超过阈值时,直接走快速失败,不再拖垮下游。
- 热点参数限流:对 accountId 维度做热点参数限流,比如单账户每秒最多处理 500 次扣减。
Sentinel 的配置既可以在控制台动态下发,也可以用代码或配置方式初始化。
一个简单的 Java 示例:
// 限流规则:扣减接口 QPS 不超过 2000 FlowRule flowRule = new FlowRule(); flowRule.setResource("balance:deduct"); flowRule.setGrade(RuleConstant.FLOW_GRADE_QPS); flowRule.setCount(2000); flowRule.setLimitApp("default"); FlowRuleManager.loadRules(Collections.singletonList(flowRule)); // 热点参数规则:按 accountId 限流,单账户 QPS 不超过 300 ParamFlowItem item = new ParamFlowItem().setObject(String.valueOf(0)).setClassType(String.class.getName()); ParamFlowRule hotRule = new ParamFlowRule("balance:deduct") .setParamIdx(0) .setCount(300) .setParamFlowItemList(Collections.singletonList(item)); ParamFlowRuleManager.loadRules(Collections.singletonList(hotRule));接口侧注解:
@SentinelResource(value = "balance:deduct", blockHandler = "deductBlockHandler", fallback = "deductFallback") public DeductResult deduct(String accountId, BigDecimal amount, String requestId) { // 扣减逻辑 } public DeductResult deductBlockHandler(String accountId, BigDecimal amount, String requestId, BlockException ex) { // 被限流/熔断后的快速失败逻辑 return DeductResult.fail("系统繁忙"); }使用 Sentinel 时有个经验:限流阈值需要根据压测数据设置,我一般是压测拿到单机瓶颈值之后,设置 60%~70% 作为线上阈值,留出缓冲空间。同时结合日志和监控动态调参,而不是一次性定死。
4.3 流控防护的实际收益
很多人担心限流会误伤正常用户。实际上,一个好的限流策略是在保护系统可用性的前提下,尽量让正常请求通过。Sentinel 的“热点参数限流”特别适合余额扣减,因为大多数扣减请求集中在少数热点账户上,按账户限流比全局 QPS 限流更精准。
我见过一个活动场景,某账户的扣减流量瞬间冲到 1 万 QPS,数据库完全扛不住。加了 Sentinel 热点限流后,单账户 300 QPS 以内正常扣减,超过的快速返回“稍后再试”,数据库压力立刻降下来,整体成功率反而提高了,因为不再有大批量超时和死锁重试。
5. 通用余额扣减的完整实现流程
5.1 整体流程设计
前面讲的是各个模块,这里串一下最接近生产环境的完整链路。我在实际项目中搭过的扣减链路大体如下:
- 客户端/上游服务发起扣减请求,携带
requestId(全局唯一)和bizOrderId(业务单号)。 - 网关/接入层先通过 Sentinel 做限流熔断。
- 扣减服务先查幂等表,如果已经有成功记录,直接返回之前的结果。
- 根据账户类型和业务场景选择扣减通道:
- 常规账户:走数据库条件更新。
- 热点账户:走 Redis Lua 原子扣减 + 异步落库。
- 扣减成功后记录流水,写账户流水表和幂等表。
- 发送消息到 MQ,触发后续的积分、通知、对账等流程。
5.2 数据库扣减的完整代码示例
用一个 Spring Boot 项目举例,核心 Service 代码:
@Service public class AccountDeductService { @Autowired private AccountMapper accountMapper; @Autowired private AccountFlowMapper accountFlowMapper; @Transactional(rollbackFor = Exception.class) public DeductResult deduct(Long accountId, BigDecimal amount, String requestId) { // 幂等检查 if (accountFlowMapper.existRequest(requestId)) { return DeductResult.success("重复请求,已处理"); } // 条件更新,余额充足才会更新成功 int rows = accountMapper.deductBalance(accountId, amount); if (rows == 0) { return DeductResult.fail("余额不足"); } // 写入流水 AccountFlow flow = new AccountFlow(); flow.setAccountId(accountId); flow.setRequestId(requestId); flow.setAmount(amount); flow.setType("DEDUCT"); accountFlowMapper.insert(flow); return DeductResult.success(); } }Mapper 方法:
@Mapper public interface AccountMapper { @Update("UPDATE account SET balance = balance - #{amount} " + "WHERE account_id = #{accountId} AND balance >= #{amount}") int deductBalance(@Param("accountId") Long accountId, @Param("amount") BigDecimal amount); }注意这里流水表和扣减在同一个事务里,订单幂等表也建议在同一事务写。如果requestId已经存在,事务直接回滚,保证重复请求扣不动款。
5.3 Redis 通道的异步落库示例
热点账户走 Redis 扣减后,异步落库是关键。我常用的方式是把“待落库流水”先存到本地消息表,然后由定时任务拉取。
public DeductResult deductWithRedis(Long accountId, BigDecimal amount, String requestId) { Long result = redisDeduct(accountId, amount, requestId); if (result == null || result == 0) { return DeductResult.fail("余额不足"); } // 写本地消息表,等待异步落库 DeductMessage message = new DeductMessage(); message.setAccountId(accountId); message.setAmount(amount); message.setRequestId(requestId); message.setStatus("PENDING"); deductMessageMapper.insert(message); return DeductResult.success(); } // 定时任务,同步到数据库 @Scheduled(fixedDelay = 2000) public void processPendingMessages() { List<DeductMessage> pendingList = deductMessageMapper.selectPendingList(100); for (DeductMessage msg : pendingList) { try { accountDeductService.deduct(msg.getAccountId(), msg.getAmount(), msg.getRequestId()); msg.setStatus("DONE"); deductMessageMapper.updateStatus(msg); } catch (Exception e) { // 记录失败,等待下轮重试,并触发告警 log.error("落库失败", e); } } }5.4 幂等设计是整个扣减的地基
没有幂等的余额扣减,在高并发下一定会出大问题。重试机制、MQ 重投、前端重复提交都可能触发多次扣款。
幂等设计我总结为三个层面:
- 请求层幂等:
requestId由上游生成,每次业务请求唯一,服务端记录处理结果。 - 数据库层幂等:在流水表或幂等表上加唯一索引,用数据库约束兜底。
- Redis 层幂等:在扣减脚本里用
SET NX做短期去重,防止重复扣减。
三者的顺序不能乱:Redis 去重先挡住突发重复,数据库唯一索引兜底,流水表做长期追溯。全部降级了以后,还能靠对账发现并人工修复。
6. 常见问题与排查技巧实录
6.1 余额扣成负数了,先查这三个地方
余额负数几乎每个团队都遇到过,排查流程我建议按以下顺序来:
- 检查 SQL 里是否忘了加
balance >= amount条件,很多初级开发会写成balance = balance - amount。 - 检查流水表和余额表是否在同一个事务里,如果不在,扣余额成功但写流水失败,对账时就会觉得“无法溯源”。
- 检查是否引入了非事务的异步扣减,比如直接操作 Redis 扣减但落库失败,Redis 里出现负数而数据库没有记录。
我遇到最奇葩的一次,是上线时把测试环境和生产环境的 Lua 脚本弄混了,生产扣减脚本少了余额判断,结果活动十分钟内把账户扣成负几千。那次之后我加了一条硬性规范:所有扣减脚本必须以脚本文件单独存放,并且执行前先打印内容比对哈希,严禁直接在 Redis 控制台粘贴执行。
6.2 重复扣款,问题出在幂等失效
重复扣款的根因基本都在幂等链路断裂。常见场景是:扣减成功了,但返回响应超时,上游发起重试;如果重试请求带着新的requestId,就会绕开幂等,扣两次款。
解决办法有两个:
- 下单、支付、扣减全链路共用同一个业务单号,并以业务单号作为幂等键。
- 分阶段幂等:订单状态已经变成“已支付”后才允许扣款,已扣过款的订单再次回调时直接返回原结果。
另外,幂等表记录建议保留至少 30 天,我见过跨月对账才发现重复扣款的场景,保留时间太短会非常被动。
6.3 单账户热点导致数据库连接打满
热点账户问题在数据库扣减里很典型:一个热门账户的扣减请求把所有数据库行锁占死,其他账户的简单查询也被拖慢。
处理方式我总结了一条路径,从轻到重:
- 第一步:加 Sentinel 热点参数限流,单账户设置合理阈值。
- 第二步:把热点账户的扣减迁到 Redis 通道,数据库只做异步落库。
- 第三步:如果热点流量持续存在,做账户拆分,把一个逻辑账户拆成多个子账户,扣减时轮询或哈希选择子账户,再汇总余额。
第三步要非常谨慎,因为会引入子账户余额汇总的不一致问题,需要配合定期对账。通常到第二步已经能解决 95% 的场景。
6.4 扣减链路监控指标
扣减系统上线后,至少要关注这几个指标:
| 指标 | 说明 | 建议告警阈值 |
|---|---|---|
| 扣减成功率 | 成功扣减数 / 总请求数 | 低于 95% 告警 |
| 数据库行锁等待时间 | 平均等待时长 | 大于 200ms 告警 |
| Redis 扣减失败率 | Lua 脚本返回 0 的比例 | 超过 20% 关注是否余额不足 |
| 异步落库积压 | 待落库消息数 | 持续超过 10 万告警 |
| 限流触发量 | Sentinel Block 次数 | 突然上升说明流量异常 |
我见过很多团队只监控接口 QPS,完全不看锁等待和落库积压,结果问题爆发时已经晚了。扣减系统的健康度不能只看 QPS,要看整个链路的队列水位和延迟。
7. 方案对比与选择建议
为了让你选型时更直观,我把几种主流方案放在一张表里:
| 方案 | 一致性 | 性能 | 复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库悲观锁 | 强一致 | 低 | 低 | 后台调账、管理端操作 |
| 数据库条件更新(乐观锁/余额条件) | 强一致 | 中 | 低 | 常规下单、积分扣减 |
| Redis Lua 原子扣减 + 异步落库 | 最终一致 | 高 | 高 | 秒杀、热点账户、送礼高并发 |
| 消息队列异步扣减 | 最终一致 | 高 | 高 | 对实时性要求低的批量扣减 |
选择建议:如果你的业务没有强监管要求,先做数据库条件更新;如果确实出现单账户热点,再上 Redis 通道;如果整个系统有实时的流量压力,再叠加 Sentinel 流控。不要在第一天就把所有组件都上齐,复杂的链路对运维、监控、故障排查的要求都会成倍提升。
我在实际项目中有一个体会:把数据库条件更新作为最基础的保底通道,把 Redis 扣减作为加速通道,把 Sentinel 作为保护公共资源的手段,这三层组合几乎能覆盖所有常见的余额扣减场景。最后再靠对账机制做最终兜底,整个系统才算真正稳。每次做技术方案评审,我都会问对方一个问题:如果 Redis 全挂了,你的余额数据还能从数据库恢复出正确结果吗?如果能,才算合格。