高并发下的热点账户余额扣减: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大神实战圈2 天前
DTO、VO、PO 到底要不要拆?一篇讲透 Java 实体分层的底层逻辑
场景设计题
Rain的Java大神实战圈3 天前
百万数据Excel如何快速导入导出
场景设计题
Rain的Java大神实战圈4 天前
如何快速上传10G文件
场景设计题
Rain的Java大神实战圈7 天前
数据脱敏是怎么做的
场景设计题
Rain的Java大神实战圈8 天前
对加密的手机号如何进行模糊查询
场景设计题
Rain的Java大神实战圈9 天前
如何自定义一个MyBatis插件
场景设计题
Rain的Java大神实战圈10 天前
高并发下的抽奖系统:不只是扣库存,还要防作弊与风控
场景设计题
Rain的Java大神实战圈13 天前
高并发下的秒杀系统架构全景:从单体到云原生的演进之路
场景设计题
Rain的Java大神实战圈14 天前
Spring事务失效的场景有哪些
场景设计题
Rain的Java大神实战圈15 天前
高并发下的排行榜系统:如何用Redis快速实现千万用户实时排名
场景设计题