高并发下的热点账户余额扣减
针对高并发下热点账户余额扣减场景,核心方案是Redis + Lua 脚本实现无锁原子记账,彻底解决传统数据库行锁的热点竞争问题,是互联网高并发记账场景的标准落地方案,以下是完整方案说明:
为什么这是个棘手的问题 🔥
热点账户扣减的本质是高并发写同一行数据 。在传统 MySQL 里,大量 update account set balance = balance - ? where id = ? and balance >= ? 会围绕行锁排成一条长队,数据库 CPU 飙升、连接池打满,TPS 很难超过几千。
这就逼着我们把热数据前置到 Redis ,利用 Redis 的 单线程命令执行 特性来做原子扣减。
⚡ 方案核心解决的痛点
传统数据库扣减依赖数据库行锁,标准写法:
sql
UPDATE account SET balance = balance - ? WHERE user_id = ? AND balance >= ?
- 核心问题:热点账户(平台对公户、头部大 B 商户)会形成单行锁竞争,并发上来后大量事务等待锁释放,单库 TPS 通常只能到几百级,完全扛不住高并发流量。
- 无锁方案核心思路:把余额扣减前置到 Redis,利用 Redis 单线程模型 + Lua 脚本原子执行特性,全程无需业务加锁,单 key 即可支撑万级以上 QPS,性能比数据库方案高两个数量级。
🔑 无锁记账的核心原理
这套方案本质是业务层完全无锁,核心依托 Redis 两个原生特性保障原子性与并发安全:
- Redis 单线程执行模型:所有命令串行执行,天然不存在多线程竞态条件
- Lua 脚本原子打包:把「幂等校验 + 余额查询 + 合法性校验 + 余额扣减」打包成一个脚本,Redis 会一次性完整执行,中间不会插入其他请求,从根源规避并发超扣问题,不需要额外加分布式锁。
我们不是真的"无锁",而是把锁的竞争交给了 Redis 单线程事件循环去排队,业务代码层面完全感知不到锁的存在。Lua 脚本在 Redis 里以原子方式执行,天然就避免了多线程的并发问题。
📝 核心 Lua 脚本实现
脚本是整个方案的核心,必须保证逻辑闭环,写法如下:
lua
-- KEYS[1]: 账户余额key 例:account:balance:1001
-- KEYS[2]: 幂等流水key 例:account:idempotent:req20260716001
-- ARGV[1]: 扣减金额
-- ARGV[2]: 幂等key过期时间(秒)
-- 1. 幂等校验:重复请求直接返回成功,避免重复扣减
if redis.call('exists', KEYS[2]) == 1 then
return 1 -- 返回码约定:1=重复请求,业务层判定成功
end
-- 2. 查询当前账户余额
local balance = tonumber(redis.call('get', KEYS[1]) or '0')
local deductAmount = tonumber(ARGV[1])
-- 3. 余额不足,扣减失败
if balance < deductAmount then
return -1 -- 返回码约定:-1=余额不足
end
-- 4. 余额充足,原子执行:余额扣减 + 写入幂等标记
redis.call('decrby', KEYS[1], deductAmount)
redis.call('setex', KEYS[2], ARGV[2], '1')
return 0 -- 返回码约定:0=扣减成功
✅ 关键收益:网络往返一次,判断+扣减原子完成,没有 CAS 重试,不需要分布式锁。
📊 完整执行流程图
为什么不选其他 Redis 方案? 🤔
| 方案 | 问题 |
|---|---|
WATCH + MULTI 事务 |
乐观锁模式,热点 key 冲突严重时会疯狂重试,浪费 CPU,吞吐量反而下降 📉 |
Redisson 分布式锁 |
引入额外锁开销,热点下同样有锁竞争,且一旦超时误释放会出大问题 💣 |
直接用 DECRBY |
无法做"余额不能小于0"的校验,直接扣成负数 🚫 |
所以 Lua 脚本原子扣减 是热点余额场景的最佳平衡:零重试、强一致、低延迟。
📋 方案核心维度对比
| 对比维度 | 数据库行锁方案 | Redis+Lua 无锁方案 |
|---|---|---|
| 并发性能 | 百级 TPS,热点行锁等待严重 | 万级 + TPS,无锁竞争开销极低 |
| 原子性保障 | 依赖数据库行锁 + 事务 | Lua 脚本单线程原子执行 |
| 超扣风险 | 行锁下无风险,但锁开销大 | 脚本内原子判断,零超扣风险 |
| 一致性模型 | 强一致性 | 最终一致性,对账兜底 |
| 适用场景 | 低并发普通账户 | 高并发热点账户 |
关于这个问题的底层原理和更多实战细节,我整理了一份《大厂面试手册》,包含大厂高频面试题、源码解析和性能调优案例。
关注公众号【Rain的Java大神之路】,回复"Java"即可免费领取,持续更新中。
核心 Java 实现代码
基于 Spring Boot + Spring Data Redis 标准技术栈,全链路贴合大厂生产规范。
Lua 脚本预加载配置
技术亮点:提前编译脚本 + evalsha 调用,避免每次请求传输完整脚本,减少网络 IO 与 Redis 重复编译开销,性能比直接 eval 提升 30% 以上
java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.redis.core.script.DefaultRedisScript;
@Configuration
public class RedisLuaConfig {
@Bean
public DefaultRedisScript<Long> balanceDeductScript() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
// 脚本存放路径:resources/lua/balance_deduct.lua
script.setScriptLocation(new ClassPathResource("lua/balance_deduct.lua"));
script.setResultType(Long.class);
return script;
}
}
核心记账服务实现
技术亮点:全程无任何业务锁 / 分布式锁,原子性由 Lua 脚本兜底;统一返回码枚举,全链路语义清晰;金额以分为单位规避浮点精度问题;内置幂等兼容重试场景
java
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Arrays;
@Service
@RequiredArgsConstructor
public class AccountBalanceService {
private final StringRedisTemplate stringRedisTemplate;
private final DefaultRedisScript<Long> balanceDeductScript;
// 余额key前缀
private static final String BALANCE_KEY_PREFIX = "account:balance:";
// 幂等key前缀
private static final String IDEMPOTENT_KEY_PREFIX = "account:deduct:idempotent:";
// 幂等key过期时间:24小时
private static final long IDEMPOTENT_EXPIRE_SEC = 86400L;
/**
* 余额扣减核心方法
* @param accountId 账户ID
* @param deductAmount 扣减金额(单位:分,规避浮点精度问题)
* @param requestNo 业务唯一流水号(幂等校验用)
* @return 扣减结果枚举
*/
public DeductResultEnum deduct(Long accountId, Long deductAmount, String requestNo) {
// 组装Redis Key
String balanceKey = BALANCE_KEY_PREFIX + accountId;
String idempotentKey = IDEMPOTENT_KEY_PREFIX + requestNo;
// 执行Lua脚本:所有逻辑原子串行执行,无并发竞态
Long result = stringRedisTemplate.execute(
balanceDeductScript,
Arrays.asList(balanceKey, idempotentKey),
String.valueOf(deductAmount),
String.valueOf(IDEMPOTENT_EXPIRE_SEC)
);
// 按约定返回码映射业务结果
return switch (result.intValue()) {
case 0 -> DeductResultEnum.SUCCESS;
case 1 -> DeductResultEnum.REPEAT_REQUEST;
case -1 -> DeductResultEnum.BALANCE_NOT_ENOUGH;
default -> DeductResultEnum.SYSTEM_ERROR;
};
}
// 统一返回码枚举
public enum DeductResultEnum {
SUCCESS(0, "扣减成功"),
REPEAT_REQUEST(1, "重复请求,幂等拦截"),
BALANCE_NOT_ENOUGH(-1, "余额不足"),
SYSTEM_ERROR(-99, "系统异常");
private final int code;
private final String desc;
DeductResultEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
public int getCode() {
return code;
}
public String getDesc() {
return desc;
}
}
}
进阶:热点分片扣减核心逻辑
技术亮点:解决单热点 Key 性能天花板,通过分片打散实现线性扩容,单账户 QPS 可从 10w 级提升至百万级
java
import java.util.concurrent.ThreadLocalRandom;
// 分片数:根据预估峰值QPS设置,10分片对应10倍吞吐量提升
private static final int SHARDING_COUNT = 10;
/**
* 分片版余额扣减:随机选择分片执行扣减,均匀打散热点请求
*/
public DeductResultEnum shardingDeduct(Long accountId, Long deductAmount, String requestNo) {
// 随机生成分片索引,将请求均匀打散到多个Key上
int shardIndex = ThreadLocalRandom.current().nextInt(SHARDING_COUNT);
// 分片余额key格式:account:balance:1001:0 ~ account:balance:1001:9
String shardBalanceKey = BALANCE_KEY_PREFIX + accountId + ":" + shardIndex;
String idempotentKey = IDEMPOTENT_KEY_PREFIX + requestNo;
// 执行分片扣减脚本(单分片余额不足时可扩展跨分片兜底逻辑)
Long result = stringRedisTemplate.execute(
balanceDeductScript,
Arrays.asList(shardBalanceKey, idempotentKey),
String.valueOf(deductAmount),
String.valueOf(IDEMPOTENT_EXPIRE_SEC)
);
return mapResult(result);
}
// 结果映射复用通用逻辑
private DeductResultEnum mapResult(Long result) {
return switch (result.intValue()) {
case 0 -> DeductResultEnum.SUCCESS;
case 1 -> DeductResultEnum.REPEAT_REQUEST;
case -1 -> DeductResultEnum.BALANCE_NOT_ENOUGH;
default -> DeductResultEnum.SYSTEM_ERROR;
};
}
🎯 核心技术难点与工业级解决方案
| 技术难点 | 问题本质 | 工业级解决方案 |
|---|---|---|
| 单热点 Key 性能天花板 | Redis 单线程模型下,单个 Key 的读写 QPS 上限约 10w,超头部热点账户会打满单实例瓶颈 | 余额分片打散:将单个账户余额拆分为 N 个分片 Key,扣减时随机选择分片执行,总余额为所有分片之和,实现吞吐量线性扩容;配合定时分片间余额调度,避免单分片余额不足导致的扣减失败 |
| Redis 与数据库数据一致性 | 异步落库架构下,MQ 丢失、服务宕机、Redis 故障都会导致两端余额偏差,直接引发资损风险 | 三级一致性保障:1. 正常链路:事务消息 MQ 保证落库消息不丢失2. 实时兜底:大额交易同步校验 DB 余额做双重校验3. 日终对账:全量跑批核对 Redis 与 DB,差异自动生成补正流水 |
| 重复请求导致重复扣减(幂等性) | 接口重试、MQ 重投、网络超时重发会导致同一笔交易多次执行扣减,是资损高频场景 | Lua 脚本内置原子幂等:以业务流水号为唯一标识,扣减操作与幂等标记写入打包在同一脚本内,重复请求直接拦截返回成功,彻底避免「先查再扣」的竞态漏洞 |
| Redis 宕机数据丢失风险 | Redis 为内存存储,未持久化的数据会随实例宕机丢失,直接造成余额错误 | 高可用兜底:1. 开启 AOF+RDB 混合持久化,数据丢失窗口控制在 1 秒内2. 主从 + 哨兵集群部署,故障秒级自动切换3. 故障恢复后自动从数据库全量预热余额,兜底数据正确性 |
| 余额不足的流量击穿 | 账户余额为 0 时,大量无效扣减请求持续打到 Redis,占满带宽影响正常业务 | 应用层本地缓存降级:余额不足的账户在本地缓存短时间标记,后续请求直接在应用层返回,无需穿透到 Redis,减少无效请求占比 |
| Redis 集群整体故障的可用性 | Redis 集群全挂时,记账业务不能完全瘫痪,需保障核心交易能力可用 | 多级降级开关:Redis 不可用时自动降级为数据库乐观锁方案(version版本号控制),牺牲部分性能保障核心交易可用,故障恢复后自动切回 Redis 方案 |
🎯 分桶扣减流程再画一遍,加深印象
✨生产级进阶优化细节
- 余额预热机制:账户首次操作 / 系统启动时,将数据库余额预热到 Redis,避免 Redis 无数据导致扣减逻辑异常。
- 金额单位规范:所有金额统一以分(整数)为单位存储与计算,彻底规避浮点运算精度丢失问题。
- 风控前置拦截:扣减前先做风控校验(异常金额、高频操作、账户冻结状态),拦截无效请求减少 Redis 压力。
⚠️ 常见踩坑避坑指南
- 严禁将
get查询和decrby扣减拆成两个 Redis 命令调用,并发场景下必然出现超扣,必须全部打包进 Lua 脚本。 - Lua 脚本必须短小精悍,不能写入耗时逻辑,否则会阻塞 Redis 主线程,影响整个实例的可用性。
- 幂等 key 必须设置合理的过期时间,避免无限占用 Redis 内存。
- Redis 必须开启 AOF+RDB 混合持久化,防止实例宕机后余额数据丢失。
- 禁止在 Lua 脚本中实现复杂跨分片逻辑,避免脚本执行超时引发 Redis 阻塞。
🎯 方案总结
Redis + Lua 用单线程的天然原子性,替代了业务层显式的行锁或分布式锁,实现了一种"无感"的高并发余额扣减。
实践中配合分桶打散 + 异步落库,抗住几十万 TPS 是经过大促验证的成熟模式。
总的来说,这套方案的核心优势是用 Redis 内存操作的高性能 + Lua 脚本的原子性,以无锁编程模型解决了热点账户的并发扣减问题,兼顾性能与正确性,同时通过异步落库 + 对账机制保证最终一致性。
如果本文对你有帮助,欢迎关注我的公众号【Rain的Java大神之路】。
专注 Java 面试、源码、高并发实战,回复"Java"领取《大厂面试手册》,持续更新。