前言

在电商秒杀、抢红包等高并发场景中,“超卖”是最经典也是最棘手的问题之一。今天我们就通过一个真实的秒杀案例,看看如何从有问题的第一版迭代到原子操作的 Lua 脚本第二版,彻底解决超卖难题。


1. 场景描述

我们要实现一个简单的秒杀功能:

  • 商品库存存储在 Redis 中(如 stock:product:1001 = 10
  • 用户请求秒杀接口
  • 如果库存充足,扣减库存并返回成功
  • 如果库存不足,返回已售罄

2. 第一版实现:分步操作(存在超卖风险)

2.1 代码实现

@PostMapping("/{productId}")
public String seckill(@PathVariable String productId,
                      @RequestParam String userId) {
    String stockKey = "stock:product:" + productId;
    
    // 第一步:查询库存
    String stockStr = redisTemplate.opsForValue().get(stockKey);
    
    // 第二步:判断库存
    if (stockStr == null || Integer.parseInt(stockStr) <= 0) {
        return "已售罄";
    }
    
    // 第三步:扣减库存
    Long remaining = redisTemplate.opsForValue().decrement(stockKey);
    
    if (remaining < 0) {
        // 回滚
        redisTemplate.opsForValue().increment(stockKey);
        return "已售罄";
    }
    
    return "用户 " + userId + " 秒杀成功!";
}

2.2 问题分析

这段代码在低并发下运行正常,但在高并发场景下会出现超卖

时间线演示:
假设库存只有 1 件,两个请求同时到达:

时间点请求A请求B库存值
T1get → 得到 1-1
T2-get → 得到 11
T3decrement → 变成 0-0
T4-decrement → 变成 -1-1

结果: 两个请求都成功了,但库存变成了 -1,这就是典型的超卖!

核心原因:

“查库存” 和 “扣库存” 是两个独立的操作,中间有时间窗口,不具备原子性。


3. 第二版实现:Lua 脚本(原子操作,解决超卖)

3.1 Lua 脚本详解

我们将"查库存"和"扣库存"打包成一个 Lua 脚本:

local stock = tonumber(redis.call('get', KEYS[1])) 
if stock == nil or stock <= 0 then 
    return 0 
end 
redis.call('decr', KEYS[1]) 
return 1

逐行解析:

  1. 查询库存

    local stock = tonumber(redis.call('get', KEYS[1]))
    
    • KEYS[1]:传入的库存 key
    • redis.call('get', ...):执行 Redis GET 命令
    • tonumber():将字符串转为数字
  2. 判断库存是否充足

    if stock == nil or stock <= 0 then 
        return 0 
    end
    
    • 如果库存为 nil(不存在)或 <= 0,返回 0 表示失败
  3. 扣减库存

    redis.call('decr', KEYS[1])
    
    • 执行 Redis DECR 命令,库存减 1
  4. 返回成功

    return 1
    
    • 返回 1 表示秒杀成功

3.2 Java 代码实现

@RestController
@RequestMapping("/seckill")
public class SeckillController {

    private final StringRedisTemplate redisTemplate;

    // Lua脚本:查库存 + 扣库存,原子执行
    private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
    static {
        SECKILL_SCRIPT = new DefaultRedisScript<>();
        SECKILL_SCRIPT.setResultType(Long.class);
        SECKILL_SCRIPT.setScriptText(
            "local stock = tonumber(redis.call('get', KEYS[1])) " +
            "if stock == nil or stock <= 0 then " +
            "    return 0 " +
            "end " +
            "redis.call('decr', KEYS[1]) " +
            "return 1"
        );
    }

    public SeckillController(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 秒杀接口 —— Lua脚本原子操作
     */
    @PostMapping("/{productId}")
    public String seckill(@PathVariable String productId,
                          @RequestParam String userId) {
        String stockKey = "stock:product:" + productId;

        Long result = redisTemplate.execute(
            SECKILL_SCRIPT,
            Collections.singletonList(stockKey)
        );

        if (result == null || result == 0) {
            return "已售罄";
        }
        return "用户 " + userId + " 秒杀成功!";
    }
}

3.3 为什么 Lua 脚本能解决超卖?

核心原理:原子性执行

Redis 网络 I/O 可以多线程,但命令执行队列是单线程串行的,它会把整个 Lua 脚本当作一个不可分割的整体来执行。在执行期间,不会插入其他客户端的命令。

时间线演示(使用 Lua 脚本后):

时间点请求A请求B库存值
T1开始执行 Lua 脚本(获得锁)-1
T2get → 1(等待)1
T3decr → 0(等待)0
T4返回 1,脚本结束-0
T5-开始执行 Lua 脚本0
T6-get → 00
T7-判断 <= 0,返回 00

结果: 只有请求 A 成功,请求 B 被正确拦截,库存保持为 0,没有超卖!


4. 知识点总结

4.1 Redis 常用操作

// 设置库存
redisTemplate.opsForValue().set("stock:product:1001", "10");

// 查询库存
String stock = redisTemplate.opsForValue().get("stock:product:1001");

// 自减(等价于 Lua 中的 decr)
redisTemplate.opsForValue().decrement("stock:product:1001");

4.2 Lua 脚本的优势

  1. 原子性:整个脚本执行期间不会被中断
  2. 减少网络开销:一次请求完成多个操作
  3. 代码复用:脚本可以缓存和重复使用

4.3 适用场景

  • 秒杀/抢购
  • 库存扣减
  • 计数器限流
  • 分布式锁

5. 思考题

如果现在要求每个用户只能秒杀一次,这个 Lua 脚本应该如何修改?

提示: 可以使用 Redis 的 Set 结构记录已秒杀的用户 ID。

欢迎在评论区留下你的答案!👇


结语

通过从第一版到第二版的演进,我们看到了如何利用 Redis + Lua 脚本优雅地解决高并发下的超卖问题。在实际生产中,还可以结合消息队列、数据库乐观锁等方案进一步提升系统的可靠性。

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、转发!有任何问题也欢迎在评论区交流~


标签: #Redis #Lua #高并发 #秒杀 #超卖 #SpringBoot #后端开发

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐