高并发下怎么做余额扣减?

扣款接口,最直觉的写法:

java 复制代码
int balance = mapper.getBalance(uid);
if (balance >= amount) {
    mapper.updateBalance(uid, balance - amount);
}

查余额,够就扣。逻辑没毛病。

两个请求同时来,同时查到余额 100,各扣 80。两个都通过了余额检查,两个都执行了 UPDATE,最终余额是 20。用户花了 160,账上只扣了 80。

这段代码在开发环境跑一千次都没问题,因为开发环境没有并发。上了生产,流量一高就会出事,而且出事的概率跟并发量成正比。

把判断和扣减合并成一条 SQL

问题出在"查"和"扣"之间有时间窗口。两步操作,中间会被别的请求插进来。

最简单的修法------别分两步,合成一条 SQL:

sql 复制代码
UPDATE account SET balance = balance - 80 
WHERE uid = 1001 AND balance >= 80;

MySQL 执行 UPDATE 时会对这一行加排他锁。两个请求同时来,一个先拿到锁执行扣减,另一个等锁释放后再执行------此时 balance >= 80 已经不满足了,UPDATE 影响行数为 0,扣款失败,业务层判断 affected rows 就知道余额不足。

一条 SQL,不用 SELECT FOR UPDATE,扣减和余额检查是原子的。

实际业务里扣完余额通常还要插一条流水记录,这时候需要显式事务包起来:

java 复制代码
@Transactional
public boolean deduct(long uid, BigDecimal amount, String trxId) {
    int rows = mapper.deductBalance(uid, amount); // UPDATE ... WHERE balance >= amount
    if (rows == 0) return false;
    mapper.insertTransLog(uid, amount, trxId);    // 插流水
    return true;
}

@Transactional 保证扣余额和记流水要么一起成功,要么一起回滚。流水表的 trxId 加唯一索引,重复请求直接被数据库拦住,天然幂等。

这个方案能扛住绝大多数业务场景。用户各扣各的余额,单行并发不高,行锁的串行等待几乎感知不到。

行锁的瓶颈在哪

一条 SQL 搞定了正确性,但 InnoDB 的行锁意味着同一个用户的扣款请求只能串行执行。

正常用户,一秒钟不会发几十次扣款请求,行锁的串行等待感知不到。但有一类场景是例外------热点账户

企业发红包,一个企业账户,几万人同时来领。所有扣款请求都指向同一行数据,排他锁让它们严格排队。InnoDB 的行锁争用不光是等待------高并发下锁等待队列本身也有开销,线程挂起唤醒、死锁检测、undo log 膨胀,MySQL 内部的争用会拖慢整个实例,不只是这一行的事。

这时候一条 UPDATE 就不够用了。

Redis 预扣

思路很直接:把余额缓存到 Redis 里,扣减在 Redis 做,扣完再异步落库。

Redis 单线程,天然没有并发问题。但"查余额+判断+扣减"这三步在 Redis 里也得是原子的,不能分成三条命令,否则一样会被插队。Lua 脚本解决这个问题------整个脚本在 Redis 里原子执行,中间不会被打断:

lua 复制代码
local balance = tonumber(redis.call('GET', KEYS[1]))
if balance >= tonumber(ARGV[1]) then
    redis.call('DECRBY', KEYS[1], ARGV[1])
    return 1
end
return 0

Java 端调用:

java 复制代码
Long result = redisTemplate.execute(deductScript, 
    List.of("balance:" + uid), String.valueOf(amount));
if (result == 1) {
    // 扣减成功,发 MQ 消息异步落库
    mqProducer.send(new DeductMessage(uid, amount, trxId));
}

Redis 单机 QPS 十几万,一个热点账户扣到几万并发也不是问题。

但代价来了。

Redis 里扣成功了,异步落库的消息丢了怎么办?Redis 挂了重启,内存里的余额丢了怎么办?Redis 扣了但下游支付通道返回失败需要回滚,Redis 加回去的那一刻如果也挂了呢?

这些问题每一个都需要单独的机制来兜。MQ 消息要保证至少消费一次(confirm + 重试),落库 SQL 要做幂等(用 trxId 去重),定时对账任务比对 Redis 余额和数据库余额,不一致就告警修复。扣减回滚要记补偿日志,补偿失败要有人工介入流程。

一条 UPDATE 搞不定之后,每多加一层,就多出一类需要你自己处理的中间状态。

分账户

Redis 预扣适合"一个热点账户被高频扣减"的场景。但如果不想引入 Redis 这一层,纯数据库也有办法------把一个账户拆成多个子账户。

企业账户余额 100 万,拆成 10 个子账户,每个 10 万。扣款请求进来,按某种规则(取模、随机)分到不同子账户上扣。原来所有请求争一把行锁,现在争 10 把,并发能力直接提升 10 倍。

sql 复制代码
-- 子账户表
CREATE TABLE account_shard (
    id BIGINT PRIMARY KEY,
    uid BIGINT NOT NULL,
    shard_id INT NOT NULL,
    balance DECIMAL(18,2) NOT NULL,
    UNIQUE KEY uk_uid_shard (uid, shard_id)
);

-- 扣款时随机选一个子账户
UPDATE account_shard 
SET balance = balance - 80 
WHERE uid = 1001 AND shard_id = 3 AND balance >= 80;

如果选中的子账户余额不够呢?两个选择:换一个子账户重试,或者触发子账户间的余额归集(把其他子账户的余额转一部分过来)。余额归集本身也需要事务保证,又是一层复杂度。

支付宝、微信支付这类系统在底层用的就是分账户的思路。但它们的实现比这复杂得多------子账户数量动态调整、余额归集策略、跨分片事务、资金对账......养着一个专门的资金核心团队来维护这套东西。

你的账户到底热不热

上面从一条 UPDATE 走到 Redis 预扣再到分账户,复杂度涨了十倍不止。但回到实际业务里,绝大多数扣款场景压根不存在热点账户------用户 A 扣用户 A 的钱,用户 B 扣用户 B 的,行锁之间互不干扰,一条 UPDATE 跑得稳稳的。

真正需要 Redis 预扣或者分账户的场景,往往就那么几个:企业发红包、平台活动钱包、主播打赏归集账户。这些场景在整个系统里可能只占一两张表,剩下几十张表的扣减逻辑用一条 SQL 就够了。

但很多方案设计是反过来做的------还没确认有没有热点账户,先把 Redis 预扣+异步落库+对账全搞上了。结果大部分账户一秒钟就扣一两次,Redis 那层纯粹多此一举,反而多出来一堆一致性问题要处理。数据库行锁虽然慢,但它替你把原子性、持久性、回滚全兜住了,这些保障一旦自己来做,每一个都是生产事故的潜在来源。

相关推荐
SimonKing1 小时前
Java 图片处理还在用 ImageIO?这个库让你代码从 30 行变 3 行
java·后端·程序员
嘻哈baby1 小时前
Rust 是不是就相当于新时代的 C 语言?
后端
IT_陈寒1 小时前
Redis持久化配置漏了这一步,线上数据丢了5小时
前端·人工智能·后端
Moment1 小时前
太好了!NestJS 12 大版本转向 ESM,新项目默认构建换 Rspack
前端·javascript·后端
名字还没想好☜2 小时前
Go 用 -race 抓数据竞争:一个偶发崩溃的排查、原理与修复
开发语言·后端·golang·go
学习星球2 小时前
CodeWhale 深度剖析:从 DeepSeek-TUI 到 40.9K Star 的 Rust 终端编程 Agent
开发语言·后端·rust
Rain的Java大神之路2 小时前
高并发下的热点账户余额扣减:Redis+Lua脚本实现无锁记账
java·spring boot·redis·后端·spring cloud·缓存·lua
kgduu2 小时前
moduo之http
后端
烂蜻蜓5 小时前
Flask入门教程(二十六):Session API——用户会话状态管理
后端·python·flask