当前位置: 首页 > news >正文

6.秒杀优化

6.1 异步秒杀思路

回顾一下下单流程

  • 当用户发起请求,此时会请求 nginx,nginx 会访问到 tomcat,而 tomcat 中的程序,会进行串行操作,分成如下几个步骤
  1. 1. 查询优惠卷
  2. 2. 判断是否在秒杀活动期间
  3. 3. 判断秒杀库存是否足够
  4. 4. 查询订单
  5. 5. 校验是否是一人一单
  6. 6. 扣减库存
  7. 7. 创建订单

在这六步操作中,又有很多操作是要去操作数据库的,而且还是一个线程串行执行,这样就会导致我们的程序执行的很慢,所以我们需要异步程序执行,那么如何加速呢?

6.1.1 优化方案

  • 我们将耗时比较短的逻辑判断放入到 redis 中,比如是否库存足够,比如是否一人一单,这样的操作,只要这种逻辑可以完成,就意味着我们是一定可以下单完成的
  • 我们只需要进行快速的逻辑判断,根本就不用等下单逻辑走完,我们直接给用户返回成功
  • 再在后台开一个线程,后台线程慢慢的去执行 queue 里边的消息,这样程序不就超级快了吗?
  • 而且也不用担心线程池消耗殆尽的问题,因为这里我们的程序中并没有手动使用任何线程池
  • 当然这里边有两个难点
    • 第一个难点是我们怎么在 redis 中去快速校验一人一单,还有库存判断
    • 第二个难点是由于我们校验和 tomct 下单是两个线程,那么我们如何知道到底哪个单他最后是否成功,或者是下单完成,为了完成这件事我们在 redis 操作完之后,我们会将一些信息返回给前端,同时也会把这些信息丢到异步 queue 中去,后续操作中,可以通过这个 id 来查询我们 tomcat 中的下单逻辑是否完成了。

我们现在来看看整体思路:

  • 当用户下单之后,判断库存是否充足只需要到 redis 中去根据 key 找对应的 value 是否大于 0 即可
  • 如果不充足,则直接结束,如果充足,继续在 redis 中判断用户是否可以下单
  • 如果 set 集合中没有这条数据,说明他可以下单
  • 如果 set 集合中没有这条记录,则将 userId 和优惠卷存入到 redis 中,并且返回 0,整个过程需要保证是原子性的,我们可以使用 lua 来操作

当以上判断逻辑走完之后,我们可以判断当前 redis 中返回的结果是否是 0,如果是 0,则表示可以下单,则将之前说的信息存入到到 queue 中去,然后返回,然后再来个线程异步的下单,前端可以通过返回的订单 id 来判断是否下单成功。

在这里笔者想给大家分享一下课程内没有的思路,看看有没有小伙伴这么想,比如,我们可以不可以使用异步编排来做,或者说我开启 N 多线程,N 多个线程,一个线程执行查询优惠卷,一个执行判断扣减库存,一个去创建订单等等,然后再统一做返回,这种做法和课程中有哪种好呢?答案是课程中的好,因为如果你采用我刚说的方式,如果访问的人很多,那么线程池中的线程可能一下子就被消耗完了,而且你使用上述方案,最大的特点在于,你觉得时效性会非常重要,但是你想想是吗?并不是,比如我只要确定他能做这件事,然后我后边慢慢做就可以了,我并不需要他一口气做完这件事,所以我们应当采用的是课程中,类似消息队列的方式来完成我们的需求,而不是使用线程池或者是异步编排的方式来完成这个需求

6.1.2 Redis 完成秒杀资格判断

需求:

  • 新增秒杀优惠券的同时,将优惠券信息保存到 Redis 中
  • 基于 Lua 脚本,判断秒杀库存、一人一单,决定用户是否抢购成功
  • 如果抢购成功,将优惠券 id 和用户 id 封装后存入阻塞队列
  • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
-- 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.库存不足,返回1return 1
end
-- 3.2.判断用户是否下单 SISMEMBER orderKey userId
if(redis.call('sismember', orderKey, userId) == 1) then-- 3.3.存在,说明是重复下单,返回2return 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
@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.判断结果是否为0if (r != 0) {// 2.1.不为0 ,代表没有购买资格return Result.fail(r == 1 ? "库存不足" : "不能重复下单");}//TODO 保存阻塞队列// 3.返回订单idreturn Result.ok(orderId);

6.1.3 实现下单阻塞队列

修改下单动作,现在我们去下单时,是通过 lua 表达式去原子执行判断逻辑,如果判断我出来不为 0,则要么是库存不足,要么是重复下单,返回错误信息,如果是 0,则把下单的逻辑保存到队列中去,然后异步执行

//异步处理线程池
private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();//在类初始化之后执行,因为当这个类初始化好了之后,随时都是有可能要执行的
@PostConstruct
private void init() {SECKILL_ORDER_EXECUTOR.submit(new VoucherOrderHandler());
}
// 用于线程池处理的任务
// 当初始化完毕后,就会去从对列中去拿信息private class VoucherOrderHandler implements Runnable{@Overridepublic void run() {while (true){try {// 1.获取队列中的订单信息VoucherOrder voucherOrder = orderTasks.take();// 2.创建订单handleVoucherOrder(voucherOrder);} catch (Exception e) {log.error("处理订单异常", e);}}}private void handleVoucherOrder(VoucherOrder voucherOrder) {//1.获取用户Long userId = voucherOrder.getUserId();// 2.创建锁对象RLock redisLock = redissonClient.getLock("lock:order:" + userId);// 3.尝试获取锁boolean isLock = redisLock.lock();// 4.判断是否获得锁成功if (!isLock) {// 获取锁失败,直接返回失败或者重试log.error("不允许重复下单!");return;}try {//注意:由于是spring的事务是放在threadLocal中,此时的是多线程,事务会失效proxy.createVoucherOrder(voucherOrder);} finally {// 释放锁redisLock.unlock();}}//aprivate BlockingQueue<VoucherOrder> orderTasks =new  ArrayBlockingQueue<>(1024 * 1024);@Overridepublic 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.判断结果是否为0if (r != 0) {// 2.1.不为0 ,代表没有购买资格return Result.fail(r == 1 ? "库存不足" : "不能重复下单");}VoucherOrder voucherOrder = new VoucherOrder();// 2.3.订单idlong orderId = redisIdWorker.nextId("order");voucherOrder.setId(orderId);// 2.4.用户idvoucherOrder.setUserId(userId);// 2.5.代金券idvoucherOrder.setVoucherId(voucherId);// 2.6.放入阻塞队列orderTasks.add(voucherOrder);//3.获取代理对象proxy = (IVoucherOrderService)AopContext.currentProxy();//4.返回订单idreturn Result.ok(orderId);}@Transactionalpublic  void createVoucherOrder(VoucherOrder voucherOrder) {Long userId = voucherOrder.getUserId();// 5.1.查询订单int count = query().eq("user_id", userId).eq("voucher_id", voucherOrder.getVoucherId()).count();// 5.2.判断是否存在if (count > 0) {// 用户已经购买过了log.error("用户已经购买过了");return ;}// 6.扣减库存boolean success = seckillVoucherService.update().setSql("stock = stock - 1") // set stock = stock - 1.eq("voucher_id", voucherOrder.getVoucherId()).gt("stock", 0) // where id = ? and stock > 0.update();if (!success) {// 扣减失败log.error("库存不足");return ;}save(voucherOrder);}

6.1.4 小总结

秒杀业务的优化思路是什么?

  • 先利用 Redis 完成库存余量、一人一单判断,完成抢单业务
  • 再将下单业务放入阻塞队列,利用独立线程异步下单
  • 基于阻塞队列的异步秒杀存在哪些问题?
    • 内存限制问题:一直往阻塞队列中放,容易 OOM
    • 数据安全问题:数据全在 JVM 内存中(阻塞队列中),挂了全没了

6.2 消息队列

6.2.1 redis 消息队列

什么是消息队列:字面意思就是存放消息的队列。最简单的消息队列模型包括 3 个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
  • 生产者:发送消息到消息队列
  • 消费者:从消息队列获取消息并处理消息

使用队列的好处在于

  • 解耦:所谓解耦,举一个生活中的例子就是:快递员 (生产者) 把快递放到快递柜里边 (Message Queue) 去,我们 (消费者) 从快递柜里边去拿东西,这就是一个异步,如果耦合,那么这个快递员相当于直接把快递交给你,这事固然好,但是万一你不在家,那么快递员就会一直等你,这就浪费了快递员的时间,所以这种思想在我们日常开发中,是非常有必要的
  • 这种场景在我们秒杀中就变成了:我们下单之后,利用 redis 去进行校验下单条件,再通过队列把消息发送出去,然后再启动一个线程去消费这个消息,完成解耦,同时也加快我们的响应速度。
  • 这里我们可以使用一些现成的 mq,比如 kafka,rabbitmq 等等,但是呢,如果没有安装 mq,我们也可以直接使用 redis 提供的 mq 方案,降低我们的部署和学习成本。

6.2.2 基于 List 的 MQ

消息队列(Message Queue),字面意思就是存放消息的队列。而 Redis 的 list 数据结构是一个双向链表,很容易模拟出队列效果。

队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP 来实现。

不过要注意的是,当队列中没有消息时 RPOP 或 LPOP 操作会返回 null,并不像 JVM 的阻塞队列那样会阻塞并等待消息。因此这里应该使用 BRPOP 或者 BLPOP 来实现阻塞效果。

基于 List 的消息队列有哪些优缺点?

优点:

  • 利用 Redis 存储,不受限于 JVM 内存上限
  • 基于 Redis 的持久化机制,数据安全性有保证
  • 可以满足消息有序性

缺点:

  • 无法避免消息丢失
  • 只支持单消费者

6.2.3 基于 PubSub 的 MQ

PubSub(发布订阅)是 Redis2.0 版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个 channel,生产者向对应 channel 发送消息后,所有订阅者都能收到相关消息。

  • SUBSCRIBE channel [channel]:订阅一个或多个频道
  • PUBLISH channel msg:向一个频道发送消息
  • PSUBSCRIBE pattern[pattern]:订阅与 pattern 格式匹配的所有频道

基于 PubSub 的消息队列有哪些优缺点?

优点:

  • 采用发布订阅模型,支持多生产、多消费

缺点:

  • 不支持数据持久化
  • 无法避免消息丢失
  • 消息堆积有上限,超出时数据丢失

6.2.4 基于 Stream 的 MQ

Stream 是 Redis 5.0 引入的一种新数据类型,可以实现一个功能非常完善的消息队列。

  • 发送消息的命令:
  • 读取消息的方式之一:XREAD
  • XREAD 阻塞方式,读取最新的消息:
  • 在业务开发中,我们可以循环的调用 XREAD 阻塞方式来查询最新消息,从而实现持续监听队列的效果

注意:当我们指定起始 ID 为 $ 时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过 1 条以上的消息到达队列,则下次获取时也只能获取到最新的一条,会出现漏读消息的问题

STREAM 类型消息队列的 XREAD 命令特点:

  • 消息可回溯
  • 一个消息可以被多个消费者读取
  • 可以阻塞读取
  • 有消息漏读的风险

6.2.5 消费者组

消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列。具备下列特点:

  • 创建消费者组:
  • key:队列名称
  • groupName:消费者组名称
  • ID:起始 ID 标示,$ 代表队列中最后一个消息,0 则代表队列中第一个消息
  • MKSTREAM:队列不存在时自动创建队列
  • 其它常见命令:

删除指定的消费者组

XGROUP DESTORY key groupName

给指定的消费者组添加消费者

XGROUP CREATECONSUMER key groupname consumername

删除消费者组中的指定消费者

XGROUP DELCONSUMER key groupname consumername

从消费者组读取消息:

XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]
  • group:消费组名称
  • consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
  • count:本次查询的最大数量
  • BLOCK milliseconds:当没有消息时最长等待时间
  • NOACK:无需手动 ACK,获取到消息后自动确认
  • STREAMS key:指定队列名称
  • ID:获取消息的起始 ID:

">":从下一个未消费的消息开始

其它:根据指定 id 从 pending-list 中获取已消费但未确认的消息,例如 0,是从 pending-list 中的第一个消息开始

消费者监听消息的基本思路:

STREAM 类型消息队列的 XREADGROUP 命令特点:

  • 消息可回溯
  • 可以多消费者争抢消息,加快消费速度
  • 可以阻塞读取
  • 没有消息漏读的风险
  • 有消息确认机制,保证消息至少被消费一次

最后我们来个小对比

6.3 Stream 实现异步秒杀

需求:

  • 创建一个 Stream 类型的消息队列,名为 stream.orders
  • 修改之前的秒杀下单 Lua 脚本,在认定有抢购资格后,直接向 stream.orders 中添加消息,内容包含 voucherId、userId、orderId

    修改 lua 表达式,新增 3.6

    private class VoucherOrderHandler implements Runnable {@Overridepublic void run() {while (true) {try {// 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(Consumer.from("g1", "c1"),StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),StreamOffset.create("stream.orders", ReadOffset.lastConsumed()));// 2.判断订单信息是否为空if (list == null || list.isEmpty()) {// 如果为null,说明没有消息,继续下一次循环continue;}// 解析数据MapRecord<String, Object, Object> record = list.get(0);Map<Object, Object> value = record.getValue();VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);// 3.创建订单createVoucherOrder(voucherOrder);// 4.确认消息 XACKstringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());} catch (Exception e) {log.error("处理订单异常", e);//处理异常消息handlePendingList();}}}private void handlePendingList() {while (true) {try {// 1.获取pending-list中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 0List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(Consumer.from("g1", "c1"),StreamReadOptions.empty().count(1),StreamOffset.create("stream.orders", ReadOffset.from("0")));// 2.判断订单信息是否为空if (list == null || list.isEmpty()) {// 如果为null,说明没有异常消息,结束循环break;}// 解析数据MapRecord<String, Object, Object> record = list.get(0);Map<Object, Object> value = record.getValue();VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);// 3.创建订单createVoucherOrder(voucherOrder);// 4.确认消息 XACKstringRedisTemplate.opsForStream().acknowledge("s1", "g1", record.getId());} catch (Exception e) {log.error("处理pendding订单异常", e);try{Thread.sleep(20);}catch(Exception e){e.printStackTrace();}}}}
    }
  • 项目启动时,开启一个线程任务,尝试获取 stream.orders 中的消息,完成下单

6.4 秒杀模块大总结

  • 问题 1-全局唯一 ID
    • id 不能具有明显的规则
    • mysql 的自增 id 无法保证以后的业务
    • 解决方案:
      • 采用全局 ID 生成器,组成为:符号位(永远为 0),时间戳(31bit),序列号(32bit)
      • 专门一个表控制全局 ID
      • UUID 生成
  • 秒杀下单
    1. 业务上判断秒杀活动是否开始,库存是否充足
    2. 先查询优惠卷信息,判断时间是否允许 ---- 判断库存是否充足 --- 扣减库存 --- 创建订单 --- 返回 id
  • 问题 2-超卖
    • 这里还没扣减库存,另一个线程发现大于 1,一起扣减库存,实质上最后的结果为扣减了 1
    • 解决方案:
      • 悲观锁-在进行扣减库存前就获得锁封锁整个数据库的操作,确保线程串行执行;
      • 乐观锁-不加锁,而是在更新时判断数据是否被修改了
    • 这里采用乐观锁方式只要库存没被修改就扣减
    • 进一步优化为库存大于 0 即可扣减。原因是这里要保证的是最终一致性而不是强一致性
  • 一人一单
    • 逻辑改变为:判断时间 --- 判断库存 --- 根据优惠券 id 和用户 id 判断是否已经使用过 --- 返回错误/执行下单逻辑
  • 问题 4-一人一单
    • 多线程情况下(该用户)可能多个线程同时判断自己未购买过,然后执行下面的扣减库存-创建订单逻辑
    • 解决方案:
      • synchronized 锁封装 (获取用户信息-判断是否已经购买-扣减库存-创建订单)方法。问题:锁方法粒度太大
      • 改为 synchronized 锁部分逻辑,这里使用 intern()从常量池中拿数据,因为如果直接使用 userId.toString()拿出来的对象不是同一个,因为是不同线程 new 出来的对象,无法作为锁。问题:方法被 spring 事务控制,在方法内部加锁,可能导致档案事务还没有提交,但是锁已经释放导致问题
      • 改为将方法整个包裹起来,使用代理模式获取方法
  • 问题 5-分布式并发
    • 通过锁可以解决在单机情况下的一人一单安全问题,但如果在多服务集群下(分布式下),两个秒杀请求不同的服务,synchronized 只能锁自己 JVM 内的逻辑,两个服务同时进行秒杀逻辑造成一人多单
    • 解决方案-分布式锁
      • 满足可见性,互斥,高可用,高性能,安全性
      • 三种实现:mysql(CA)、redis(AP)、zookeeper(CP)
      • 思路:获取锁-互斥-非阻塞;释放锁-手动释放-超时释放
      • 利用 setnx 方法,第一个线程进入加锁
  • 问题 6-分布式并发
    • 持有锁的线程在内部出现了阻塞,导致释放,另一个线程拿到了锁执行下单逻辑,线程 1、2 都完成下单,且线程 1 在释放锁时会将线程 2 的锁释放
    • 解决方案:
      • 增加锁超时自动续期
      • 扣减库存前再次判断是否已经下单
      • 释放锁时判断是否属于自己(这不是重点)-- 用 value 作为标识判断,使用 lua 脚本保证原子性
  • 问题 7-增加重试-续期-重入
    • 解决方案:
      • redlock
  • 问题 8-性能
    • 整个秒杀业务逻辑是串行的:
    1. 查询优惠卷
    2. 判断是否在秒杀活动期间
    3. 判断秒杀库存是否足够
    4. 查询订单
    5. 校验是否是一人一单
    6. 扣减库存
    7. 创建订单
    8. 返回前端
    • 解决方案
      • redis 优化:将耗时比较短的逻辑放入 redis 中(库存是否足够,是否为一人一单),完成后就可以认为可以正常下单,直接返回给用户成功,再开启一个下次执行队列中的任务
      • 将一些信息存入 redis 进行判断,利用 set 存储优惠券对应的消费过的用户 id
      • 基于 lua 脚本判断库存、一人一单
      • 成功后将优惠券 id 以及用户 id 放如阻塞队列,开启线程任务从阻塞队列中获得先实现异步下单
  • 问题 9-安全
    • 阻塞队列有内存限制,容易 OOM,数据爱 JVM 内存汇总,挂了全没了,导致订单无法正常生成
    • 解决方案-MQ:
      • 基于 List 的 MQ:只支持单消费者,无法避免信息丢失
      • 基于 PubSub 的 MQ:不支持持久化,信息丢失,堆积上线
      • 基于 Stream 的 MQ:增加消费者组,生产可用

思路总结:正常秒杀----超卖问题----乐观锁解决----一人一单问题----synchronized 加锁解决----分布式下问题----分布式锁解决----超时释放问题----watchdog 解决----张冠李戴问题----lua 脚本解决----增加可用性----重试机制----汇总为 redlock 解决方案增加重入机制----性能问题----异步阻塞队列解决----阻塞队列安全问题----MQ 解决

相关文章:

  • HTTP:十三.HTTP日志
  • 数据库版本控制工具--flyway
  • CSDN 中 LaTeX 数学公式输入方法
  • 思考:(linux) tmux 超级终端快速入门的宏观思维
  • c++ using使用
  • 通信原理绪论
  • JDBC工具类的三个版本
  • 【python】json解析:invalid literal for int() with base 10: ‘\“\“‘“
  • 工厂节能新路径:精准节能的深度剖析
  • YOLO目标检测算法评估标准
  • C++STL——stack,queue
  • Python3(30) 正则表达式
  • 两数相加(2)
  • Profinet转CanOpen网关,打破协议壁垒的关键技术
  • 国内特殊车辆检测数据集VOC+YOLO格式4930张3类别
  • NVMe控制器IP设计之接口模块
  • Python核心编程深度解析:作用域、递归与匿名函数的工程实践
  • Python自动化-python基础(下)
  • C++中的static_cast:类型转换的安全卫士
  • 警备,TRO风向预警,In-N-Out Burgers维权风暴来袭
  • 中拉论坛第四届部长级会议将举行,外交部介绍情况
  • ​中国超大规模市场是信心所在——海南自贸港建设一线观察
  • 上海楼市“银四”兑现:新房市场高端改善领跑,二手房量价企稳回升
  • 印度外交秘书:“朱砂行动”不针对军事设施,无意升级事态
  • 盖茨:20年内将捐出几乎全部财富,盖茨基金会2045年关闭
  • 上海如何为街镇营商环境赋能?送政策、配资源、解难题、强活力