黑马点评秒杀优化二: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 判断通过之后,订单任务如何进入阻塞队列,又是谁在后台真正创建订单?