Springboot实战——黑马点评之秒杀优化

Springboot实战——黑马点评之 秒杀优化

1 秒杀优化

先来复习以下,秒杀优惠券业务的现有实现逻辑:


以上流程图中的操作串行执行,效率极低。
其中 判断秒杀库存 以及 校验一人一单 属于对数据库的读取,耗时较少;扣减库存 以及 创建订单 属于对数据库的写操作,耗时相对较久。

提升效率的方法我们可以考虑两个方面:
1)引入并发(开启多线程):主线程负责读取操作,如果读取检验资格通过,则开启另外的线程负责写操作
2)引入Redis缓存:可以将订单信息以及秒杀券信息存入Redis,在Redis中检验资格后,将符合资格的优惠券id+用户id+订单id存入阻塞队列,单独开启第二线程来读取阻塞队列执行写操作,即刻给用户返回下单订单号。

1.1 引入Redis进行资格检验

资格检验分为 检查库存是否充足 以及 用户是否下单过该优惠券 两个操作,如果引入Redis来实现,要考虑:

  • 秒杀券库存导入Redis,并且要数据及时更新同步,即 在检验资格通过后需要将Redis中的券库存-1
  • 下单记录:使用的数据结构需要满足1 集合;2 元素唯一性
  • 使用Redis中的set类型来缓存下单该优惠券的用户id集合,并且要保证数据及时更新同步,即 在检验资格通过后需要向set中添加用户id


以上所考虑的几点还需要保证操作的原子性,所以使用Redis的Lua脚本来实现。
Lua脚本需要的ARGV参数列表中有两个待定参数,分别是优惠券id 以及 用户id,其他的业务逻辑均调用Redis命令即可实现

-- 1. 参数列表
-- 1.1. 优惠券id 用于查询优惠券库存时的关键字
local voucherId = ARGV[1]
-- 1.2. 用户id 用于将查询下单用户对比
local useId = ARGV[2]

-- 2. 数据key
-- 2.1. 库存key + 业务前缀 拼接 优惠券id
local stockKey = 'seckill:stock:' .. voucherId
-- 2.2 订单key
local orderKey = 'seckill:order:' .. voucherId


-- 3. 业务执行
-- 3.1 首先判断库存是否充足
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 下单(插入用户id) sadd orderKey userId
redis.call('sadd', orderKey, userId)

return 0

如果库存不足则返回1(Long),如果该用户重复下单则返回2(Long),如果资格检验通过则返回0

如果资格检验通过,则需要保证该有效订单被阻塞队列拿到,后续阻塞式执行成功,所以将“凭证”(封装好用户id、券id、订单id的订单实例)传入阻塞队列,等待异步线程阻塞式读取处理下单业务。

// 这里直接封装成订单实例
VoucherOrder voucherOrder = new VoucherOrder();
voucherOrder.setVoucherId(voucherId);
voucherOrder.setUserId(userId);
voucherOrder.setId(redisIdWorker.nextId("order"));
// 放入阻塞队列 blockingqueue
orderTasks.add(voucherOrder);

1.2 开启异步线程写数据库

需要准备以下几个数据结构:

  • 阻塞队列:当一个线程尝试从该队列中获取元素时,当查询到队列为空时会阻塞等待,直到队列中插入元素后被唤醒,不会导致线程空转消耗CPU资源。
  • 异步线程实现下单即 开启异步独立线程来阻塞式执行下单业务,所以需要准备1 线程池 2 线程任务
  • 线程池常量(单线程线程处理器),用于提交异步任务
// 线程池/线程处理器 此处创建的是单线程处理器
private static final ExecutorService SECKILL_ORDER_EXECUTOR
                    = Executors.newSingleThreadExecutor();

并且要保证在类初始化,在用户最初调用该接口时就同步开启线程处理器

@PostConstruct
private void init(){
    SECKILL_ORDER_EXECUTOR.submit(new voucherOrderHandler());
}
  • 线程任务
// 定义交给线程池执行的业务内容
private class voucherOrderHandler implements Runnable{

    @Override
    public void run(){
       // 该线程执行任务为阻塞式的 当发现队列中存在元素时才进行
       while(true){
           //...这里执行下单的具体业务
           //1. 获取队列中的订单信息
           try {
              VoucherOrder voucherOrder = orderTasks.take();
              // 2. 下单业务
              handleVoucherOrder(voucherOrder);
           } catch (Exception e) {
              log.error("处理订单异常",e);
           }
    }

    }
}

1.3 以上异步实现的弊端

1)内存限制问题:阻塞队列是有JDK内部的,底层使用的是JVM内存,如果有大量的订单信息被存入阻塞队列,将会带来较大内存负担,内存溢出
2)数据安全问题:JVM内存没有持久化机制。如果服务宕机,内存中的订单信息消失,用户支付状态与后台保存订单状态不一致;或者是 从阻塞队列中取出订单信息后尚未来得及处理下单逻辑,服务宕机了,将会造成订单丢失的问题。

2 Redis消息队列实现异步秒杀

使用Redis消息队列的两个优势:
1)Redis的消息队列是独立于JVM之外的数据结构,不受JVM内存的限制
2)Redis的消息队列可对消息作持久化,保证数据安全性,且封装有消息确认机制,确保了消息至少被消费一次

2.1 Redis实现消息队列的三种方式

  • 基于List结构:


    使用BPOP来阻塞式从Redis的list数据结构中获取队首元素,本质上原理和JDK的阻塞队列一样的。





    这样实现的弊端
    1)无法避免服务宕机导致的消息丢失
    2)只支持单消费者

  • 基于PubSub结构
    支持多消费者了,支持多生产、多消费



    这样实现的弊端
    1)不支持数据持久化,发送消息时如果消息无人订阅,消息不会永久存储在Redis中
    2)消息堆积有上限,消费者接收数据有缓存区,如果消息缓存超额,则会造成数据丢失了
    3)无法避免消息丢失

  • 基于Stream数据类型





    如果基于Stream数据类型来实现异步下单业务,则会出现消息漏读问题

  • 基于Stream的消费者组
    消费者组:将多个消费者划分到一个组中,该组监听同一个队列
    这样设计有以下几个特点:
    1)消息分流:同组内的消费者用来“竞争”同一个队列中的消息,与单消费者相比,加快了处理消息的速度,且消息可回溯
    2)消息标示:消费者组会维护一个标示,记录最后一个被处理的消息,如果服务宕机重启,能从标示之后读取消息,确保每一个消息都被消费成功,避免像单消费者出现漏读消息的问题
    3)消息确认:消费者组当获取到一个消息时,会将消息插入Pending-list中,标志该消息尚未处理,当处理结束后,会通过XACK来确认消息已处理,然后从pending-list中移除


    当消费者获取到消息时,消息会自动放入pending-list中,等待消费者处理完毕后发出XACK确认后才将其移除


    消费者1和2相继从s1队列中读取未读取过的第一条消息,与此同时这些消息均被放入了Pending-list中,等待消息确认Ack

2.2 Stream队列实现异步下单

所以可以将原有的异步下单功能替换成用Stream队列实现:
循环从Stream队列中读取订单信息 -> 消费者组以最后一个被获取的消息标识($),读取队列中还没被消费的消息,并设置2秒内阻塞式(|block)读取 -> 如果阻塞等待时间内并未拿到最新消息则continue -> 如果阻塞等待时间内获取到新消息,则按下单业务将其处理
捕获异常 -> 意味着此时pending-list中存在已被消费但未被处理完毕的消息 -> 循环从pending-list中获取第0号消息(非阻塞式,0)来尝试继续处理 -> 如果获取到尚未处理过的消息,则按正常下单业务继续处理 -> 如果没有异常中止的消息则结束异常捕获业务
如果捕获异常过程中又遇到异常 -> 继续循环读取pending-list