高并发下的热点账户余额扣减:Redis+Lua脚本实现无锁记账

高并发下的热点账户余额扣减

针对高并发下热点账户余额扣减场景,核心方案是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 两个原生特性保障原子性与并发安全:

  1. Redis 单线程执行模型:所有命令串行执行,天然不存在多线程竞态条件
  2. 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 方案

🎯 分桶扣减流程再画一遍,加深印象

✨生产级进阶优化细节

  1. 余额预热机制:账户首次操作 / 系统启动时,将数据库余额预热到 Redis,避免 Redis 无数据导致扣减逻辑异常。
  2. 金额单位规范:所有金额统一以分(整数)为单位存储与计算,彻底规避浮点运算精度丢失问题。
  3. 风控前置拦截:扣减前先做风控校验(异常金额、高频操作、账户冻结状态),拦截无效请求减少 Redis 压力。

⚠️ 常见踩坑避坑指南

  1. 严禁将get查询和decrby扣减拆成两个 Redis 命令调用,并发场景下必然出现超扣,必须全部打包进 Lua 脚本。
  2. Lua 脚本必须短小精悍,不能写入耗时逻辑,否则会阻塞 Redis 主线程,影响整个实例的可用性。
  3. 幂等 key 必须设置合理的过期时间,避免无限占用 Redis 内存。
  4. Redis 必须开启 AOF+RDB 混合持久化,防止实例宕机后余额数据丢失。
  5. 禁止在 Lua 脚本中实现复杂跨分片逻辑,避免脚本执行超时引发 Redis 阻塞。

🎯 方案总结

Redis + Lua 用单线程的天然原子性,替代了业务层显式的行锁或分布式锁,实现了一种"无感"的高并发余额扣减。

实践中配合分桶打散 + 异步落库,抗住几十万 TPS 是经过大促验证的成熟模式。

总的来说,这套方案的核心优势是用 Redis 内存操作的高性能 + Lua 脚本的原子性,以无锁编程模型解决了热点账户的并发扣减问题,兼顾性能与正确性,同时通过异步落库 + 对账机制保证最终一致性。


如果本文对你有帮助,欢迎关注我的公众号【Rain的Java大神之路】。

专注 Java 面试、源码、高并发实战,回复"Java"领取《大厂面试手册》,持续更新。

相关推荐
Rain的Java大神实战圈1 天前
介绍一下常用的Token鉴权方案
场景设计题
Rain的Java大神实战圈1 天前
高并发下的抢红包设计:微信红包背后的算法与温情
场景设计题
Rain的Java大神实战圈3 天前
如何设计对外API接口
场景设计题
Rain的Java大神实战圈6 天前
如何保证接口幂等
场景设计题
Rain的Java大神实战圈7 天前
PC版网站被狂刷怎么处理
场景设计题
Rain的Java大神实战圈8 天前
接口被狂刷怎么处理
场景设计题
Rain的Java大神实战圈9 天前
线上在Docker中跑MySQL会怎样
场景设计题
Rain的Java大神实战圈10 天前
Docker搭建Redis集群完全指南
场景设计题
Rain的Java大神实战圈13 天前
短信接口被狂刷怎么处理
场景设计题