Redis 无锁化库存扣减方案(INCR + SETNX 实现,高并发不超卖)

在高并发场景(如秒杀、抢购)中,库存扣减是核心业务,也是最容易出现问题的环节------超卖、锁竞争、死锁、不知道扣减对应哪个库存节点,这些都是开发者常踩的坑。

本文将分享一种 Redis INCR + SETNX 组合的无锁化库存扣减方案,彻底解决上述问题,实现高并发下的安全扣减,同时清晰追踪每一次扣减对应的库存节点,适合博客分享、项目落地和面试参考。

一、方案背景与核心痛点

在传统库存扣减中,我们常遇到以下问题:

  • 用 Redis DECR 直接扣减:高并发下可能出现超卖(虽 Redis 单线程原子性,但多请求判断库存+扣减非原子操作);

  • 用 SETNX 做分布式锁:存在严重锁竞争,高并发下大量请求阻塞或失败,性能低下;

  • 多次扣减时,无法追踪每一次扣减对应的具体库存节点,排查问题困难;

  • 引入 Lua 脚本虽能解决问题,但部分开发者对 Lua 不熟悉,维护成本高。

基于此,我们采用「INCR 生成唯一序号 + SETNX 抢占令牌」的无锁思路,既保证高并发安全,又能清晰追踪每一次扣减,且无需复杂脚本,易于理解和落地。

二、核心原理(无锁化的关键)

方案的核心是「不竞争同一个库存 key,用唯一序号标记每一次扣减」,利用 Redis 原子命令的特性,实现无锁化、高并发、不超卖。

1. 核心命令解析

  • INCR 命令:Redis 原子自增命令,每次执行对指定 key 的值 +1,即使并发 10000+ 请求,也能保证生成的序号唯一、不重复、不乱序(Redis 单线程执行,天然原子性)。

  • SETNX 命令:Redis 分布式锁核心命令(SET if Not eXists),只有当 key 不存在时才会设置成功,用于抢占「库存令牌」,保证同一个序号不会被多个请求占用。

2. 无锁化逻辑流程

整个流程无需任何分布式锁,完全并行执行,步骤如下:

  1. 初始化商品库存:在 Redis 中存储商品的总库存(如商品 ID=1001,总库存=100);

  2. 请求到来时,通过 INCR 命令生成一个全局唯一的自增序号(序号从 1 开始,依次递增);

  3. 判断序号是否超过总库存:若序号 > 总库存,说明库存不足,扣减失败;

  4. 若序号 ≤ 总库存,用 SETNX 命令抢占该序号对应的「库存令牌」(key 格式固定,包含商品 ID 和序号);

  5. SETNX 抢占成功,说明扣减成功(该序号对应的库存被当前请求占用);抢占失败,说明该序号已被其他请求占用,扣减失败。

关键优势:每个请求对应唯一序号,抢占的是自己的「专属令牌」,不会和其他请求竞争同一个 key,实现真正的无锁化。

三、完整方案实现(Spring Boot + Redis)

以下是可直接落地的代码实现,基于 Spring Boot + Spring Data Redis,包含核心方法、配置、测试说明,复制即可使用。

1. 依赖配置(pom.xml)

引入 Redis 相关依赖,确保项目能正常操作 Redis:

复制代码
复制代码
<!-- Spring Data Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<!-- 连接池依赖(可选,提升性能) -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

2. Redis 配置(application.yml)

配置 Redis 连接信息,确保能正常连接 Redis 服务:

复制代码
复制代码
spring:
  redis:
    host: 127.0.0.1  # 你的 Redis 地址
    port: 6379       # Redis 端口
    password:        # 若有密码,填写密码
    lettuce:
      pool:
        max-active: 100  # 最大连接数
        max-idle: 20     # 最大空闲连接
        min-idle: 5      # 最小空闲连接
        max-wait: 1000ms # 最大等待时间

3. 核心业务代码(无锁库存扣减)

实现 INCR 生成序号、SETNX 抢占令牌的核心逻辑,清晰追踪每一次扣减对应的 key:

复制代码
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

/**
 * Redis 无锁化库存扣减服务(INCR + SETNX 实现)
 * 高并发安全、不超卖、可追踪每一次扣减对应的库存节点
 */
@Service
public class StockDeductService {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 无锁化库存扣减核心方法
     * @param productId 商品ID
     * @param totalStock 商品总库存(可从配置/数据库获取,此处简化为参数)
     * @return true:扣减成功;false:扣减失败(库存不足/令牌抢占失败)
     */
    public boolean deductStock(Long productId, int totalStock) {
        // 1. 定义 Redis key:自增序号key、库存令牌key
        String seqKey = "stock:seq:" + productId;       // 自增序号key(全局唯一)
        String tokenKeyPrefix = "stock:token:" + productId + ":"; // 库存令牌key前缀

        try {
            // 2. INCR 生成唯一自增序号(原子操作,无并发问题)
            // 若 seqKey 不存在,Redis 会自动创建并设置值为 1,后续每次 INCR +1
            Long seq = stringRedisTemplate.opsForValue().increment(seqKey);
            if (seq == null) {
                // 自增失败(Redis 异常),直接返回失败
                return false;
            }

            // 3. 判断序号是否超过总库存:超过则库存不足,扣减失败
            if (seq > totalStock) {
                return false;
            }

            // 4. 确定当前请求扣减的库存令牌key(核心:知道扣的是哪个key)
            String tokenKey = tokenKeyPrefix + seq;
            System.out.println("当前请求扣减的库存key:" + tokenKey); // 日志追踪

            // 5. SETNX 抢占令牌(原子操作,只有key不存在时才设置成功)
            // 过期时间设置为10分钟,避免Redis中残留无效令牌(可根据业务调整)
            Boolean isSuccess = stringRedisTemplate.opsForValue()
                    .setIfAbsent(tokenKey, "1", 10, TimeUnit.MINUTES);

            // 6. 抢占成功 = 扣减成功;抢占失败 = 该序号已被其他请求占用
            return Boolean.TRUE.equals(isSuccess);
        } catch (Exception e) {
            // 异常处理(如Redis连接失败),可根据业务添加日志/重试逻辑
            e.printStackTrace();
            return false;
        }
    }

    /**
     * 初始化商品库存(项目启动时执行,或手动调用)
     * @param productId 商品ID
     * @param totalStock 商品总库存
     */
    public void initStock(Long productId, int totalStock) {
        String stockKey = "stock:total:" + productId;
        stringRedisTemplate.opsForValue().set(stockKey, String.valueOf(totalStock));
        // 初始化自增序号key(可选,若不存在,INCR会自动创建)
        String seqKey = "stock:seq:" + productId;
        if (stringRedisTemplate.hasKey(seqKey)) {
            stringRedisTemplate.delete(seqKey);
        }
    }

    /**
     * 查询某一次扣减对应的库存令牌是否存在(用于追踪扣减记录)
     * @param productId 商品ID
     * @param seq 扣减序号
     * @return true:存在(扣减成功);false:不存在(扣减失败/令牌过期)
     */
    public boolean checkStockToken(Long productId, Long seq) {
        String tokenKey = "stock:token:" + productId + ":" + seq;
        return Boolean.TRUE.equals(stringRedisTemplate.hasKey(tokenKey));
    }
}

4. 测试代码(验证无锁化效果)

用多线程测试高并发场景,验证是否超卖、是否能追踪扣减的 key:

复制代码
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.annotation.Resource;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

@SpringBootTest
public class StockDeductTest {

    @Resource
    private StockDeductService stockDeductService;

    // 商品ID
    private static final Long PRODUCT_ID = 1001L;
    // 商品总库存
    private static final int TOTAL_STOCK = 100;
    // 并发请求数
    private static final int CONCURRENT_NUM = 200;

    @Test
    public void testDeductStock() throws InterruptedException {
        // 1. 初始化库存
        stockDeductService.initStock(PRODUCT_ID, TOTAL_STOCK);

        // 2. 多线程并发测试
        ExecutorService executorService = Executors.newFixedThreadPool(20);
        CountDownLatch countDownLatch = new CountDownLatch(CONCURRENT_NUM);
        // 统计扣减成功次数
        AtomicInteger successCount = new AtomicInteger(0);

        for (int i = 0; i < CONCURRENT_NUM; i++) {
            executorService.submit(() -> {
                try {
                    boolean result = stockDeductService.deductStock(PRODUCT_ID, TOTAL_STOCK);
                    if (result) {
                        successCount.incrementAndGet();
                    }
                } finally {
                    countDownLatch.countDown();
                }
            });
        }

        // 等待所有线程执行完毕
        countDownLatch.await();
        executorService.shutdown();

        // 3. 输出测试结果
        System.out.println("并发请求数:" + CONCURRENT_NUM);
        System.out.println("扣减成功次数:" + successCount.get());
        System.out.println("商品总库存:" + TOTAL_STOCK);
        System.out.println("是否超卖:" + (successCount.get() > TOTAL_STOCK ? "是" : "否"));
    }
}

四、关键细节与优势(博客重点强调)

1. 如何知道扣减的是哪一个 key?

这是本文方案的核心优势之一,无需猜测,直接通过「自增序号」定位扣减的 key:

  • 每一次请求通过 INCR 拿到唯一序号 seq;

  • 库存令牌 key 格式为:stock:token:{商品ID}:{seq}

  • 例如:seq=5 → 扣减的 key 是 stock:token:1001:5,通过 checkStockToken 方法可直接查询该扣减是否有效。

2. 方案核心优势

  • 真正无锁化:不使用分布式锁,所有请求并行执行,无锁竞争、无阻塞,性能远超 SETNX 分布式锁方案;

  • 绝对不超卖:INCR 生成唯一序号,序号超过总库存则拒绝扣减,SETNX 保证同一个序号不被重复占用;

  • 可追踪性强:每一次扣减都对应唯一的 key,便于排查问题、统计扣减记录;

  • 易于实现和维护:无需复杂的 Lua 脚本,仅用 Redis 两个基础原子命令,代码简洁,新手也能快速上手;

  • 高并发适配:支持万级并发请求,性能接近 Redis 原生命令,适合秒杀、抢购等高频场景。

3. 注意事项(避坑重点)

  • 自增序号 key 的过期时间:无需设置过期时间,若商品下架/库存重置,手动删除该 key 即可;

  • 库存令牌 key 的过期时间:建议设置 5~10 分钟,避免 Redis 中残留大量无效令牌,占用内存;

  • 异常处理:Redis 连接失败、网络波动等异常,需添加日志和重试逻辑(可结合 Spring 重试注解);

  • 库存一致性:若需要和数据库保持一致,可在扣减成功后,异步同步到数据库(最终一致性,不影响高并发性能)。

五、方案对比(面试/选型参考)

为了让读者更清晰地了解本方案的优势,对比主流库存扣减方案:

方案 核心命令 是否无锁 是否超卖 可追踪扣减 key 性能 维护成本
DECR 直接扣减 DECR 可能超卖
SETNX 分布式锁 SETNX + DECR 否(有锁竞争)
Lua 原子脚本 Lua + DECRBY 极高 中(需懂 Lua)
INCR + SETNX(本文方案) INCR + SETNX 是(真正无锁)

六、总结与拓展

本文提出的 Redis INCR + SETNX 无锁化库存扣减方案,核心是「用唯一序号标记每一次扣减,用 SETNX 抢占令牌」,既解决了高并发超卖问题,又实现了扣减记录的可追踪,同时具备易于实现、维护成本低的优势。

适合场景:秒杀、抢购、限量商品发放等高并发库存扣减场景,尤其适合对代码简洁性、可追踪性有要求的项目。

相关推荐
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第44题】【JVM篇】第4题:什么时候会触发 Young GC?什么时候会触发 Full GC?
java·开发语言·jvm·后端·面试
小妖6661 小时前
js 实现python的SortedList有序集合
java·javascript·python
梦梦代码精1 小时前
电商系统的核心难点:订单与营销系统如何设计?——LikeShop 架构深度拆解(规则计算与状态一致性)
java·开发语言·低代码·架构·开源·github
SZLSDH1 小时前
专项治理场景下,数字孪生IOC的架构适配逻辑:以智慧河湖监管为例
java·大数据·架构·数据可视化
隐退山林1 小时前
JavaEE进阶:SpringBoot日志
java·开发语言
庞轩px1 小时前
Redis工具类重构——从臃肿到优雅的门面模式实践
数据库·redis·设计模式·重构·门面模式·可扩展性·可维护性
东风微鸣1 小时前
AWS 可靠性最佳实践:从架构设计到故障恢复一把梭
java·jvm·aws
略知java的景初1 小时前
【面试特集】Redis 面试题与应用场景
redis·面试·职场和发展
敲敲千反田1 小时前
微服务基础
java·微服务·架构