黑马点评-秒杀优化-02_lua_precheck

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

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

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

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


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

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

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

本文只讲第一个环节。

也就是这个问题:

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

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

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

先给结论:

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


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

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

java 复制代码
@Override
@Transactional
public void addSeckillVoucher(Voucher voucher) {
    // 保存优惠券
    save(voucher);
    // 保存秒杀信息
    SeckillVoucher seckillVoucher = new SeckillVoucher();
    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 章优化的伏笔:

java 复制代码
stringRedisTemplate.opsForValue().set(
        SECKILL_STOCK_KEY + voucher.getId(),
        voucher.getStock().toString()
);

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

text 复制代码
seckill:stock:{voucherId} -> stock

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

text 复制代码
seckill:stock:10 -> 100

为什么要这么做?

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

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

text 复制代码
Redis 判断库存是否还有
Redis 判断用户是否重复下单

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


3. Redis 中需要哪些 key

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

第一类是库存 key:

text 复制代码
seckill:stock:{voucherId}

它是 String 类型。

例如:

text 复制代码
seckill:stock:10 -> 100

表示:

text 复制代码
秒杀券 10 在 Redis 中还有 100 份库存。

第二类是已下单用户 key:

text 复制代码
seckill:order:{voucherId}

它是 Set 类型。

例如:

text 复制代码
seckill:order:10 -> {101, 102, 205}

表示:

text 复制代码
用户 101、102、205 已经抢过秒杀券 10。

为什么用 Set?

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

redis 复制代码
SISMEMBER seckill:order:10 101

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

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


4. Lua 脚本完整代码

讲义中的 Lua 脚本如下:

lua 复制代码
-- 1.参数列表
-- 1.1.优惠券id
local voucherId = ARGV[1]
-- 1.2.用户id
local userId = ARGV[2]
-- 1.3.订单id
local orderId = ARGV[3]

-- 2.数据key
-- 2.1.库存key
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2.订单key
local orderKey = 'seckill:order:' .. voucherId

-- 3.脚本业务
-- 3.1.判断库存是否充足 get stockKey
if(tonumber(redis.call('get', stockKey)) <= 0) then
    -- 3.2.库存不足,返回1
    return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then
    -- 3.3.存在,说明是重复下单,返回2
    return 2
end
-- 3.4.扣库存 incrby stockKey -1
redis.call('incrby', stockKey, -1)
-- 3.5.下单(保存用户)sadd orderKey userId
redis.call('sadd', orderKey, userId)
-- 3.6.发送消息到队列中, XADD stream.orders * k1 v1 k2 v2 ...
redis.call('xadd', 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', orderId)
return 0

这里要特别说明一下:

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

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

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

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

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

5. Lua 参数 ARGV 是什么

脚本开头:

lua 复制代码
local voucherId = ARGV[1]
local userId = ARGV[2]
local orderId = ARGV[3]

这里的 ARGV 可以理解为:

text 复制代码
Java 执行 Lua 脚本时传进来的参数数组。

Java 里执行脚本时传入:

java 复制代码
Long result = stringRedisTemplate.execute(
        SECKILL_SCRIPT,
        Collections.emptyList(),
        voucherId.toString(), userId.toString(), String.valueOf(orderId)
);

所以对应关系是:

text 复制代码
ARGV[1] = voucherId
ARGV[2] = userId
ARGV[3] = orderId

举个例子:

text 复制代码
voucherId = 10
userId = 888
orderId = 123456789

那么 Lua 中:

text 复制代码
ARGV[1] = "10"
ARGV[2] = "888"
ARGV[3] = "123456789"

Lua 里拼 key:

lua 复制代码
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId

得到:

text 复制代码
stockKey = seckill:stock:10
orderKey = seckill:order:10

6. redis.call 是什么

Lua 脚本中大量出现:

lua 复制代码
redis.call('get', stockKey)
redis.call('sismember', orderKey, userId)
redis.call('incrby', stockKey, -1)
redis.call('sadd', orderKey, userId)

可以把 redis.call 理解成:

text 复制代码
在 Lua 脚本内部执行一条 Redis 命令。

比如:

lua 复制代码
redis.call('get', stockKey)

本质类似执行:

redis 复制代码
GET seckill:stock:10

再比如:

lua 复制代码
redis.call('sismember', orderKey, userId)

本质类似执行:

redis 复制代码
SISMEMBER seckill:order:10 888

再比如:

lua 复制代码
redis.call('incrby', stockKey, -1)

本质类似执行:

redis 复制代码
INCRBY seckill:stock:10 -1

再比如:

lua 复制代码
redis.call('sadd', orderKey, userId)

本质类似执行:

redis 复制代码
SADD seckill:order:10 888

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

它不是另一套业务系统。

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


7. Lua 具体执行流程

假设:

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

用户 888 第一次抢券。

Lua 第一步:

lua 复制代码
if(tonumber(redis.call('get', stockKey)) <= 0) then
    return 1
end

对应:

redis 复制代码
GET seckill:stock:10

结果是 1。

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

第二步:

lua 复制代码
if(redis.call('sismember', orderKey, userId) == 1) then
    return 2
end

对应:

redis 复制代码
SISMEMBER seckill:order:10 888

结果是 0。

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

第三步:

lua 复制代码
redis.call('incrby', stockKey, -1)

对应:

redis 复制代码
INCRBY seckill:stock:10 -1

库存从 1 变成 0。

第四步:

lua 复制代码
redis.call('sadd', orderKey, userId)

对应:

redis 复制代码
SADD seckill:order:10 888

集合变成:

text 复制代码
seckill:order:10 = {888}

最后:

lua 复制代码
return 0

表示抢购资格通过。

Lua 内部流程图

#mermaid-svg-6UpDzfHkSYAneBcI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6UpDzfHkSYAneBcI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6UpDzfHkSYAneBcI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6UpDzfHkSYAneBcI .error-icon{fill:#552222;}#mermaid-svg-6UpDzfHkSYAneBcI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6UpDzfHkSYAneBcI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6UpDzfHkSYAneBcI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6UpDzfHkSYAneBcI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6UpDzfHkSYAneBcI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6UpDzfHkSYAneBcI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6UpDzfHkSYAneBcI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6UpDzfHkSYAneBcI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6UpDzfHkSYAneBcI .marker.cross{stroke:#333333;}#mermaid-svg-6UpDzfHkSYAneBcI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6UpDzfHkSYAneBcI p{margin:0;}#mermaid-svg-6UpDzfHkSYAneBcI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6UpDzfHkSYAneBcI .cluster-label text{fill:#333;}#mermaid-svg-6UpDzfHkSYAneBcI .cluster-label span{color:#333;}#mermaid-svg-6UpDzfHkSYAneBcI .cluster-label span p{background-color:transparent;}#mermaid-svg-6UpDzfHkSYAneBcI .label text,#mermaid-svg-6UpDzfHkSYAneBcI span{fill:#333;color:#333;}#mermaid-svg-6UpDzfHkSYAneBcI .node rect,#mermaid-svg-6UpDzfHkSYAneBcI .node circle,#mermaid-svg-6UpDzfHkSYAneBcI .node ellipse,#mermaid-svg-6UpDzfHkSYAneBcI .node polygon,#mermaid-svg-6UpDzfHkSYAneBcI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6UpDzfHkSYAneBcI .rough-node .label text,#mermaid-svg-6UpDzfHkSYAneBcI .node .label text,#mermaid-svg-6UpDzfHkSYAneBcI .image-shape .label,#mermaid-svg-6UpDzfHkSYAneBcI .icon-shape .label{text-anchor:middle;}#mermaid-svg-6UpDzfHkSYAneBcI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6UpDzfHkSYAneBcI .rough-node .label,#mermaid-svg-6UpDzfHkSYAneBcI .node .label,#mermaid-svg-6UpDzfHkSYAneBcI .image-shape .label,#mermaid-svg-6UpDzfHkSYAneBcI .icon-shape .label{text-align:center;}#mermaid-svg-6UpDzfHkSYAneBcI .node.clickable{cursor:pointer;}#mermaid-svg-6UpDzfHkSYAneBcI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6UpDzfHkSYAneBcI .arrowheadPath{fill:#333333;}#mermaid-svg-6UpDzfHkSYAneBcI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6UpDzfHkSYAneBcI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6UpDzfHkSYAneBcI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6UpDzfHkSYAneBcI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6UpDzfHkSYAneBcI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6UpDzfHkSYAneBcI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6UpDzfHkSYAneBcI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6UpDzfHkSYAneBcI .cluster text{fill:#333;}#mermaid-svg-6UpDzfHkSYAneBcI .cluster span{color:#333;}#mermaid-svg-6UpDzfHkSYAneBcI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6UpDzfHkSYAneBcI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6UpDzfHkSYAneBcI rect.text{fill:none;stroke-width:0;}#mermaid-svg-6UpDzfHkSYAneBcI .icon-shape,#mermaid-svg-6UpDzfHkSYAneBcI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6UpDzfHkSYAneBcI .icon-shape p,#mermaid-svg-6UpDzfHkSYAneBcI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6UpDzfHkSYAneBcI .icon-shape .label rect,#mermaid-svg-6UpDzfHkSYAneBcI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6UpDzfHkSYAneBcI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6UpDzfHkSYAneBcI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6UpDzfHkSYAneBcI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是



Lua 开始执行
读取 seckill:stock:{voucherId}
库存是否 <= 0?
返回 1:库存不足
SISMEMBER 判断 userId 是否已下单
用户是否已下单?
返回 2:重复下单
INCRBY 库存 -1
SADD 记录 userId 已下单
返回 0:资格通过


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

有人可能会想:

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

单线程下可以。

但秒杀不是单线程。

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

比如库存只剩 1:

text 复制代码
请求 A:GET 库存 = 1
请求 B:GET 库存 = 1
请求 A:INCRBY -1,库存变 0
请求 B:INCRBY -1,库存变 -1

这就出问题了。

Lua 的好处是:

text 复制代码
Redis 执行 Lua 脚本时,中间不会插入其他命令。

所以它可以保证:

text 复制代码
判断库存、判断重复、扣库存、记录用户

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


9. Java 代码如何执行 Lua

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

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

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

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

返回值含义

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

所以 Java 里:

java 复制代码
if (r != 0) {
    return Result.fail(r == 1 ? "库存不足" : "不能重复下单");
}

这句的意思就是:

text 复制代码
只要 Lua 返回不是 0,就不进入后续下单流程。

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

讲义中先生成订单 id:

java 复制代码
long orderId = redisIdWorker.nextId("order");

再执行 Lua:

java 复制代码
stringRedisTemplate.execute(..., voucherId, userId, orderId)

这样做是为了:

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

异步下单最大的特点是:

text 复制代码
请求线程不等待数据库订单创建完成。

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

就是这个订单 id。

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

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

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

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


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

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

不是。

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

它负责:

text 复制代码
判断 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. 总结

本篇的主线是:

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

这一篇只解决了:

text 复制代码
谁有资格抢。

下一篇继续讲:

text 复制代码
Lua 判断通过之后,订单任务如何进入阻塞队列,又是谁在后台真正创建订单?
相关推荐
盈建云系统1 小时前
外贸网站SEO怎么做?从产品关键词到询盘页面,独立站内容优化流程和费用参考
开发语言·网站搭建
Dream_ksw1 小时前
Python多继承之super()继承问题解决
开发语言·python
迈巴赫车主1 小时前
蓝桥杯21241灯塔java
java·开发语言·数据结构·算法·职场和发展·蓝桥杯·动态规划
半个烧饼不加肉1 小时前
JS 底层探究-- 调用栈(Call Stack)
开发语言·前端·javascript
弹简特2 小时前
【Java项目-轻聊】08-用户管理模块-实现获取用户信息+头像上传+显示头像
java·开发语言·springboot
vickycheung32 小时前
RK182X 如何在 RK3588 上进行应用测试
开发语言·php
至天2 小时前
FastAPI 接入 FastAPI-Limiter 以及使用 Redis 进行限流指南
redis·python·fastapi·请求限流
真实的菜2 小时前
Redis 从入门到精通(三):持久化机制 —— RDB 与 AOF 深度解析
数据库·redis·缓存
迷藏4942 小时前
双阶段动态权重匹配系统:高效精准的工业级解决方案
java·junit