扣款接口,最直觉的写法:
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 那层纯粹多此一举,反而多出来一堆一致性问题要处理。数据库行锁虽然慢,但它替你把原子性、持久性、回滚全兜住了,这些保障一旦自己来做,每一个都是生产事故的潜在来源。