双十一那天,我盯着监控面板上一张account表的热点行锁等待曲线,整个人都有点懵。同一个营销账户,同一秒内涌入了几十笔扣减请求,数据库里那行余额记录的锁等待时间直接飙到了秒级。紧接着告警群开始刷屏:“订单支付成功但余额未扣减”“用户余额变成负数”“扣减成功但流水查不到”。
当时团队里争论最多的问题就是:要不要立刻上 Redis?要不要做账户拆分?
我的回答是:先别急。你首先需要确认的不是“用什么中间件扛并发”,而是“一次余额扣减,在并发场景下到底做没做对”。高并发下的余额扣减,真正难的不是减法本身,而是多请求同时到达时,保证不超扣、不重复、可追溯。性能优化做得再漂亮,只要这三个前提塌掉,后期就是没完没了的对账和赔钱。
1. 先搞清楚:余额扣减为什么会成为高并发痛点
1.1 真正的瓶颈不是接口 QPS,而是单账户行的锁竞争
很多人一听到“高并发扣减”,第一反应是“我要一个高吞吐的方案”。但先把模型拆开看:
一个账户的余额,在数据库里大概率就是一行记录:
SELECT id, balance FROM account WHERE id = 100001;用户下单、支付、退款、后台调整余额,最终都会落到这一行记录上。数据库为了保证并发写入的正确性,会对这一行加锁。并发量越大,锁等待越严重,事务耗时越长,最终表现为接口 RT 变大、超时、死锁。
所以真实瓶颈不是一个系统每秒能处理多少请求,而是同一个账户在同一行记录上的行锁竞争。10000 个用户同时给同一个账户充值和消费,对数据库来说,冲突全集中在那一条记录上。
1.2 最典型的错误写法是“先查余额,再扣余额”
很多新人第一次触达这个场景时,会写出类似这样的逻辑:
// 错误示例:并发下会超扣 BigDecimal balance = accountMapper.getBalance(accountId); if (balance.compareTo(amount) < 0) { throw new BusinessException("余额不足"); } accountMapper.updateBalance(accountId, balance.subtract(amount));看起来没毛病:先检查余额是否足够,再扣减。
但高并发下,两个请求可能同时读到同一个余额,比如 100 元。两个请求都判断“余额够扣 80 元”,然后都去执行更新,都把余额改成 20 元。实际上用户只有一个账户,却可能被扣了两次,或者产生一次本不该成功的超扣。
这类问题本质上是check-then-act这个动作不是原子的。你把“检查余额”和“修改余额”拆成了两个独立的步骤,中间没有任何并发保护。
1.3 先把“正确性”定义清楚,再谈优化
讨论方案前,我建议团队先统一“正确性”的含义。一个合格的余额扣减系统至少要满足:
- 不超扣:余额不足时,扣减必须失败,不能让余额变成负数。
- 不重复扣:同一个业务请求,重试执行多次,也不能扣多次。
- 可追溯:每笔扣减必须有流水,能查到来源、金额、时间、业务单号。
- 最终一致:如果引入缓存、消息队列、异步任务,任何一处的失败都必须能被补偿和对账发现。
这四个要求做不到,谈再多 Redis、分库分表、动静分离都是空中楼阁。
2. 第一步:先把数据库原子更新做对
2.1 一条带条件的 UPDATE 是性价比最高的方案
在任何高性能方案之前,先确认最基础的一步是否做对:把“余额足不足”的判断和“扣减动作”合并成一个原子 SQL。
UPDATE account SET balance = balance - #{amount}, update_time = NOW() WHERE id = #{accountId} AND balance >= #{amount};这条 SQL 返回的影响行数只有两种情况:
1:表示余额足够,扣减成功。0:表示账户不存在,或余额不足,扣减失败。
为什么有效?因为数据库行锁保证了这条UPDATE在同一时刻只会有一个请求真正执行成功。后面的请求必须等前面的请求提交或回滚后,才能继续判断新的余额是否足够。也就是说,“检查余额”和“扣减余额”被压缩到了同一条语句里,天然避免了并发下两个请求都读到同一个旧余额的问题。
应用层只需要判断影响行数:
public DeductResult deduct(AccountDeductCommand command) { int rows = accountMapper.deductIfEnough( command.getAccountId(), command.getAmount(), command.getRequestId() ); if (rows == 0) { return DeductResult.balanceNotEnough(); } return DeductResult.success(); }从工程经验看,很多中小型系统的高并发余额扣减问题,靠这一条 SQL 就能解决掉 80%。它不需要引入额外中间件,不需要改架构,逻辑清晰,也便于排查。
2.2 为什么balance >= amount这个条件一定要放在 WHERE 里
我见过很多“看似正确但偷偷扣超”的版本:
UPDATE account SET balance = balance - #{amount} WHERE id = #{accountId};这条 SQL 不判断余额是否足够,结果就是余额从 0 变成负的。这时候业务层再去做一次“事后检查”,已经没有意义了,因为扣减已经发生。所以,判断余额是否充足,必须和扣减作为同一条 SQL 的条件,而不是扣完再检查。
2.3 还需要加 version 乐观锁吗?
很多文章会建议在表里加一个version字段,每次更新时WHERE version = #{oldVersion}。这个方案本身没有错,但要看场景。
如果业务里只有“余额扣减”这种强竞争操作,balance >= amount已经具备 CAS 的效果。但如果业务中还可能存在并发“后台调整余额”“用户退款”等各种操作,并且你不希望旧版本覆盖新版本,那version就是一种更通用的并发控制手段。
UPDATE account SET balance = #{newBalance}, version = version + 1 WHERE id = #{accountId} AND version = #{oldVersion};实际项目里,我会保守一点:如果账户数据有多个写入入口,优先加 version;如果只有一个消费入口,可以先不加,但 WHERE 条件必须带balance >= amount。
2.4 重试可以,但必须带上业务幂等键
即使 SQL 写对了,高并发下死锁、锁等待超时依然会发生。这时应用层通常会做重试。
但重试有一个天然风险:第一个请求如果已经扣减成功,只是响应超时,客户端重试时可能再次执行扣减,导致重复扣费。
解决办法是引入业务唯一键。每个扣减请求都携带一个全局唯一的biz_id,在流水表上加唯一索引。
CREATE TABLE account_deduct_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, biz_id VARCHAR(64) NOT NULL COMMENT '业务幂等键', account_id BIGINT NOT NULL, amount DECIMAL(18,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0处理中 1成功', created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_biz_id (biz_id) );重试时先检查biz_id是否已经存在,如果存在就直接返回上一次的结果。这样,重试再多,也不会重复扣余额。
2.5 这个方案的边界在哪里
数据库原子更新方案不是万能的。
当热点账户的并发量持续升高,即使每条 SQL 只执行几毫秒,行锁等待队列也可能让整体 RT 变得不可接受。尤其是“同时给一个账户扣款”的营销场景,单行更新的吞吐上限很快会触顶。这时候才需要考虑下一层优化。
但我要强调:如果你连最基础的原子更新都没写对,就先别搞架构级优化。基础方案的问题是性能上限,不是正确性。
3. 热点账户优化:Redis 预占 + 异步落库 + 定时对账
3.1 Redis 不能当正式账本,只能做预占
当单账户行锁成为瓶颈时,很多人会想到用 Redis 来承接热点扣减。
Redis 的优势很明显:单线程模型,天然串行,DECR又快又原子。但劣势也致命:Redis 数据可能丢失,缺少事务和审计能力,难以保证和数据库的强一致。如果你把 Redis 中的余额当成“最终余额”,等于给自己埋了一个随时爆掉的雷。
更稳妥的做法是,把 Redis 当成“预占层”或“热点余额展示层”,数据库仍然作为最终账本。
流程一般是这样:
- 请求进来,带上
accountId + amount + requestId。 - 先对 Redis 中该账户的热点余额执行预占扣减。
- 预占成功,向消息队列投递一条“扣减任务”。
- 返回给前端“扣减中”或“扣减成功”。
- 异步任务消费消息,在数据库事务中执行真正的余额扣减,并写入流水表。
- 通过唯一键
biz_id保证数据库扣减只执行一次。 - 定时任务对账,发现 Redis 与数据库不一致时告警并尝试修正。
在这个设计里,Redis 解决的是“用户感知层面的并发吞吐”,数据库解决的是“最终的钱账一致”。两者各司其职,谁也别替代谁。
3.2 用 Lua 脚本保证 Redis 上的比较与扣减原子性
如果直接执行DECR,一样会有“先检查后扣减”的并发问题。推荐的写法是用 Lua 脚本把“判断余额是否充足”和“扣减”合在一起:
-- KEYS[1]:账户热点余额 key -- ARGV[1]:本次扣减金额 if tonumber(redis.call('GET', KEYS[1])) >= tonumber(ARGV[1]) then redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 end return 0Redis 会原子执行这段脚本,避免多个请求同时读到同一个余额。但这里有个必须注意的坑:Redis 里的预热余额不是凭空来的,要么来自数据库余额的加载,要么来自“预占额度”的冻结。两类操作都需要在业务流程中明确边界。
3.3 Redis 预占失败、超时、宕机后的补偿链路
引入 Redis 之后,出现问题的可能性反而变多了。常见的有:
- Redis 扣减成功,但 MQ 发送失败,数据库没扣。
- Redis 扣减成功,MQ 投递成功,但消费者执行数据库扣减时余额不足,导致 Redis 显示已扣、数据库未扣。
- Redis key 过期或数据丢失,导致预占余额全部丢失。
- 同一个业务请求因为重试,被 Redis 扣了两次。
这些问题不能靠“运气”解决,必须提前设计补偿链路:
- 所有 Redis 预占请求都要带
requestId,并在 Redis 中用 SETNX 做幂等标记。 - MQ 消费端必须做数据库扣减幂等,以
biz_id为唯一键,重复消息直接忽略。 - 数据库扣减失败时,要回补 Redis 的预占余额,或者进入待对账队列。
- 设置预占过期时间,超过时间未确认的预占自动释放。
- 每日/每小时的定时对账任务,把“Redis 预占余额 + 数据库流水”与账户余额做核对。
注意:Redis 预占方案适合营销活动、优惠券、库存、积分等不涉及严格资金审计的场景。如果账户余额是用户真实资金或平台资金,我强烈建议不要用 Redis 做最终扣减,最多用它做“可用余额的缓存预热”,最终结果以数据库流水为准。
3.4 这个方案真正改变的是什么
你会发现,引入 Redis 预占,本质上是把“同步扣减”变成了“最终一致”。用户不再需要等数据库事务提交,所以接口响应更快、吞吐更高。但代价是:流程中每一环都可能失败,每一环都需要有补偿逻辑。
很多团队栽在 Redis 方案上,不是 Redis 本身不行,而是只看到了性能收益,没看到新增的复杂度。如果你的业务能接受“短时间余额展示有延迟”,并且你有完善的对账机制,这个方案才值得上。
4. 账户拆分:并发放大的思路,容易忽略对账成本
4.1 账户拆分的基本思路
当单账户行锁实在扛不住时,还有一个思路是“把一个账户拆成多个子账户”。比如一个账户有 10000 元,拆成 10 个子账户,每个子账户余额 1000 元。每次扣减时,通过某种策略选一个子账户执行。
-- 伪代码:按子账户扣款 SELECT id, balance FROM sub_account WHERE account_id = #{accountId} AND balance >= #{amount} AND is_available = 1 LIMIT 1 FOR UPDATE; UPDATE sub_account SET balance = balance - #{amount} WHERE id = #{subAccountId};表面上看,并发能力被放大了 N 倍,因为锁从一行分散到了多行。但我必须说一句逆耳的话:账户拆分不是余额扣减的银弹,在很多强一致场景下,它是一个代价极高、容易引发资金事故的“馊主意”。
4.2 先想想拆分之后查询怎么办
账户拆完之后,一个最简单的问题就会出现:用户账户里到底还有多少钱?
你不可能只查一个子账户,你必须把该用户下所有子账户余额汇总。这本来是一个很简单的SELECT balance FROM account WHERE id = ?,拆完之后变成了一个聚合查询。一旦某个子账户数据有问题,你根本分不清是账算错了,还是拆分逻辑写错了。
再往下走,更麻烦。
用户要退款,退回到哪个子账户?退款金额怎么分摊?某个子账户余额不够退,其他子账户余额充足,要不要跨子账户调拨?跨子账户调拨是不是又要锁多行?这些每一步都是新增的事务复杂度。
更严重的是分批扣减的一致性问题。比如一个请求需要扣 500 元,A 子账户扣了 300 元,B 子账户再扣 200 元时发现余额不足,那 A 子账户已经扣完的 300 元怎么回滚?这就需要一个分布式事务或者很复杂的反向补偿。一旦补偿失败,账就对不上了。
4.3 什么场景适合拆分
账户拆分并非完全不可用,但它更适合**“非资金类余额”**,比如:
- 营销活动预算池,每个活动有独立预算编号。
- 优惠券批次,每一批有独立的库存余额。
- 虚拟货币、积分、游戏币,审计要求相对较低。
这些场景天然存在“批次”“活动”“子池”的概念,拆分不会增加额外的业务语义负担。但如果你拆的是用户真实资金账户,我建议宁可接受单账户行锁,甚至用队列把同一个账户的请求串行化,也不要把账搞乱。因为资金类的“算不清”比“慢”可怕得多。
4.4 不拆账户,也能用的降级方案:账户队列串行化
如果热点真的集中在一个账户上,还有一个非常土但有效的方法:把同一个账户的扣减请求发送到同一个队列,由单线程依次执行。
// 按 accountId 做一致性哈希,保证同一账户进入同一队列 String queueKey = "deduct_" + accountId % QUEUE_COUNT;这样,同一账户的扣减请求天然串行,不再有数据库行锁争用,也不会有并发超扣问题。缺点是吞吐被串行执行限制住了,但通常对于“某个账户一个人用自己的钱”这种场景,串行化已经完全够用。
5. 工程化兜底:流水、幂等、发件箱和对账缺一不可
5.1 没有流水,就没有“可追溯”
前面说了,一个合格的扣减系统必须可追溯。可追溯靠什么实现?靠流水。
无论你用数据库原子更新、Redis 预占还是账户拆分,每一笔扣减都必须写一条流水记录。流水中至少要有:
biz_id:业务幂等键,来自订单号、支付单号或请求方生成。account_id:账户 ID。amount:扣减金额,必须用 DECIMAL,不能用 float/double。status:处理状态,比如 0 初始、1 成功、2 失败、3 冻结。before_balance/after_balance:扣减前余额和扣减后余额,强烈建议记录。created_at:创建时间。
有了流水,后续排查、退款、对账、审计才有依据。否则等线上出了数据问题,你只能看着一个孤零零的余额数字发呆。
5.2 扣减与流水要放在同一个本地事务里
一个常见错误是:先扣减余额,再单独发一条 MQ 消息让其他系统写流水。如果 MQ 发送失败,或者消费者执行失败,就会出现“余额已经扣了,流水却没了”。
更稳的做法是:扣减余额、写流水、写发件箱表,全部放进同一个数据库本地事务。
BEGIN; -- 1. 扣减余额 UPDATE account SET balance = balance - #{amount} WHERE id = #{accountId} AND balance >= #{amount}; -- 2. 写扣减流水 INSERT INTO account_deduct_log(biz_id, account_id, amount, status) VALUES(#{bizId}, #{accountId}, #{amount}, 1); -- 3. 写一条待发送消息记录 INSERT INTO outbox_message(biz_id, topic, payload, status) VALUES(#{bizId}, 'account_deducted', #{payload}, 0); COMMIT;事务成功之后,再由一个后台任务扫描outbox_message表,把待发送消息投递到 MQ。投递成功后再更新消息表状态。这就是业界常见的“发件箱模式”。
这样做的好处是:扣款和消息的产生具备了原子性,不再出现“扣款成功但消息丢失”这类问题。
5.3 有重试就必须有幂等
无论是 HTTP 调用、RPC 调用还是 MQ 消费,都天然存在重试可能。重复消费是常态,不是偶发。所以,任何扣减入口都要通过biz_id做幂等。
幂等的实现方式可以是流水表唯一索引,也可以是 Redis 中的幂等标记。我的建议是:数据库唯一索引是底线,Redis 幂等标记是优化。靠 Redis 做幂等可能因为数据丢失而失效,但数据库唯一索引不会骗你。
5.4 对账是一种“体外兜底”
再完善的流程都会有意外:代码 Bug、人工误操作、依赖服务故障。所以,余额扣减系统必须有一套独立于业务主流程的对账机制。
最常见的对账方式是:
- 把当天所有余额变动的流水汇总成一个金额。
- 和账户余额的实际变化做比对。
- 不一致时,通过
biz_id明细定位到具体业务单号和日志。
对账周期可以按天,也可以按小时。关键不是频率,而是对账任务本身要稳定、要能触发告警、要能输出差异明细。如果对账任务自己挂了都没人发现,那等于没有对账。
6. 排查链路:余额扣减出问题时,按顺序查六层
线上余额扣减问题,通常表现为这样几类现象:
- 用户余额变成负数。
- 系统显示扣减成功,但实际余额没变。
- 重试一次,被扣了两次。
- 数据库锁等待很高,接口大量超时。
- 流水表里查不到某次扣减记录。
遇到这类问题,不要急着改代码。按下面的排查顺序走,会更快定位。
6.1 第一层:先看请求是否重复
先查流水表,看同一个biz_id是否出现多次。如果出现多次,问题一定来自幂等失效。
重点检查三处:
- 流水表上有没有
biz_id唯一索引。 - 扣减入口是否对这个
biz_id做过校验。 - 重试时是否沿用同一个
biz_id,还是每次新生成。
6.2 第二层:看扣减 SQL 是否带条件
检查UPDATE语句里是否真的有balance >= amount。如果 SQL 是裸的SET balance = balance - #{amount},那余额为负就是必然结果。
6.3 第三层:看事务边界
检查事务是否包含多余操作。比如,在扣减余额之前,事务里还查了商品、调用了外部接口、做了耗时的远程调用。这些都会会拉长行锁持有时间,从而放大锁竞争。正常应该让“余额扣减 + 写流水”这个核心动作尽量早执行,提交后其他动作再做。
6.4 第四层:看锁等待和死锁日志
数据库监控里如果出现大量Lock wait timeout exceeded,要重点分析:
- 是否有多个线程同时更新同一账户。
- 是否在事务中先更新 A 账户再更新 B 账户,而另一个事务先更新 B 再更新 A,导致死锁。
- 是否同一个账户的并发请求已经超过数据库行锁的处理能力。
6.5 第五层:看缓存和数据一致性
如果已经引入了 Redis 预占,排查时要把 Redis 里的余额和数据库流水、数据库账户余额分别做比较。重点看三种差异:
- Redis 预占扣了,但数据库流水没有对应记录。
- 数据库流水有记录,但 Redis 预占没有回补。
- 数据库流水记录多条同一个
biz_id。
6.6 第六层:看隔离级别和字段类型
一个隐蔽的坑是金额字段用了float或double。浮点数存在二进制无法精确表示的问题,累加多次之后会出现 0.1 + 0.2 != 0.3 这类精度误差。金额字段必须使用DECIMAL(18,2)甚至更长的精度。
隔离级别上,MySQL 默认的可重复读(REPEATABLE READ)在多数业务场景下可用,但如果你用了“先查余额再更新”的模式,隔离级别并不能阻止丢失更新。这个模式的问题要靠加锁或 CAS 解决,而不是调隔离级别。
7. 选型建议:不同业务阶段,怎么选最稳
很多团队问“到底该用哪种方案”,其实没有标准答案,只看你的业务场景和阶段。
| 场景 | 核心方案 | 优点 | 风险和边界 |
|---|---|---|---|
| 普通电商账户余额、积分扣减 | 数据库原子 UPDATE + 幂等表 + 重试 | 简单、稳定、可审计 | 单账户高并发场景下,行锁等待会变大 |
| 营销活动账户、优惠券、热点库存 | Redis 预占 + 异步扣款 + 定时对账 | 吞吐高,响应快 | 最终一致,流程复杂,必须做好补偿和对账 |
| 用户资金、钱包等强一致场景 | 行锁/CAS + 同一账户请求队列化 | 正确性最高,审计干净 | 单账户允许的并发有限,不适合极高 QPS |
| 非资金的热点账户 | 预算池拆分 / 批次拆分 | 并发放大明显 | 查询、退款、对账成本高,不能用于严格账务 |
如果把选择逻辑收敛成一句话,应该是:先判断这个业务能不能接受最终一致,再决定要不要引入缓存和异步;先判断账户拆分会不会增加对账成本,再决定要不要动余额结构。
对于大多数中小团队,我的建议非常保守:
- 第一版直接用数据库原子更新,配合
biz_id唯一索引。 - 数据库扛不住的时候,先试试“同一账户串行队列”,而不是立刻上 Redis。
- 只有确认热点账户是绝对瓶颈,且团队具备补偿和对账能力,再引入 Redis 预占。
- 账户拆分能不做就不做,尤其资金类账户。
8. 回到主判断:先保证正确性,再谈吞吐
聊了这么多方案,最后还是想回到文章开头的那句话:高并发下的余额扣减,要先保证不超扣、不重复、可追溯,然后再去优化性能。
我见过太多团队,一上来就奔着 Redis、消息队列、分库分表去。方案文档写得很漂亮,架构图画得很完整,结果上线后连最基础的“余额不足时这条 SQL 是否返回 0”都没验证过,最后被对账数据打脸。
反过来,那些做得稳的系统,往往在最底层扣减 SQL 上花了很多时间,把 WHERE 条件、幂等键、流水、对账,每一层都钉得死死的。它们可能看起来“不够高级”,但真正的生产价值恰恰在于“出了问题时,你能在半小时内定位到是哪一笔、哪个账户、哪个环节出了问题”。
余额扣减没有一滴永逸的方案。如果需要给一个排序:先做对数据库原子更新,再做热点队列化或 Redis 预占,最后才考虑账户拆分。每一步都要有明确的性能数据和补偿机制来支撑,而不是跟着热门技术名词走。
下次再有人问你“高并发下余额扣减怎么做”,你也许可以这样回答:先把一条 UPDATE 写对,把流水表建好,再谈并发。