Redis实战(黑马点评--异步秒杀消息队列)

异步秒杀思路

  • 我们先来回顾一下下单流程
  • 当用户发起请求,此时会先请求Nginx,Nginx反向代理到Tomcat,而Tomcat中的程序,会进行串行操作,分为如下几个步骤
    1. 查询优惠券
    2. 判断秒杀库存是否足够
    3. 查询订单
    4. 校验是否一人一单
    5. 扣减库存
    6. 创建订单
  • 在这六个步骤中,有很多操作都是要去操作数据库的,而且还是一个线程串行执行,这样就会导致我们的程序执行很慢,所以我们需要异步程序执行,那么如何加速呢?
  • 优化方案:我们将耗时较短的逻辑判断放到Redis中,例如:库存是否充足,是否一人一单这样的操作,只要满足这两条操作,那我们是一定可以下单成功的,不用等数据真的写进数据库,我们直接告诉用户下单成功就好了。然后后台再开一个线程,后台线程再去慢慢执行队列里的消息,这样我们就能很快的完成下单业务。

  • 但是这里还存在两个难点

    1. 我们怎么在Redis中快速校验是否一人一单,还有库存判断
    2. 我们校验一人一单和将下单数据写入数据库,这是两个线程,我们怎么知道下单是否完成。
      • 我们需要将一些信息返回给前端,同时也将这些信息丢到异步queue中去,后续操作中,可以通过这个id来查询下单逻辑是否完成
  • 我们现在来看整体思路:当用户下单之后,判断库存是否充足,只需要取Redis中根据key找对应的value是否大于0即可,如果不充足,则直接结束。如果充足,则在Redis中判断用户是否可以下单,如果set集合中没有该用户的下单数据,则可以下单,并将userId和优惠券存入到Redis中,并且返回0,整个过程需要保证是原子性的,所以我们要用Lua来操作,同时由于我们需要在Redis中查询优惠券信息,所以在我们新增秒杀优惠券的同时,需要将优惠券信息保存到Redis中
  • 完成以上逻辑判断时,我们只需要判断当前Redis中的返回值是否为0,如果是0,则表示可以下单,将信息保存到queue中去,然后返回,开一个线程来异步下单,其阿奴单可以通过返回订单的id来判断是否下单成功

Redis完成秒杀资格判断

  • 需求:
    1. 新增秒杀优惠券的同时,将优惠券信息保存到Redis中
    2. 基于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()); 
}

步骤二:编写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);
}

基于阻塞队列实现秒杀优化

  • 修改下单的操作,我们在下单时,是通过Lua表达式去原子执行判断逻辑,如果判断结果不为0,返回错误信息,如果判断结果为0,则将下单的逻辑保存到队列中去,然后异步执行
  • 需求
    • 如果秒杀成功,则将优惠券id和用户id封装后存入阻塞队列
    • 开启线程任务,不断从阻塞队列中获取信息,实现异步下单功能
  • 步骤一:创建阻塞队列
    阻塞队列有一个特点:当一个线程尝试从阻塞队列里获取元素的时候,如果没有元素,那么该线程就会被阻塞,直到队列中有元素,才会被唤醒,并去获取元素

阻塞队列的创建需要指定一个大小

private final BlockingQueue<VoucherOrder> orderTasks = new ArrayBlockingQueue<>(1024 * 1024);

那么把优惠券id和用户id封装后存入阻塞队列

@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) {
            // 2.1 不为0,表示没有购买资格
            return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
        }
        // 2.2 为0,有购买资格,把下单信息保存到阻塞队列
        VoucherOrder voucherOrder = new VoucherOrder();
        // 2.3 订单id
        long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        // 2.4 用户id
        voucherOrder.setUserId(UserHolder.getUser().getId());
        // 2.5 代金券id
        voucherOrder.setVoucherId(voucherId);
        // 2.6 放入阻塞队列
        orderTasks.add(voucherOrder);
        //3. 返回订单id
        return Result.ok(orderId);
    }

步骤二:实现异步下单功能

  1. 先创建一个线程池
    private static final ExecutorService SECKILL_ORDER_EXECUTOR = Executors.newSingleThreadExecutor();
  2. 创建线程任务,秒杀业务需要在类初始化之后,就立即执行,所以这里需要用到@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);
                }
            }
        }
    }
  3.  编写创建订单的业务逻辑
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);
}

 

Redis消息队列

  • 什么是消息队列?字面意思就是存放消息的队列,最简单的消息队列模型包括3个角色
    1. 消息队列:存储和管理消息,也被称为消息代理(Message Broker)
    2. 生产者:发送消息到消息队列
    3. 消费者:从消息队列获取消息并处理消息
  • 使用队列的好处在于解耦:举个例子,快递员(生产者)把快递放到驿站/快递柜里去(Message Queue)去,我们(消费者)从快递柜/驿站去拿快递,这就是一个异步,如果耦合,那么快递员必须亲自上楼把快递递到你手里,服务当然好,但是万一我不在家,快递员就得一直等我,浪费了快递员的时间。所以解耦还是非常有必要的
  • 那么在这种场景下我们的秒杀就变成了:在我们下单之后,利用Redis去进行校验下单的结果,然后在通过队列把消息发送出去,然后在启动一个线程去拿到这个消息,完成解耦,同时也加快我们的响应速度
  • 这里我们可以直接使用一些现成的(MQ)消息队列,如kafka,rabbitmq等,但是如果没有安装MQ,我们也可以使用Redis提供的MQ方案

基于List实现消息队列

  • 基于List结构模拟消息队列
  • 消息队列(Message Queue),字面意思就是存放消息的队列,而Redis的list数据结构是一个双向链表,很容易模拟出队列的效果
  • 队列的入口和出口不在同一边,所以我们可以利用:LPUSH结合RPOP或者RPUSH结合LPOP来实现消息队列。
  • 不过需要注意的是,当队列中没有消息时,RPOP和LPOP操作会返回NULL,而不像JVM阻塞队列那样会阻塞,并等待消息,所以我们这里应该使用BRPOP或者BLPOP来实现阻塞效果

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

    • 优点
      1. 利用Redis存储,不受限于JVM内存上限
      2. 基于Redis的持久化机制,数据安全性有保障
      3. 可以满足消息有序性
    • 缺点
      1. 无法避免消息丢失(经典服务器宕机)
      2. 只支持单消费者(一个消费者把消息拿走了,其他消费者就看不到这条消息了)

 

基于PubSub的消息队列

  • PubSub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费和可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息
  • SUBSCRIBE channel [channel]:订阅一个或多个频道
  • PUBLISH channel msg:向一个频道发送消息
  • PSUBSCRIBE pattern [pattern]:订阅与pattern格式匹配的所有频道

Subscribes the client to the given patterns.
Supported glob-style patterns:

h?flo subscribes to hello, hallo and hxllo
h*llo subscribes to hllo and heeeello
h[ae]llo subscribes to hello and hallo, but not hillo
Use \ to escape special characters if you want to match them verbatim.
  • 基于PubSub的消息队列有哪些优缺点

    • 优点:
      1. 采用发布订阅模型,支持多生产,多消费
    • 缺点:
      1. 不支持数据持久化
      2. 无法避免消息丢失(如果向频道发送了消息,却没有人订阅该频道,那发送的这条消息就丢失了)
      3. 消息堆积有上限,超出时数据丢失(消费者拿到数据的时候处理的太慢,而发送消息发的太快)

基于Stream的消息队列

  • Stream是Redis 5.0引入的一种新数据类型,可以时间一个功能非常完善的消息队列
  • 发送消息的命令
XADD key [NOMKSTREAM] [MAXLEN|MINID [=!~] threshold [LIMIT count]] *|ID field value [field value ...]
NOMKSTREAM
如果队列不存在,是否自动创建队列,默认是自动创建
[MAXLEN|MINID [=!~] threshold [LIMIT count]]
设置消息队列的最大消息数量,不设置则无上限
*|ID
消息的唯一id,*代表由Redis自动生成。格式是”时间戳-递增数字”,例如”114514114514-0”
field value [field value …]
发送到队列中的消息,称为Entry。格式就是多个key-value键值对

  举例

# 创建名为users的队列,并向其中发送一个消息,内容是{name=jack, age=21},并且使用Redis自动生成ID
XADD users * name jack age 21

读取消息的方式之一:XREAD

XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]
[COUNT count]
每次读取消息的最大数量
[BLOCK milliseconds]
当没有消息时,是否阻塞,阻塞时长
STREAMS key [key …]
要从哪个队列读取消息,key就是队列名
ID [ID …]
起始ID,只返回大于该ID的消息
0:表示从第一个消息开始
$:表示从最新的消息开始

  例如:使用XREAD读取第一个消息

云服务器:0>XREAD COUNT 1 STREAMS users 0
1) 1) "users"
   2) 1) 1) "1667119621804-0"
         2) 1) "name"
            2) "jack"
            3) "age"
            4) "21"

例如:XREAD阻塞方式,读取最新消息

XREAD COUNT 2 BLOCK 10000 STREAMS users $

在业务开发中,我们可以使用循环调用的XREAD阻塞方式来查询最新消息,从而实现持续监听队列的效果,伪代码如下

while (true){
    //尝试读取队列中的消息,最多阻塞2秒
    Object msg = redis.execute("XREAD COUNT 1 BLOCK 2000 STREAMS users $");
    //没读取到,跳过下面的逻辑
    if(msg == null){
        continue;
    }
    //处理消息
    handleMessage(msg);
}
注意:当我们指定其实ID为$时,代表只能读取到最新消息,如果当我们在处理一条消息的过程中,又有超过1条以上的消息到达队列,那么下次获取的时候,也只能获取到最新的一条,会出现漏读消息的问题
  • STREAM类型消息队列的XREAD命令特点
    1. 消息可回溯
    2. 一个消息可以被多个消费者读取
    3. 可以阻塞读取
    4. 有漏读消息的风险

基于Stream的消息队列—消费者组

  • 消费者组(Consumer Group):将多个消费者划分到一个组中,监听同一个队列,具备以下特点
    1. 消息分流
      • 队列中的消息会分留给组内的不同消费者,而不是重复消费者,从而加快消息处理的速度
    2. 消息标识
      • 消费者会维护一个标识,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标识之后读取消息,确保每一个消息都会被消费
    3. 消息确认
      • 消费者获取消息后,消息处于pending状态,并存入一个pending-list,当处理完成后,需要通过XACK来确认消息,标记消息为已处理,才会从pending-list中移除
  • 创建消费者组
XGROUP CREATE key groupName ID [MKSTREAM]
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 [keys ...] ID [ID ...]
group
消费者组名称
consumer
消费者名,如果消费者不存在,会自动创建一个消费者
count
本次查询的最大数量
BLOCK milliseconds
当前没有消息时的最大等待时间
NOACK
无需手动ACK,获取到消息后自动确认(一般不用,我们都是手动确认)
STREAMS key
指定队列名称
ID
获取消息的起始ID
>:从下一个未消费的消息开始(pending-list中)
其他:根据指定id从pending-list中获取已消费但未确认的消息,例如0,是从pending-list中的第一个消息开始

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

while(true){
    // 尝试监听队列,使用阻塞模式,最大等待时长为2000ms
    Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS s1 >")
    if(msg == null){
        // 没监听到消息,重试
        continue;
    }
    try{
        //处理消息,完成后要手动确认ACK,ACK代码在handleMessage中编写
        handleMessage(msg);
    } catch(Exception e){
        while(true){
            //0表示从pending-list中的第一个消息开始,如果前面都ACK了,那么这里就不会监听到消息
            Object msg = redis.call("XREADGROUP GROUP g1 c1 COUNT 1 STREAMS s1 0");
            if(msg == null){
                //null表示没有异常消息,所有消息均已确认,结束循环
                break;
            }
            try{
                //说明有异常消息,再次处理
                handleMessage(msg);
            } catch(Exception e){
                //再次出现异常,记录日志,继续循环
                log.error("..");
                continue;
            }
        }
    }
}
  • STREAM类型消息队列的XREADGROUP命令的特点
    1. 消息可回溯
    2. 可以多消费者争抢消息,加快消费速度
    3. 可以阻塞读取
    4. 没有消息漏读风险
    5. 有消息确认机制,保证消息至少被消费一次
ListPubSubStream
消息持久化 支持 不支持 支持
阻塞读取 支持 支持 支持
消息堆积处理 受限于内存空间,
可以利用多消费者加快处理
受限于消费者缓冲区 受限于队列长度,
可以利用消费者组提高消费速度,减少堆积
消息确认机制 不支持 不支持 支持
消息回溯 不支持 不支持 支持

 

Stream消息队列实现异步秒杀下单

  • 需求:
    1. 创建一个Stream类型的消息队列,名为stream.orders
    2. 修改之前的秒杀下单Lua脚本,在认定有抢购资格后,直接向stream.orders中添加消息,内容包含voucherId、userId、orderId
    3. 项目启动时,开启一个线程任务,尝试获取stream.orders中的消息,完成下单
  • 步骤一:创建一个Stream类型的消息队列,名为stream.orders
XGROUP CREATE stream.orders g1 0 MKSTREAM
  • 步骤二:修改Lua脚本,新增orderId参数,并将订单信息加入到消息队列中
-- 订单id
local voucherId = ARGV[1]
-- 用户id
local userId = ARGV[2]
-- 新增orderId,但是变量名用id就好,因为VoucherOrder实体类中的orderId就是用id表示的
local id = ARGV[3]
-- 优惠券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)
-- 将下单数据保存到消息队列中
redis.call("sadd", 'stream.orders', '*', 'userId', userId, 'voucherId', voucherId, 'id', id)
return 0

 

posted on 2023-07-08 15:22  夏雪冬蝉  阅读(587)  评论(0编辑  收藏  举报