Redis实战篇笔记(六)

Redis实战篇笔记——消息队列

前言

本系列文章是针对于黑马的Redis教学视频中的实战篇,本篇文章是实战篇的第五部分——消息队列,本篇内容与第五期的内容结合较为紧密,没有看过第五期的朋友,可以先看下第五期的内容,再来看本期内容



消息队列

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

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

Redis 提供了三种不同的方式来实现消息队列:

  • list 结构:基于 List 结构模拟消息队列
  • PubSub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

基于 List 结构模拟消息队列

Redis 的 list 数据结构是一个双向链表,很容易模拟出队列效果
我们可以利用:LPUSH 结合 RPOP,或者 RPUSH 结合 LPOP来实现。
但是,当队列中没有消息是 RPOP 或 LPOP操作会返回 null,并不像 JVM 的阻塞队列那样会阻塞并等待消息。因此这里应该使用 BRPOP 或 BLPOP 来实现阻塞效果

当队列中没有元素,他会一直等待,持续传入的时间,一旦队列中有元素,BRPOP就会弹出一个元素。
image.png

基于 List 的消息队列的优缺点
优点:

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

缺点:

  • 无法避免消息丢失(在 Redis 当中数据是有安全性的,但是当消息取出来后,没处理,系统挂了 ,数据就丢失 了。因为消息取出来后,POP就把消息从Redis中删除了)
  • 只支持单消费者

基于PubSub实现消息队列

这个PubSub感觉有点鸡肋,就简单介绍一下吧

PubSub (发布订阅) 是Redis 2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个 channel,生产者向对于 channel发送消息后,所有订阅者都能收到相关信息,主要的命令是下面三个

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

下面是 parrern的一些格式
image.png
下面我们来演示一下他的用法:

我们看上面的那一个是生产者,负责发送消息,下面两个是消费者负责订阅消息,左下的消费者订阅了 order.q1这个频道,右下的消费者订阅了 order. 也就是所有前缀为 order.的频道*

我们看生产者向 order.q1 频道发送了一个 hello消息,向order.q2 频道发送了一个 hello2 消息
我们可以看到左下的消费者他只接收到了 hello,右下的消费者,hello和hello2都接收到了
image.png
基于PubSub的消息队列的优缺点
优点:
采用发布订阅模型,支持多生成,多消费
缺点:

  • 不支持数据持久化(因为在 PubSub 当中,一旦有人发布了消息,他不存,他是直接发到接收这个频道的消费者手中)
  • 无法避免消息丢失(如果有一个消费者挂了,那个在这个消费者重新上线之前的所有消息他都是获取不到的)
  • 消息堆积有上限,超出时数据丢失

基于 Stream 的消息队列

Stream 是 Redis 5.0 引入的一种心数据结构,可以实现一个功能非常完整的消息队列
主要的命令是 XADD 和 XREAD
XADD:
image.pngimage.png
XREAD:
image.png
下面我们就具体来操作一下这两个命令。

还是上面的是一个生产者,下面两个是消费者,我们看在生产者中,我们向 s1 这个 stream 中添加一个消息
(Stream中消息是一个 entry就是一个键值对)k1 v1,我们发现在下面消费者中我们都读到了 k1 v1 并且还有这条消息的 id。
image.png
我们看左下的消费者又读了一次刚才的消息,发现还能读到,这说明 在 Stream中 消息一经读取是不会被删除的,我们再来看 左下的消费者吧最后的 0 换成了 $,这说明他要从最新消息开始读,但是我们看结构是一个 nil,这是正常的,因为上一条消息我们已经读过了,不是最新消息了。
image.png
我们再来看,因为上次读最新消息是个 nil,所以这回我们让左下的生产者阻塞的读消息,然后我们再让左下的生产者读,我们看到左下的生产者立马就读到了。那么利用这个模式可以改造一下 上回的Java代码
image.png

image.png
但是呢,这个 XREAD有一点点小 bug.
当我们指定起始 ID 为 $时,代表读取最新的消息,如果我们处理一条消息的过程中,又有超过1条以上的消息到达队列,则下次获取时,也只能获取到最新的一条,会出现漏读消息的问题

我们再向 s1 中添加几个消息,再用阻塞的方式读取会发现读不到,因为这些消息已经是发布过的,这时候我们再发一条消息他才能收到
image.png
image.png

Stream类型消息队列的优缺点
优点

  • 消息可回溯
  • 一条消息可以被多个消费者读取
  • 可以阻塞读取

缺点:
有消息漏读的风险

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

消费者组 (Consumer Group):将多个消费者划分到一个组中,监听同一个队列

  1. 消息分流:队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度
  2. 消息标示:消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标识之后读取消息。确保每一个消息都会被消费
  3. 消息确认:消费者获取消息后,消息处于 pending状态,并存入一个 pending-list 当处理完成后需要通过XACK来确认消息,标记消息已处理,才会从 pending-list移除。

**下面我们来看看常见的消费者命令
XGROUP CREATE key groupName ID [MKSTREAM]

  • key:队列名称
  • groupName:消费者名称
  • ID:起始ID标识,$表示 从队列中最后一个消息开始接受,0 表示从队列中第一个消息开始接受
  • MKSTREAM:队列不存在时自动创建队列

image.png
**下面我们就来创建一个消费者组
创建一个消费者组,从 s1 这个 stream中读,读到 g1 这个消费者组中,并且从 s1的第一条消息开始读
image.png

看完了消费者组的命令后,我们再来看看如何从消费者组中读消息
XREAD GROUP group consumer [COUNT count] [BLOCK millisecounds] [NOACK] STREAMS key [key …] ID [ID …]

  • group:消费组名称
  • consumer:消费者名称,如果消费者不存在,会自动创建一个消费者
  • count:本次查询的最大数量
  • BLOCK millisecounds:当没有消息时最长等待时间
  • NOACK:无需手动ACK,获取到消息后自动确认
  • STREAMS key:指定队列名称
  • ID:获取消息的起始ID:
    • >:从下一个未消费的消息开始
    • 其他:根据指定 id 从 pending-list 中获取已消费但未确认的消息,例如是 0 ,是从pending-list中的第一个消息开始

**下面我们就具体使用一下这几个命令

这个还是接者上一次 Stream的,里面还有几条消息。我们先让左下的消费者拿两条消息,他拿到了 k1 v1 和 k2 v2,然后我们再让 左下的消费者拿两条消息,我们是用 > 号获取,会从Stream中的标识开始取,于是右下的消费者接着拿到了 k3 v3 和 k4 v4。但是我们看,这几条消息我们都只是获取了,但是没确认,那就是说这几天消息还在我们的pending-list中。
image.png
接下来我们就去确认一下这几条消息,把他们的 ID 拿过来就好了,我们看返回值是5,说明他处理了 5 条数据,那这时候 pending-list 应该只剩下 k6 v6 这条数据了。那pending-list的数据如何查看呢,我们接下来就去看看
image.png
XPENDING 这条命令就是用来查看 pending-list里面的数据, - 就是最小ID,+ 就是 最大ID,那 - + 就是从pending-list 里面的最小ID,到最大ID,取 10 条。当然我们现在为确认的就一条数据,然后我们再从pending-list里面读数据,就是把 XREADGROUP 命令的最后的 > 改成 0 就会从 pending-list中读取数据。这之后我们再从 pending-list中取数据,发现没了。那基于Stream 的消费者组 我们来改进一下我们的异步秒杀
image.png

前面还是一样,先获取,如果为null,说明没有消息,要处理。如果不为 null,就是获取到消息了,然后取处理消息,处理完后要 确认。如果处理消息的时候抛异常了,我们就要捕获这个异常,再从pending-list中获取,
如果这时候还有异常,那就循环,直到这个消息被确认为止。
image.png
Stream的消费者组的优点:

  1. 消息可回溯 (当消息没确认的时候,是可以回溯的,但是消息一旦确认,我们就认为这个消息已经完成了,就没了)
  2. 可以多消费者争抢消息,加快消费速度
  3. 可以阻塞读取
  4. 没有消息漏读的风险
  5. 有消息确认机制,保证消息至少被消费一次

### 基于 Stream 消息队列实现异步秒杀 **我们看一下需求:**
  1. 创建一个 Stream 类型的消息队列,名为 stream.orders
  2. 修改之前的秒杀 Lua 脚本,在认定有抢购资格后,直接向 stream.orders中添加消息
  3. 项目启动时,开启一个线程任务,尝试 stream.orders 中的消息,完成下单

我们先通过命令来创建一个 stream 和 一个消费者组,stream的名字为 stream.orders 消费者组的名字为 g1
image.png

我们再来修改一下 Lua脚本,我们添加了一个 orderId,还有把这个订单的信息发给消息队列。

---
--- Generated by EmmyLua(https://github.com/EmmyLua)
--- Created by 86156.
--- DateTime: 2022/12/23 16:48
---
-- 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:' .. userId

-- 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)
-- 3.6 发送消息到队列中 ,XADD stream.orders * k1 v1
redis.call("xadd",'stream.orders','*','userId',userId,'voucherId',voucherId,'Id',orderId)
return 0

修改我们的 查询,判断的 Java代码

  //添加了一个向 lua 脚本中添加 orderId,然后把向阻塞队列中添加的信息删除
   @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)
        );
        //2. 判断结果是否为 0
        if(result !=0){
            //2.1 不为0,代表没有购买资格
            return Result.fail(result==1 ? "库存不足" : "不能重复下单");
        }
        proxy = (IVoucherOrderService) AopContext.currentProxy();

        return Result.ok(orderId);
    }

修改下单的线程

private class VoucherOrderHandler implements Runnable{
        String queueName="stream.orders";
        @Override
        public void run() {
            while (true){
                try {
                    // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 cl COUNT 1 BLOCK 2000 STREAMD stream.orders >
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                            StreamOffset.create(queueName, ReadOffset.lastConsumed())
                    );
                    // 2.判断消息获取是否成功
                    if(list==null||list.isEmpty()){
                        // 2.1 如果获取失败,说明没有信息,继续下一次循环
                        continue;
                    }
                    // 解析消息
                    MapRecord<String, Object, Object> entries = list.get(0);
                    Map<Object, Object> value = entries.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    ;
                    // 3 如果获取成功,可以下单
                    handleVoucherOrder(voucherOrder);
                    // 4 确认消息
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",entries.getId());
                } catch (Exception e) {
                   handlePendingList();
                }
            }
        }

        private void handlePendingList() {
            while (true){
                try {
                    // 1.获取消息队列中的订单信息 XREADGROUP GROUP g1 cl COUNT 1  STREAMD stream.orders 0
                    List<MapRecord<String, Object, Object>> list = stringRedisTemplate.opsForStream().read(
                            Consumer.from("g1", "c1"),
                            StreamReadOptions.empty().count(1),
                            StreamOffset.create(queueName, ReadOffset.from("0"))
                    );
                    // 2.判断消息获取是否成功
                    if(list==null||list.isEmpty()){
                        // 2.1 如果获取失败,说明pending-list没有异常信息,结束循环
                        break;
                    }
                    // 解析消息
                    MapRecord<String, Object, Object> entries = list.get(0);
                    Map<Object, Object> value = entries.getValue();
                    VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(value, new VoucherOrder(), true);
                    // 3 如果获取成功,可以下单
                    handleVoucherOrder(voucherOrder);
                    // 4 确认消息
                    stringRedisTemplate.opsForStream().acknowledge(queueName,"g1",entries.getId());
                    //如果 还没确认,就让他休眠一会,然后再循环就好了
                } catch (Exception e) {
                    log.error("处理pending-list异常",e);
                    try {
                        Thread.sleep(20);
                    } catch (InterruptedException ex) {
                        throw new RuntimeException(ex);
                    }
                }
            }
        }


    }

**这就是下单后的 redis,但是那个下单id 不关下单是否成功都会有,因为他把下单 id 给弄到了执行脚本前面,这是业务问题,但我们是来学Redis的,就先不注意这里了 **
image.png
到此我们今天就学完了 如何用 Redis 做消息队列,已经用这个消息队列去改造我们的秒杀业务。😋

总结

到此,我们就结束了Redis在实际业务中比较重要的几个用法,接下来几期内容都是围绕着前几期内容投入到黑马点评这个项目去运用。

最后,我是Mayphyr,从一点点到亿点点,我们下次再见

  • 7
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值