秒杀和拼团场景都涉及高并发下的库存扣减。我选择 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 脚本
拼团比秒杀复杂得多,一次参团需要校验:
活动状态是否有效
团剩余名额是否 > 0
活动库存是否充足
用户是否已参团(防重复)
-- 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,什么时候用分布式锁?
在最开始的秒杀功能中,“一人一单”最初我用 Redisson 锁实现,后来发现和库存扣减分离会产生锁竞争,就一并放入 Lua 脚本。拼团因为本身就是复杂校验集合,天然适合 Lua。
七、总结
脚本原子性和事务回滚是两码事
Lua 脚本只能保证 Redis 内部的原子性,不涉及数据库回滚。拼团场景下订单创建失败需要主动调用补偿 Lua 脚本(见我的另一篇博客)。
Redis + Lua 为高并发下的原子写操作提供了轻量、高效的解决方案。
秒杀:双重校验(库存 + 一人一单)
拼团:四重校验(状态 + 名额 + 库存 + 重复)
以上是针对Lua脚本的使用,欢迎交流讨论。
评论交流
欢迎留下你的想法