5.秒杀优化
5.1异步秒杀思路
我们先来回顾一下下单流程
当用户发起请求,此时会先请求Nginx,Nginx反向代理到Tomcat,而Tomcat中的程序,会进行串行操作,分为如下几个步骤
- 查询优惠券
- 判断秒杀库存是否足够
- 查询订单
- 校验是否一人一单
- 扣减库存
- 创建订单
在这六个步骤中,有很多操作都是要去操作数据库的,而且还是一个线程串行执行,这样就会导致我们的程序执行很慢,所以我们需要异步程序执行,那么如何加速呢?
优化方案:
我们将耗时较短的逻辑判断放到Redis中,例如:库存是否充足,是否一人一单这样的操作,只要满足这两条操作,那我们是一定可以下单成功的,不用等数据真的写进数据库,我们直接告诉用户下单成功就好了。然后后台再开一个线程,后台线程再去慢慢执行队列里的消息,这样我们就能很快的完成下单业务。但是这里还存在两个难点
- 我们怎么在Redis中快速校验是否一人一单,还有库存判断
- 我们校验一人一单和将下单数据写入数据库,这是两个线程,我们怎么知道下单是否完成。
- 我们需要将一些信息返回给前端,同时也将这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询下单逻辑是否完成
我们现在来看整体思路:当用户下单之后,判断库存是否充足,只需要取Redis中根据key找对应的value是否大于0即可,如果不充足,则直接结束。如果充足,则在Redis中判断用户是否可以下单,如果set集合中没有该用户的下单数据,则可以下单,并将userId和优惠券存入到Redis中,并且返回0,整个过程需要保证是原子性的,所以我们要用Lua来操作,同时由于我们需要在Redis中查询优惠券信息,所以在我们新增秒杀优惠券的同时,需要将优惠券信息保存到Redis中
完成以上逻辑判断时,我们只需要判断当前Redis中的返回值是否为0,如果是0,则表示可以下单,将信息保存到queue中去,然后返回,开一个线程来异步下单,其阿奴单可以通过返回订单的id来判断是否下单成功
5.2Redis完成秒杀资格判断
- 需求:
- 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
- 基于Lua脚本,判断秒杀库存、一人一单,决定用户是否秒杀成功
步骤一:
修改保存优惠券相关代码
@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);
// 保存秒杀优惠券信息到Reids,Key名中包含优惠券ID,Value为优惠券的剩余数量
stringRedisTemplate.opsForValue().set(SECKILL_STOCK_KEY + voucher.getId(), voucher.getStock().toString());
}
- 使用PostMan发送请求,添加优惠券
请求路径:http://localhost:8080/api/voucher/seckill
请求方式:POST{ "shopId":1, "title":"9999元代金券", "subTitle":"365*24小时可用", "rules":"全场通用\\nApex猎杀无需预约", "payValue":1000, "actualValue":999900, "type":1, "stock":100, "beginTime":"2022-01-01T00:00:00", "endTime":"2022-12-31T23:59:59" }
- 添加成功后,数据库中和Redis中都能看到优惠券信息
步骤二:
编写Lua脚本
lua的字符串拼接使用..
,字符串转数字是tonumber()
-- 订单id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 优惠券key
local stockKey = 'seckill:stock:' .. voucherId
-- 订单key
local orderKey = 'seckill:order:' .. voucherId
-- 判断库存是否充足
if (tonumber(redis.call('get', stockKey)) <= 0) then
return 1
end
-- 判断用户是否下单
if (redis.call('sismember', orderKey, userId) == 1) then
return 2
end
-- 扣减库存
redis.call('incrby', stockKey, -1)
-- 将userId存入当前优惠券的set集合
redis.call('sadd', orderKey, userId)
return 0
- 修改业务逻辑
@Override public Result seckillVoucher(Long voucherId) { //1. 执行lua脚本 Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), UserHolder.getUser().getId().toString()); //2. 判断返回值,并返回错误信息 if (result.intValue() != 0) { return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单"); } long orderId = redisIdWorker.nextId("order"); //TODO 保存阻塞队列 //3. 返回订单id return Result.ok(orderId); }
- 现在我们使用PostMan发送请求,redis中的数据会变动,而且不能重复下单,但是数据库中的数据并没有变化
5.3基于阻塞队列实现秒杀优化
- 修改下单的操作,我们在下单时,是通过Lua表达式去原子执行判断逻辑,如果判断结果不为0,返回错误信息,如果判断结果为0,则将下单的逻辑保存到队列中去,然后异步执行
- 需求
- 如果秒杀成功,则将优惠券id和用户id封装后存入阻塞队列
- 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
步骤一:
创建阻塞队列
阻塞队列有一个特点:当一个线程尝试从阻塞队列里获取元素的时候,如果没有元素,那么该线程就会被阻塞,直到队列中有元素,才会被唤醒,并去获取元素
阻塞队列的创建需要指定一个大小private final BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);
- 那么把优惠券id和用户id封装后存入阻塞队列
@Override public Result seckillVoucher(Long voucherId) { Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), UserHolder.getUser().getId().toString()); if (result.intValue() != 0) { return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单"); } long orderId = redisIdWorker.nextId("order"); //封装到voucherOrder中 VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setVoucherId(voucherId); voucherOrder.setUserId(UserHolder.getUser().getId()); voucherOrder.setId(orderId); //加入到阻塞队列 orderTasks.add(voucherOrder); return Result.ok(orderId); }
步骤二:
实现异步下单功能 - 先创建一个线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
- 创建线程任务,秒杀业务需要在类初始化之后,就立即执行,所以这里需要用到
@PostConstruct
注解@PostConstruct private void init() { SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler()); } private class VoucherOrderHandler implements Runnable { @Override public void run() { while (true) { try { //1. 获取队列中的订单信息 VoucherOrder voucherOrder = orderTasks.take(); //2. 创建订单 handleVoucherOrder(voucherOrder); } catch (Exception e) { log.error("订单处理异常", e); } } } }
- 编写创建订单的业务逻辑
private IVoucherOrderService proxy; private void handleVoucherOrder(VoucherOrder voucherOrder) { //1. 获取用户 Long userId = voucherOrder.getUserId(); //2. 创建锁对象,作为兜底方案 RLock redisLock = redissonClient.getLock("order:" + userId); //3. 获取锁 boolean isLock = redisLock.tryLock(); //4. 判断是否获取锁成功 if (!isLock) { log.error("不允许重复下单!"); return; } try { //5. 使用代理对象,由于这里是另外一个线程, proxy.createVoucherOrder(voucherOrder); } finally { redisLock.unlock(); } }
- 查看AopContext源码,它的获取代理对象也是通过ThreadLocal进行获取的,由于我们这里是异步下单,和主线程不是一个线程,所以不能获取成功
private static final ThreadLocal<Object> currentProxy = new NamedThreadLocal("Current AOP proxy");
- 但是我们可以将proxy放在成员变量的位置,然后在主线程中获取代理对象
@Override public Result seckillVoucher(Long voucherId) { Long result = stringRedisTemplate.execute(SECKILL_SCRIPT, Collections.emptyList(), voucherId.toString(), UserHolder.getUser().getId().toString()); if (result.intValue() != 0) { return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单"); } long orderId = redisIdWorker.nextId("order"); //封装到voucherOrder中 VoucherOrder voucherOrder = new VoucherOrder(); voucherOrder.setVoucherId(voucherId); voucherOrder.setUserId(UserHolder.getUser().getId()); voucherOrder.setId(orderId); //加入到阻塞队列 orderTasks.add(voucherOrder); //主线程获取代理对象 proxy = (IVoucherOrderService) AopContext.currentProxy(); return Result.ok(orderId); }
5.4小结
秒杀业务的优化思路是什么?
- 先利用Redis完成库存容量、一人一单的判断,完成抢单业务
- 再将下单业务放入阻塞队列,利用独立线程异步下单
基于阻塞队列的异步秒杀存在哪些问题?
- 内存限制问题:
- 我们现在使用的是JDK里的阻塞队列,它使用的是JVM的内存,如果在高并发的条件下,无数的订单都会放在阻塞队列里,可能就会造成内存溢出,所以我们在创建阻塞队列时,设置了一个长度,但是如果真的存满了,再有新的订单来往里塞,那就塞不进去了,存在内存限制问题
- 数据安全问题:
- 经典服务器宕机了,用户明明下单了,但是数据库里没看到
- 内存限制问题: