📚 实战篇 23. 秒杀优化 - 基于 Redis 完成秒杀资格判断学习文档
一、 核心痛点复盘:为什么要用 Lua 脚本?
在判断秒杀资格时,我们需要在 Redis 中完成以下三个动作:
- 查库存: 查询
String类型的库存数量是否 > 0。 - 查一人一单: 查询
Set集合中是否已经存在当前用户的 ID。 - 执行扣减与记录: 如果前两步都通过,则将库存 -1,并将用户 ID 存入 Set 中。
💥 并发危险:
这三个动作是分散的。如果在高并发下用 Java 代码分三次去调用 Redis,中间肯定会被其他线程插队,导致“超卖”或“一人多单”。
因此,我们必须把这三个动作写进一个 Lua 脚本里,交由 Redis 单线程一口气执行完毕,保证绝对的原子性。
二、 核心落地:Lua 脚本的设计与编写
我们需要在 src/main/resources 目录下新建一个 seckill.lua 文件。
脚本逻辑设计:
-
我们规定脚本的返回值:
- 返回
0:代表有资格,抢单成功。 - 返回
1:代表库存不足。 - 返回
2:代表该用户已经下过单了(违反一人一单)。
- 返回
seckill.lua 完整代码解析:
Lua
-- 1. 接收参数
local voucherId = ARGV[1] -- 优惠券 ID
local userId = ARGV[2] -- 用户 ID
-- 2. 拼接 Redis 的 Key
local stockKey = 'seckill:stock:' .. voucherId -- 库存 Key (String类型)
local orderKey = 'seckill:order:' .. voucherId -- 订单 Key (Set类型,用于记录谁买过)
-- 3. 核心业务逻辑校验
-- 3.1 判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
-- 库存不足,直接返回 1
return 1
end
-- 3.2 判断用户是否已经下过单
if (redis.call('sismember', orderKey, userId) == 1) then
-- 用户 ID 已经在 Set 集合中,说明买过了,返回 2
return 2
end
-- 4. 校验全部通过,开始扣减库存并记录用户
-- 4.1 扣减库存 (-1)
redis.call('decr', stockKey)
-- 4.2 将用户 ID 存入 Set 集合
redis.call('sadd', orderKey, userId)
-- 5. 成功返回 0
return 0
三、 Java 代码落地:调用 Lua 脚本
回到我们的 VoucherOrderServiceImpl 中,我们需要改造原来的同步下单逻辑。
1. 预先加载 Lua 脚本:
和之前做分布式锁一样,在类加载时静态初始化脚本,提高性能。
Java
private static final DefaultRedisScript<Long> SECKILL_SCRIPT;
static {
SECKILL_SCRIPT = new DefaultRedisScript<>();
SECKILL_SCRIPT.setLocation(new ClassPathResource("seckill.lua"));
SECKILL_SCRIPT.setResultType(Long.class);
}
2. 重构 seckillVoucher 方法:
前台线程现在只做极速校验,一旦发现没资格直接报错,有资格就拿着生成的订单号准备“扔给后台”。
Java
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 获取当前用户 ID
Long userId = UserHolder.getUser().getId();
// 2. 极速调用 Lua 脚本,判断秒杀资格
// 注意:这里不需要传 KEYS 集合,直接把两个参数当做 ARGV 传进去即可
Long result = stringRedisTemplate.execute(
SECKILL_SCRIPT,
Collections.emptyList(), // 空的 KEYS 集合
voucherId.toString(),
userId.toString()
);
// 3. 判断脚本执行结果
int r = result.intValue();
if (r != 0) {
// 4. 不为 0,代表没有资格购买
return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}
// 5. 为 0,代表有购买资格,把下单信息保存到阻塞队列 (为下一步异步化做准备)
long orderId = redisIdWorker.nextId("order");
// TODO: 封装订单对象,丢入阻塞队列,由后台独立线程去慢慢写 MySQL
// 6. 极速返回订单号给前端 (此时用户页面已经提示抢购成功!)
return Result.ok(orderId);
}
四、 学习总结
通过这一步改造,我们将原本沉重的“查 MySQL 库存 -> 查 MySQL 订单 -> 扣 MySQL 库存 -> 写 MySQL 订单”的漫长过程,彻底压缩成了 Redis 内存中的毫秒级计算。这就是高并发秒杀系统能抗住瞬间大流量的核心机密所在。