黑马点评-秒杀优化-02_lua_precheck
2026/6/6 2:08:28 网站建设 项目流程

黑马点评秒杀优化二:Lua 如何在 Redis 中完成库存和一人一单判断?

本文继续整理黑马点评 Redis 实战篇第 6 章「秒杀优化」。

上一篇讲清楚了为什么秒杀接口不能一直让请求线程同步查库、扣库存、创建订单。

这一篇进入第 6 章最关键的前置判断:Redis + Lua 如何一次性完成库存判断、一人一单判断、扣 Redis 库存、记录用户已下单。


1. 这篇文章解决什么问题

第 6 章的异步秒杀有两个核心环节:

1. 请求线程在 Redis 中快速判断用户是否有抢购资格。 2. 资格通过后,把订单任务交给后台线程异步落库。

本文只讲第一个环节。

也就是这个问题:

为什么 Lua 脚本能替代请求线程里大量数据库查询, 在 Redis 中快速完成秒杀资格判断?

更具体一点,Lua 脚本要完成四件事:

1. 判断库存是否充足。 2. 判断当前用户是否已经抢过这张券。 3. 如果通过,扣减 Redis 中的库存。 4. 如果通过,把当前用户记录到 Redis 的已下单集合中。

先给结论:

Lua 脚本的价值不只是“少写几次 Java 调 Redis”,而是把多个 Redis 操作合并成一个原子流程,避免并发请求在库存判断和一人一单判断中插队。


2. 新增秒杀券时,为什么要把库存存到 Redis

讲义中,新增秒杀券时会执行:

@Override@TransactionalpublicvoidaddSeckillVoucher(Vouchervoucher){// 保存优惠券save(voucher);// 保存秒杀信息SeckillVoucherseckillVoucher=newSeckillVoucher();seckillVoucher.setVoucherId(voucher.getId());seckillVoucher.setStock(voucher.getStock());seckillVoucher.setBeginTime(voucher.getBeginTime());seckillVoucher.setEndTime(voucher.getEndTime());seckillVoucherService.save(seckillVoucher);// 保存秒杀库存到Redis中stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());}

前两步是保存数据库数据。

最后一步是第 6 章优化的伏笔:

stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY+voucher.getId(),voucher.getStock().toString());

它相当于提前在 Redis 中准备一份秒杀库存:

seckill:stock:{voucherId} -> stock

比如优惠券 id 是 10,库存是 100:

seckill:stock:10 -> 100

为什么要这么做?

因为秒杀请求进来时,如果每次都去数据库查库存,高并发下数据库会非常吃力。

提前把库存放到 Redis,就可以让请求线程先访问 Redis:

Redis 判断库存是否还有 Redis 判断用户是否重复下单

只有通过资格判断的请求,才会进入后续异步落库流程。


3. Redis 中需要哪些 key

第 6 章 Lua 脚本主要用两类 key。

第一类是库存 key:

seckill:stock:{voucherId}

它是 String 类型。

例如:

seckill:stock:10 -> 100

表示:

秒杀券 10 在 Redis 中还有 100 份库存。

第二类是已下单用户 key:

seckill:order:{voucherId}

它是 Set 类型。

例如:

seckill:order:10 -> {101, 102, 205}

表示:

用户 101、102、205 已经抢过秒杀券 10。

为什么用 Set?

因为 Set 天然适合判断一个元素是否存在:

SISMEMBER seckill:order:10 101

如果返回 1,说明用户已经下过单。

如果返回 0,说明用户还没抢过。


4. Lua 脚本完整代码

讲义中的 Lua 脚本如下:

-- 1.参数列表-- 1.1.优惠券idlocalvoucherId=ARGV[1]-- 1.2.用户idlocaluserId=ARGV[2]-- 1.3.订单idlocalorderId=ARGV[3]-- 2.数据key-- 2.1.库存keylocalstockKey='seckill:stock:'..voucherId-- 2.2.订单keylocalorderKey='seckill:order:'..voucherId-- 3.脚本业务-- 3.1.判断库存是否充足 get stockKeyif(tonumber(redis.call('get',stockKey))<=0)then-- 3.2.库存不足,返回1return1end-- 3.2.判断用户是否下单 SISMEMBER orderKey userIdif(redis.call('sismember',orderKey,userId)==1)then-- 3.3.存在,说明是重复下单,返回2return2end-- 3.4.扣库存 incrby stockKey -1redis.call('incrby',stockKey,-1)-- 3.5.下单(保存用户)sadd orderKey userIdredis.call('sadd',orderKey,userId)-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...redis.call('xadd','stream.orders','*','userId',userId,'voucherId',voucherId,'id',orderId)return0

这里要特别说明一下:

讲义这段脚本最后出现了xadd,这是后续 Redis Stream 版本会使用的命令。

但第 6 章 BlockingQueue 版本的主线可以先理解为:

Lua 负责 Redis 里的资格判断、扣 Redis 库存、记录用户。 Java 请求线程在 Lua 返回 0 后,把订单任务放入本地阻塞队列。

也就是说,本文讨论第 6 章时,重点不是xadd,而是前面这几步:

get 库存 sismember 查重复下单 incrby 扣 Redis 库存 sadd 记录用户已下单 return 0 / 1 / 2

5. Lua 参数 ARGV 是什么

脚本开头:

localvoucherId=ARGV[1]localuserId=ARGV[2]localorderId=ARGV[3]

这里的ARGV可以理解为:

Java 执行 Lua 脚本时传进来的参数数组。

Java 里执行脚本时传入:

Longresult=stringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(),voucherId.toString(),userId.toString(),String.valueOf(orderId));

所以对应关系是:

ARGV[1] = voucherId ARGV[2] = userId ARGV[3] = orderId

举个例子:

voucherId = 10 userId = 888 orderId = 123456789

那么 Lua 中:

ARGV[1] = "10" ARGV[2] = "888" ARGV[3] = "123456789"

Lua 里拼 key:

localstockKey='seckill:stock:'..voucherIdlocalorderKey='seckill:order:'..voucherId

得到:

stockKey = seckill:stock:10 orderKey = seckill:order:10

6. redis.call 是什么

Lua 脚本中大量出现:

redis.call('get',stockKey)redis.call('sismember',orderKey,userId)redis.call('incrby',stockKey,-1)redis.call('sadd',orderKey,userId)

可以把redis.call理解成:

在 Lua 脚本内部执行一条 Redis 命令。

比如:

redis.call('get',stockKey)

本质类似执行:

GET seckill:stock:10

再比如:

redis.call('sismember',orderKey,userId)

本质类似执行:

SISMEMBER seckill:order:10 888

再比如:

redis.call('incrby',stockKey,-1)

本质类似执行:

INCRBY seckill:stock:10 -1

再比如:

redis.call('sadd',orderKey,userId)

本质类似执行:

SADD seckill:order:10 888

这样看,Lua 脚本就不神秘了。

它不是另一套业务系统。

它只是把一组 Redis 命令按固定顺序封装在一起执行。


7. Lua 具体执行流程

假设:

voucherId = 10 userId = 888 Redis 库存 seckill:stock:10 = 1 Redis 已下单集合 seckill:order:10 = {}

用户 888 第一次抢券。

Lua 第一步:

if(tonumber(redis.call('get',stockKey))<=0)thenreturn1end

对应:

GET seckill:stock:10

结果是 1。

库存大于 0,继续往下走。

第二步:

if(redis.call('sismember',orderKey,userId)==1)thenreturn2end

对应:

SISMEMBER seckill:order:10 888

结果是 0。

说明用户 888 没有抢过这张券。

第三步:

redis.call('incrby',stockKey,-1)

对应:

INCRBY seckill:stock:10 -1

库存从 1 变成 0。

第四步:

redis.call('sadd',orderKey,userId)

对应:

SADD seckill:order:10 888

集合变成:

seckill:order:10 = {888}

最后:

return0

表示抢购资格通过。

Lua 内部流程图

Lua 开始执行

读取 seckill:stock:{voucherId}

库存是否 <= 0?

返回 1:库存不足

SISMEMBER 判断 userId 是否已下单

用户是否已下单?

返回 2:重复下单

INCRBY 库存 -1

SADD 记录 userId 已下单

返回 0:资格通过


8. 为什么不能在 Java 里分多次调用 Redis

有人可能会想:

我在 Java 里先 get 库存, 再 sismember 判断用户, 再 incrby 扣库存, 再 sadd 记录用户, 不也能完成吗?

单线程下可以。

但秒杀不是单线程。

问题在于多次 Redis 调用之间可能被其他请求插队。

比如库存只剩 1:

请求 A:GET 库存 = 1 请求 B:GET 库存 = 1 请求 A:INCRBY -1,库存变 0 请求 B:INCRBY -1,库存变 -1

这就出问题了。

Lua 的好处是:

Redis 执行 Lua 脚本时,中间不会插入其他命令。

所以它可以保证:

判断库存、判断重复、扣库存、记录用户

这几个动作作为一个整体完成。


9. Java 代码如何执行 Lua

讲义中请求线程执行 Lua 的代码:

@OverridepublicResultseckillVoucher(LongvoucherId){// 获取用户LonguserId=UserHolder.getUser().getId();longorderId=redisIdWorker.nextId("order");// 1.执行lua脚本Longresult=stringRedisTemplate.execute(SECKILL_SCRIPT,Collections.emptyList(),voucherId.toString(),userId.toString(),String.valueOf(orderId));intr=result.intValue();// 2.判断结果是否为0if(r!=0){// 2.1.不为0,代表没有购买资格returnResult.fail(r==1?"库存不足":"不能重复下单");}// TODO 保存阻塞队列// 3.返回订单idreturnResult.ok(orderId);}

这段代码可以按 5 步理解:

1. 从 UserHolder 获取当前登录用户 id。 2. 用 RedisIdWorker 生成订单 id。 3. 执行 Lua 脚本,把 voucherId、userId、orderId 传给 Lua。 4. 根据 Lua 返回值判断是否有抢购资格。 5. 如果返回 0,说明资格通过,后续再把订单任务放入队列。

返回值含义

0:抢购资格通过 1:库存不足 2:重复下单

所以 Java 里:

if(r!=0){returnResult.fail(r==1?"库存不足":"不能重复下单");}

这句的意思就是:

只要 Lua 返回不是 0,就不进入后续下单流程。

10. orderId 为什么要在执行 Lua 前生成

讲义中先生成订单 id:

longorderId=redisIdWorker.nextId("order");

再执行 Lua:

stringRedisTemplate.execute(...,voucherId,userId,orderId)

这样做是为了:

抢购资格通过后,请求线程可以马上把这个 orderId 返回给前端。

异步下单最大的特点是:

请求线程不等待数据库订单创建完成。

那前端拿什么作为“我这次抢券请求”的凭证?

就是这个订单 id。

后续后台线程真正保存订单时,也应该保存同一个订单 id。

这里有一个很重要的易错点:

请求线程返回给前端的订单 id,应该和后台线程最终保存到数据库的订单 id 是同一个。

如果前台返回一个 id,后台落库又重新生成另一个 id,前端后续拿着第一个 id 查询订单,就可能查不到。


11. 本篇最容易混淆的几个点

1. Lua 是不是用来创建数据库订单的

不是。

Lua 只在 Redis 内部执行 Redis 命令。

它负责:

判断 Redis 库存 判断 Redis 一人一单 扣 Redis 库存 记录用户已下单

真正的数据库订单仍然由 Java 后台线程创建。

2. Lua 返回 0 是不是表示订单已经落库

不是。

返回 0 只表示 Redis 资格判断成功。

数据库落库在后面的异步线程中完成。

3. 为什么已下单用户用 Set

因为要快速判断某个 userId 是否已经存在。

Set 的SISMEMBER很适合做这种判断。

4. 为什么不用 Java 多次调用 Redis

因为多次调用之间可能被其他请求插队。

Lua 可以把多条 Redis 命令合成一个原子脚本执行。

5. Redis 库存扣了,数据库库存还没扣,会不会不一致

短时间内会出现 Redis 已经扣库存、MySQL 还没落库的状态。

这正是异步秒杀的特点。

系统需要后台线程继续消费订单任务,最终把数据库数据补上。


12. 面试怎么回答

如果面试官问:为什么秒杀资格判断要用 Lua?

可以这样回答:

秒杀资格判断包含多个 Redis 操作,比如判断库存、判断用户是否重复下单、扣减 Redis 库存、记录用户已下单。如果在 Java 中分多次调用 Redis,高并发下这些操作之间可能被其他请求插入,导致并发问题。Lua 脚本可以在 Redis 中一次性执行这些命令,保证整个判断和扣减流程的原子性。

如果面试官问:Lua 返回值 0、1、2 分别代表什么?

可以这样回答:

返回 0 表示抢购资格通过;返回 1 表示库存不足;返回 2 表示用户重复下单。Java 请求线程根据返回值决定是否继续把订单任务放入队列。

如果面试官问:Redis 中如何实现一人一单?

可以这样回答:

可以为每个秒杀券维护一个 Set,key 类似seckill:order:{voucherId},Set 中保存已经抢过该券的 userId。Lua 脚本通过SISMEMBER判断当前 userId 是否存在,如果存在说明重复下单,直接返回失败;如果不存在并且库存充足,就通过SADD把 userId 加入 Set。


13. 总结

本篇的主线是:

秒杀资格判断前移到 Redis ↓ 库存用 String 保存 ↓ 已下单用户用 Set 保存 ↓ Lua 原子执行库存判断、一人一单判断、扣库存、记录用户 ↓ Java 根据 Lua 返回值决定是否进入异步下单

这一篇只解决了:

谁有资格抢。

下一篇继续讲:

Lua 判断通过之后,订单任务如何进入阻塞队列,又是谁在后台真正创建订单?

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询