1. 秒杀优化-异步秒杀思路
我们来回顾一下下单流程
当用户发起请求,此时会请求nginx
,nginx
会访问到tomcat
,而tomcat
中的程序,会进行串行操作,分成如下几个步骤
-
1、查询优惠卷
-
2、判断秒杀库存是否足够
-
3、查询订单
-
4、校验是否是一人一单
-
5、扣减库存
-
6、创建订单
在这六步操作中,由于有很多操作是要去操作数据库的,而且还是一个线程串行执行,添加了分布式锁, 这样就会导致我们这段程序执行耗时比较长,并发能力变弱了,所以我们需要异步程序执行,那么如何优化呢?
在这里笔者想给大家分享一下课程内没有的思路,看看有没有小伙伴这么想,比如,我们可以不可以使用异步编排来做,或者说我开启N多个线程,一个线程执行查询优惠卷,一个执行判断扣减库存,一个去创建订单等等,然后再统一做返回,这种做法和课程中有哪种好呢?答案是课程中的好,因为如果你采用我刚说的方式,如果访问的人很多,那么线程池中的线程可能一下子就被消耗完了,而且你使用上述方案,最大的特点在于,你觉得时效性会非常重要,但是你想想是吗?并不是,比如我只要确定他能做这件事,然后我后边慢慢做就可以了,我并不需要他一口气做完这件事,所以我们应当采用的是课程中,类似消息队列的方式来完成我们的需求,而不是使用线程池或者是异步编排的方式来完成这个需求。
优化方案:我们将耗时比较短的逻辑判断放入到redis
中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的,我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功, 再在后台开一个线程,后台线程慢慢的去执行阻塞queue
里边的消息,这样程序不就超级快了吗?而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池,当然这里边有两个难点:
-
第一个难点是我们怎么在
redis
中去快速校验一人一单,还有库存判断 -
第二个难点是由于我们校验和
tomcat
下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在redis
操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步queue
中去,后续操作中,可以通过这个id来查询我们tomcat
中的下单逻辑是否完成了。
我们现在来看看整体思路:
- 选择Redis存储结构: 用户下单我们只需要存储库存变量即可,可以直接使用
string
类型结构,而一人一单问题,我们是需要存储很多用户的购买记录,并且用户不能重复下单,所以这里我们选择set
存储结构再适合不过。 - 逻辑:当用户下单之后,判断库存是否充足只需要到
redis
中去根据key
找对应的value
是否大于0即可,如果不充足,则直接结束,如果充足,继续在redis
中判断用户是否可以下单,如果set
集合中没有这条数据,说明他可以下单,并扣减库存,再将userId存入当前优惠券的set
中,并且返回0,整个过程需要保证是原子性的,我们可以使用lua
来操作。当以上判断逻辑走完之后,我们可以判断当前redis
中返回的结果是否是0 ,如果是0,则表示可以下单,则将之前说的信息存入到阻塞queue
中去,此时开启单独的线程异步写入数据库中,最后返回订单id,前端可以通过返回的订单id来判断是否下单成功。
2. 秒杀优化-Redis完成秒杀资格判断
需求:
-
新增秒杀优惠券的同时,将优惠券信息保存到Redis中
-
基于Lua脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
-
如果抢购成功,将优惠券id和用户id封装后存入阻塞队列
-
开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
VoucherServiceImpl
👇 将用户资格判断放入redis中,用户响应信息只有和redis操作,性能大幅提高,新增秒杀优惠券的同时,将优惠券库存同步到redis中
@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中
//SECKILL_STOCK_KEY 这个变量定义在RedisConstans中
//private static final String SECKILL_STOCK_KEY ="seckill:stock:"
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
seckill.lua脚本
👇 对redis的查询、判断、数据操作写入一个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)
return 0
写完lua
脚本,剩下的就是执行lua
脚本,根据脚本拿到的结果,为非0
,根据情况返回"库存不足"
或者 "不能重复下单
;为0
,返回全局id生成器生成的订单id。至于订单保存到阻塞队列中的逻辑,咱们稍后实现。
VoucherOrderServiceImpl
3. 秒杀优化-基于阻塞队列实现秒杀优化
创建阻塞队列,保存数据类型为VoucherOrder
,并设置大小为 1024 * 1024
处理订单操作的逻辑:👇
4. 总结
秒杀业务的优化思路是什么?
- 先利用Redis完成库存余量、一人一单判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题(使用jdk中的阻塞队列,以后如果有大量的订单需要创建,很容易出现OOM问题)
- 数据安全问题(数据是存储在内存里的,若出现服务宕机了,任务还没执行完毕,导致用户的订单数据丢失)
针对此问题,我们将会再下一篇解决。