秒杀和拼团场景都涉及高并发下的库存扣减。我选择 Redis + Lua 脚本来保证原子性。本文记录 Lua 原子性的原理、两个场景的脚本实现,以及我对 什么时候用 Lua 的思考。

一、问题背景

无论是秒杀抢购还是拼团参团,核心操作都是:校验库存 → 扣减库存

如果放在 Java 代码里分两步执行:

Integer stock = redis.get("stock");
if (stock > 0) {
    redis.decr("stock");
}

高并发下会出现经典的 超卖 问题:两个线程同时读到 stock=1,都通过了校验,最后库存被扣成 -1。

解决思路:让“校验+扣减”成为一个不可分割的原子操作。

二、为什么 Redis + Lua 能保证原子性?

Redis 是单线程执行命令的。Lua 脚本被送入 Redis 后,会 独占 Redis 执行线程,脚本内的多条命令不会被其他客户端的命令插队。

简单来说:Redis 把整个 Lua 脚本当作一个整体命令来执行,天然具有原子性。

同时,Lua 脚本可以:

  • 在服务端进行逻辑判断(if / else

  • 减少网络往返(原本需要 get 后再 set,现在一次 EVAL 搞定


三、秒杀场景的 Lua 脚本

秒杀的核心逻辑相对简单:扣减库存 + 校验用户是否已购买

-- KEYS[1]: 秒杀库存 Key
-- KEYS[2]: 已购买用户 Set 的 Key
-- ARGV[1]: 当前用户 ID
-- ARGV[2]: 扣减数量(通常为 1)
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) < tonumber(ARGV[2]) then
    return 0  -- 库存不足
end
-- 检查用户是否已购买(一人一单)
local exists = redis.call('sismember', KEYS[2], ARGV[1])
if exists == 1 then
    return -1  -- 用户已购买
end
-- 原子扣减
redis.call('decrby', KEYS[1], ARGV[2])
redis.call('sadd', KEYS[2], ARGV[1])
return 1

关键点:

  • get 和 sismember 做双重校验。

get 校验库存 → 防止超卖

sismember 校验用户是否已存在 → 防止同一用户重复购买

  • decrby 和 sadd 在同一个 Lua 脚本里执行,保证不会出现“扣了库存但没记录用户”的中间状态。


四、拼团场景的 Lua 脚本

拼团比秒杀复杂得多,一次参团需要校验:

  1. 活动状态是否有效

  2. 团剩余名额是否 > 0

  3. 活动库存是否充足

  4. 用户是否已参团(防重复)

-- KEYS[1]: 活动状态 Key
-- KEYS[2]: 团剩余名额 Key
-- KEYS[3]: 活动库存 Key
-- KEYS[4]: 团成员 Set 的 Key
-- ARGV[1]: 用户 ID
-- ARGV[2]: 扣减数量(通常为 1)
-- 1. 活动状态校验(假设 1 表示进行中)
local status = redis.call('get', KEYS[1])
if not status or tonumber(status) ~= 1 then
    return -1  -- 活动未开始或已结束
end
-- 2. 剩余名额校验
local remain = redis.call('get', KEYS[2])
if not remain or tonumber(remain) <= 0 then
    return -2  -- 名额不足
end
-- 3. 库存校验
local stock = redis.call('get', KEYS[3])
if not stock or tonumber(stock) < tonumber(ARGV[2]) then
    return -3  -- 库存不足
end
-- 4. 重复参团校验
local exists = redis.call('sismember', KEYS[4], ARGV[1])
if exists == 1 then
    return -4  -- 用户已参团
end
-- 5. 原子扣减
redis.call('decrby', KEYS[3], ARGV[2])  -- 扣库存
redis.call('decr', KEYS[2])              -- 扣名额
redis.call('sadd', KEYS[4], ARGV[1])     -- 记录成员
return 1

设计要点:

  • 返回值用不同负数区分失败原因,业务层可据此给用户友好提示。

  • 扣减顺序:先扣库存,再扣名额,最后记录成员,符合业务依赖逻辑。

  • 四个 Key 保证了一次网络往返完成全部校验与写操作。


五、工程化落地:Java 代码如何调用

@Service
public class SeckillService {
    
    private final DefaultRedisScript<Long> seckillScript;
    
    public SeckillService() {
        seckillScript = new DefaultRedisScript<>();
        seckillScript.setScriptSource(new ResourceScriptSource(
            new ClassPathResource("lua/seckill.lua")));
        seckillScript.setResultType(Long.class);
    }
    
    public Result doSeckill(Long skuId, Long userId) {
        List<String> keys = Arrays.asList(
            "seckill:stock:" + skuId,
            "seckill:users:" + skuId
        );
        Long result = redisTemplate.execute(seckillScript, keys, userId.toString());
        
        if (result == 1) {
            // 扣减成功,发 MQ 异步下单
            rabbitTemplate.convertAndSend("seckill.order", userId + ":" + skuId);
            return Result.success("排队中");
        } else if (result == 0) {
            return Result.fail("库存不足");
        } else if (result == -1) {
            return Result.fail("您已参与过秒杀");
        }
        return Result.fail("系统繁忙");
    }
}

六、什么时候用 Lua,什么时候用分布式锁?

场景

推荐方案

理由

高并发写操作(秒杀扣库存、拼团参团)

Lua 脚本原子化

性能高,无锁竞争,代码简洁

低频写操作(缓存击穿重建、一人一单的简单标记)

Redisson 分布式锁

实现简单,可重入,有看门狗自动续期

复杂业务逻辑(需要跨多个服务调用)

不建议放 Lua

Lua 中不宜做 IO 操作,应回退到 Java 层用分布式锁或事务

在最开始的秒杀功能中,“一人一单”最初我用 Redisson 锁实现,后来发现和库存扣减分离会产生锁竞争,就一并放入 Lua 脚本。拼团因为本身就是复杂校验集合,天然适合 Lua。

七、总结

脚本原子性和事务回滚是两码事

Lua 脚本只能保证 Redis 内部的原子性,不涉及数据库回滚。拼团场景下订单创建失败需要主动调用补偿 Lua 脚本(见我的另一篇博客)。

Redis + Lua 为高并发下的原子写操作提供了轻量、高效的解决方案。

  • 秒杀:双重校验(库存 + 一人一单)

  • 拼团:四重校验(状态 + 名额 + 库存 + 重复)

以上是针对Lua脚本的使用,欢迎交流讨论。