Redis进阶——redis消息队列

目录

redis消息队列

认识消息队列

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

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

基于List实现消息队列

如何基于List结构模拟消息队列

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

  2. 队列的入口和出口不在同一边,所以我们可以利用:LPUSH结合RPOP或者RPUSH结合LPOP来实现消息队列。

  3. 不过需要注意的是,当队列中没有消息时,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. 不支持数据持久化
  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阻塞方式,读取最新消息

云服务器: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中的第一个消息开始

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

java 复制代码
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命令的特点

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

redis三种消息队列的对比

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

步骤三:修改秒杀逻辑

由于将下单数据加入到消息队列的功能,我们在Lua脚本中实现了,所以这里就不需要将下单数据加入到JVM的阻塞队列中去了,同时Lua脚本中我们新增了一个参数

之前的代码:

java 复制代码
    @Override
    public Result seckillVoucher(Long voucherId) {
+       long orderId = redisIdWorker.nextId("order");
        Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
                Collections.emptyList(), voucherId.toString(),
+               UserHolder.getUser().getId().toString(), String.valueOf(orderId));
        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);
    }

修改后的代码:

java 复制代码
@Override
public Result seckillVoucher(Long voucherId) {
    long orderId = redisIdWorker.nextId("order");
    Long result = stringRedisTemplate.execute(SECKILL_SCRIPT,
            Collections.emptyList(), voucherId.toString(),
            UserHolder.getUser().getId().toString(), String.valueOf(orderId));
    if (result.intValue() != 0) {
        return Result.fail(result.intValue() == 1 ? "库存不足" : "不能重复下单");
    }
    //主线程获取代理对象
    proxy = (IVoucherOrderService) AopContext.currentProxy();
    return Result.ok(orderId);
}

根据伪代码来修改我们的VoucherOrderHandler

java 复制代码
String queueName = "stream.orders";

private class VoucherOrderHandler implements Runnable {

    @Override
    public void run() {
        while (true) {
            try {
                //1. 获取队列中的订单信息 XREADGROUP GROUP g1 c1 COUNT 1 BLOCK 2000 STREAMS stream.orders >
                List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read(Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        //ReadOffset.lastConsumed()底层就是 '>'
                        StreamOffset.create(queueName, ReadOffset.lastConsumed()));
                //2. 判断消息是否获取成功
                if (records == null || records.isEmpty()) {
                    continue;
                }
                //3. 消息获取成功之后,我们需要将其转为对象
                MapRecord<String, Object, Object> record = records.get(0);
                Map<Object, Object> values = record.getValue();
                VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
                //4. 获取成功,执行下单逻辑,将数据保存到数据库中
                handleVoucherOrder(voucherOrder);
                //5. 手动ACK,SACK stream.orders g1 id
                stringRedisTemplate.opsForStream().acknowledge(queueName, "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 stream.orders 0
            List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().read(
                    Consumer.from("g1", "c1"),
                    StreamReadOptions.empty().count(1),
                    StreamOffset.create(queueName, ReadOffset.from("0")));
            //2. 判断pending-list中是否有未处理消息
            if (records == null || records.isEmpty()) {
                //如果没有就说明没有异常消息,直接结束循环
                break;
            }
            //3. 消息获取成功之后,我们需要将其转为对象
            MapRecord<String, Object, Object> record = records.get(0);
            Map<Object, Object> values = record.getValue();
            VoucherOrder voucherOrder = BeanUtil.fillBeanWithMap(values, new VoucherOrder(), true);
            //4. 获取成功,执行下单逻辑,将数据保存到数据库中
            handleVoucherOrder(voucherOrder);
            //5. 手动ACK,SACK stream.orders g1 id
            stringRedisTemplate.opsForStream().acknowledge(queueName, "g1", record.getId());
        } catch (Exception e) {
            log.info("处理pending-list异常");
            //如果怕异常多次出现,可以在这里休眠一会儿
            try {
                Thread.sleep(50);
            } catch (InterruptedException ex) {
                throw new RuntimeException(ex);
            }
        }
    }
}
相关推荐
言之。28 分钟前
redis延迟队列
redis
做梦敲代码38 分钟前
达梦数据库-读写分离集群部署
数据库·达梦数据库
小蜗牛慢慢爬行1 小时前
如何在 Spring Boot 微服务中设置和管理多个数据库
java·数据库·spring boot·后端·微服务·架构·hibernate
hanbarger1 小时前
nosql,Redis,minio,elasticsearch
数据库·redis·nosql
微服务 spring cloud2 小时前
配置PostgreSQL用于集成测试的步骤
数据库·postgresql·集成测试
先睡2 小时前
MySQL的架构设计和设计模式
数据库·mysql·设计模式
弗罗里达老大爷2 小时前
Redis
数据库·redis·缓存
别这么骄傲2 小时前
lookup join 使用缓存参数和不使用缓存参数的执行前后对比
缓存
仰望大佬0072 小时前
Avalonia实例实战五:Carousel自动轮播图
数据库·microsoft·c#