Redis 篇-深入了解基于 Redis 实现消息队列(比较基于 List 实现消息队列、基于 PubSub 发布订阅模型之间的区别)

🔥博客主页: 【小扳_-CSDN博客】**
❤感谢大家点赞👍收藏⭐评论✍**

文章目录

[1.0 消息队列的认识](#1.0 消息队列的认识)

[2.0 基于 List 实现消息队列](#2.0 基于 List 实现消息队列)

[2.1 基于 List 实现消息队列的优缺点](#2.1 基于 List 实现消息队列的优缺点)

[3.0 基于 PubSub 实现消息队列](#3.0 基于 PubSub 实现消息队列)

[3.1 基于 PubSub 的消息队列优缺点](#3.1 基于 PubSub 的消息队列优缺点)

[4.0 基于 Stream 实现消息队列](#4.0 基于 Stream 实现消息队列)

[4.1 Stream 的单消费模式](#4.1 Stream 的单消费模式)

[4.2 Stream 的消费组模式](#4.2 Stream 的消费组模式)


1.0 消息队列的认识

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

1)消息队列:存储和管理消息,也被称为消息代理(Message Broker)

2)生产者:发送消息到消息队列。

3)消费者:从消息队列获取消息并处理消息。

2.0 基于 List 实现消息队列

Redis 的 list 数据结构是一个双向链表,很容易模拟出队列效果。

实现思路:

队列时入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP 或者 RPUSH 结合 LPOP 来实现。不过需要注意的是,当队列中没有消息时 RPOP 或 LPOP 操作会直接返回 null ,并不像 JVM 的阻塞队列那样会阻塞并等待消息,因此这里应该使用 BRPOP 或者 BLPOP 来实现阻塞效果。

代码演示:

当数据要进入队列时,那么可以使用 LPUSH KEY VALUE 命令,KEY 为队列名称,VALUE 为数据值,将数据写入 Redis 中。当要获取数据的时,使用 BRPOP KEY TIMEOUT命令,KEY 为队列名称,TIMEOUT 为最大阻塞时间,在最大阻塞时间内,仍旧没有获取数据,则返回 null 。该命令主要做了两步,将数据移除队列中,并将该数据返回。

2.1 基于 List 实现消息队列的优缺点

优点:

1)利用 Redis 存储,不受限于 JVM 内存上限。

2)基于 Redis 的持久化机制,数据安全性有保证。

3)可以满足消息有序性。

缺点:

1)无法避免消息丢失。

当在 BRPOP 获取数据的时候,出现异常,返回数据失败,从而导致数据丢失。因为数据已经从队列中移除出来了,所以队列中已经不存在之前的数据了。

2)只支持单消费者。

当一个消费者来消费之后,其他再来的消费者就不能再获取到第一个消费者的数据,所以说数据只能给一个消费者。

3.0 基于 PubSub 实现消息队列

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

常用的命令:

1)SUBSCRIBE channel [channel]:订阅一个或者多个频道。

2)PUBLISH channel msg:向一个频道发送消息。

3)PSUBSCRIBE pattern [pattern]:订阅与 pattern 格式匹配的所有频道。* 代表通配符,订阅所有频道。

这就实现了支持多个消费者获取到相同的消息。当消息被发布了,那么已经订阅该频道的消费者就可以及时获取到消息了。

代码演示:

先订阅频道:

发送消息:

当生产者发送完消息,消费者就会收到通知,从通道中获取到消息。

3.1 基于 PubSub 的消息队列优缺点

优点:

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

解决了基于 List 实现的消息队列的缺点,单消费。

缺点:

1)不支持数据持久化。

将消息发布出去之后,不会进行数据保存。不管有无消费者订阅,都会将消息直接发布出去。

2)无法避免消息丢失。

因为不支持持久化,当消息丢失之后,无法再找到原本的数据。

3)消息堆积有上限,超出时数据丢失。

在消费者中,接收到的数据会暂时存放起来,一旦超过存放的大小,就会导致数据丢失。

4.0 基于 Stream 实现消息队列

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

发送消息的命令:

java 复制代码
XADD key ID field string [field string ...]

key 为队列名称,*|ID 为消息的唯一 id,* 代表由 Redis 自动生成。格式是"时间戳-递增数字",例如 "1644804662707-0"。field value 代表发送到队列中的消息,称为 Entry 。格式就是多个 key-value 键值对。

代码演示:

4.1 Stream 的单消费模式

单消费者获取数据的命名:

java 复制代码
XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] ID [ID ...]

COUNT count 为每次读取消息的最大数量;BLOCK milliseconds 代表当没有消息时,是否阻塞,阻塞时长;STREAMS key 代表要从哪个队列读取消息,key 就是队列名;ID 代表起始 id ,只返回大于该 ID 的消息,0 为从第一个消息开始,而 $ 为从最新的消息开始。

代码演示:

当 ID 使用 $ 时,不会从原本 s 中直接获取原本的数据,而是在 2 秒内有无最新的数据添加进来,如果有,则返回该数据;如果没有,则返回 null。

当 ID 使用 0 时,则从原本 s 中直接获取原本的数据。

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

需要注意的地方:

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

XREAD 命令特点:

1)消息可回溯。

2)一个消息可以被多个消费者读取。

3)可以阻塞读取。

4)有消息漏读的风险。

4.2 Stream 的消费组模式

将多个消费者划分到一个组中,监听同一个队列。

特点:

1)消息分流:

队列中的消息会分流给组内的不同消费者,而不是重复消费,从而加快消息处理的速度。

2)消息标示:

消费者组会维护一个标示,记录最后一个被处理的消息,哪怕消费者宕机重启,还会从标示之后读取消息。确保每一个消息都会被消费。

3)消息确认:

消费者获取消息后,消息处于 pending 状态,并存入一个 pending-list 。当处理完成后,需要通过 XACK 来确认消息,标记消息为已处理,才会从 pending-list 移除。

创建消费者组:

java 复制代码
XGROUP CREATE key groupname id|$ [MKSTREAM]

key 代表队列名称,groupName 代表消费者组名称,ID 起始 ID 标示,$ 代表队列中最后一个消息,0 则代表队列中第一个消息。MKSTREAMS 代表不存在时自动创建队列。

如果之前列表的数据要继续获取,则 ID 选为 0;如果之前的列表中的数据不需要了,则 ID 选为 $ 。

从消费者组读取消息:

java 复制代码
XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] ID [ID ...]

group 代表组名,consumer 代表组内消费者名称,count 代表每次读取的最大数量,milliseconds 代表当没有消息时最长的等待时间,NOACK 代表无需手动 ACK,获取消息后自动确认。key 代表指定队列名称,

ID 代表获取消息的起始 ID :

当 ID 为 ">" :从下一个未消费的消息开始。

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

确认消息:

java 复制代码
XACK key groupName ID

key 为队列名,groupName 为组名,ID 为消息唯一 id 。

查看未确认的消息:

java 复制代码
XPENDING key group [start end count] [consumer]

key 为队列名,group 为组名,start 起始地址,count 个数,consumer 组内消费者名称。

消费者监听消息思路:

Java 代码实现从消息队列中获取消息:

java 复制代码
import cn.hutool.core.bean.BeanUtil;
import com.project.volunteermanagementproject.pojo.StreamObject;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.stream.*;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.List;
import java.util.Map;
@Component
public class StreamUtil {
    @Autowired
    StringRedisTemplate stringRedisTemplate;

    //实现从消息队列中获取消息
    public void getStream(){
        while (true){
            try {
                List<MapRecord<String, Object, Object>> read = stringRedisTemplate.opsForStream().read(
                        Consumer.from("g1", "c1"),
                        StreamReadOptions.empty().count(1).block(Duration.ofSeconds(2)),
                        StreamOffset.create("s1", ReadOffset.lastConsumed())
                );

                if (read == null || read.isEmpty()){
                    //如果获取失败,说明没有消息,继续下一次循环
                    continue;
                }
                //解析消息中的消息
                MapRecord<String, Object, Object> entries = read.get(0);
                Map<Object, Object> value = entries.getValue();
                StreamObject streamObject = BeanUtil.fillBeanWithMap(value, new StreamObject(), true);
                //这就拿到了消息队列中的数据了,就可以去使用该对象了
                System.out.println(streamObject);
                //这就需要确认消息队列
                stringRedisTemplate.opsForStream().acknowledge("s1", "g1", entries.getId());
            } catch (Exception e) {
                //如果在获取消息过程中出现异常,则需要再次执行该消息任务
                while (true){
                    try {
                        List<MapRecord<String, Object, Object>> read = stringRedisTemplate.opsForStream().read(
                                Consumer.from("g1", "c1"),
                                StreamReadOptions.empty().count(1),
                                StreamOffset.create("s1", ReadOffset.from("0"))
                        );
                        if (read == null || read.isEmpty()){
                            break;
                        }
                        MapRecord<String, Object, Object> entries = read.get(0);
                        Map<Object, Object> value = entries.getValue();
                        StreamObject streamObject = BeanUtil.fillBeanWithMap(value, new StreamObject(), true);
                        //重新拿到未确认的数据
                        System.out.println(streamObject);
                        //再次进行消息确认
                        Long acknowledge = stringRedisTemplate.opsForStream().acknowledge("s1", "g1", entries.getId());
                    } catch (Exception ex) {
                        throw new RuntimeException(ex);
                    }
                    
                }
            }

        }
    }
}

XREADGROUP 命令特点:

1)消息可回溯

2)可以多消费者争抢消息,加快消费速度

3)可以阻塞读取

4)没有消息漏读的风险

5)有消息确认机制,保证消息至少被消费一次

相关推荐
高兴就好(石1 小时前
DB-GPT部署和试用
数据库·gpt
这孩子叫逆2 小时前
6. 什么是MySQL的事务?如何在Java中使用Connection接口管理事务?
数据库·mysql
罗政2 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
Karoku0662 小时前
【网站架构部署与优化】web服务与http协议
linux·运维·服务器·数据库·http·架构
码农郁郁久居人下2 小时前
Redis的配置与优化
数据库·redis·缓存
架构文摘JGWZ2 小时前
Java 23 的12 个新特性!!
java·开发语言·学习
拾光师3 小时前
spring获取当前request
java·后端·spring
aPurpleBerry3 小时前
neo4j安装启动教程+对应的jdk配置
java·neo4j
我是苏苏3 小时前
Web开发:ABP框架2——入门级别的增删改查Demo
java·开发语言
MuseLss3 小时前
Mycat搭建分库分表
数据库·mycat