一、功能介绍
redis stream是redis5.0以后出现的新的数据类型,它主要是一个可以附加的数据结构
可以在一定程度上完善之前redis的订阅发布功能存在的缺陷
1.1Redis订阅发布模型
1.1.1 缺陷一:订阅者收不到最近的消息
在Redis订阅者执行订阅操作时,如果发布者在这个时间段内发布了消息,那么订阅者是收不到这条消息的。这是因为Redis的订阅发布系统是基于消息的发送和接收的,若订阅者没有注册到Redis服务器前消息已被发送,那么就会使得订阅者无法获取到这条消息。
1.1.2 缺陷二:订阅信息无法持久化
Redis订阅发布模块的另一个缺陷是无法持久化订阅信息。假设当前Redis服务器上有许多发布者和订阅者,当Redis服务器被关闭和重启时,所有的订阅信息都将被清空。这意味着需要重新订阅频道。
1.1.3 缺陷三:消息的传递不是可靠的
Redis订阅发布模块的消息传递不是可靠的。如果出现网络故障或客户端崩溃的情况,那么订阅者就无法收到全部的消息。另外,许多应用程序需要保证发布者发布的所有消息都被所有订阅者完全接收,而这需要一种更可靠、更持久的模型。
为了解决这个问题,可以使用订阅发布的新模型,Redis Stream。使用Redis Stream可以让每个消息都被保存,这个模型还提供了流式传输,使得可以在一个频道中创建一个完整的消息流。
1.2 stream优点
- stream消息是完整和有序,在一个流中可以叠加很多条消息。
- stream流的信息也是会持久化到aof或者rdb文件中去。redis服务重启后会自动读取
- 这个数据结构,可以让用户获取一个时间段内的消息,因为它内部是按照时间戳来进行排序的
- 可以接收对应的streamkey的所有消息。也可以指定时间之后的一条或多条消息
- 我们可以通过参数来设置stream的长度,能存储序列化为map对象的数据。
Stream是Redis的数据类型中最复杂的,尽管数据类型本身非常简单,它实现了额外的非强制性的特性,它存储的是有序的,以时间戳来进行的排序的一组流式消息
二、设计思路和技术框架说明
注解:
类名 | 属性 | 功能 |
StreamListenerKey | @StreamListenerKey(streamKeys = {"mystream"}) | 监听对应streamKey流名称的消息 |
接口与方法:
(接口) | 方法 | 功能 |
StreamListener | onMessage | 监听对应的流的回调方法 |
StreamMessageService | sendStreamMsg(); | 发送消息 |
readRangeStreamMessage(); | 读取时间范围内的消息 | |
readFirstMsgInStream(); | 读取指定流中的第一条消息 | |
delStream(); | 删除指定ID的消息 | |
createGroup(); | 创建消费组 | |
readGroup(); | 使用消费者读取流中的未消费的消息 | |
xAck(); | 确认消息已经被消费 | |
..... | 其他方法可以参考代码中的介绍 |
三、 stream流消息服务的基本使用
3.1 消息监听
需要实时监听对应流的消息,首先实现StreamListener这个接口,并在实现类加上StreamListenerKey注解,注解的属性streamKey是所监听的对应的流的名称
示例:监听流名称为mystream中的消息,并在消费后调用方法将该消息删除。
@Slf4j
@Component
@StreamListenerKey(streamKeys = {"mystream"})
public class TestStreamListenerImpl implements StreamListener {
@Autowired
private StreamMessageService streamMessageService;
@Override
public void onMessage(String key, List<StreamEntry> messages) {
//获取消息进行消费
ArrayList<StreamEntryID> ids = new ArrayList<>();
for (StreamEntry message : messages) {
ids.add(message.getID());
}
//消费后删除对应的消息
Long aLong = streamMessageService.delStream(key, ids);
}
}
3.2 消息流方法的使用
3.2.1 发送消息且创建流
发送一条消息并且创建对应名称的流
/**
* description:发送指定长度的消息
*
* @param streamKey 指定消息流的key
* @param EntryID 指定发送消息传入的id,可为null,默认为redis自动配置ID 例如:new StreamEntryID(1691460419394L,0L);前面一个为时间戳,后面一个可不传或为0
* @param field 消息的字段key
* @param content 消息的内容
* @param maxLen 指定消息流的长度
* @author litong
* @date 2023/8/8
*/
StreamEntryID sendStreamMsg(String streamKey, StreamEntryID EntryID, String field, String content, int maxLen);
3.2.2 读取对应流名称的时间范围或者指定ID的消息
消息在流中都是按照时间戳来进行存储的,所以我们可以使用时间戳来读取时间范围内的所有消息,如果传递的时间相同,那么就代表读取这个时间ID对应的消息
/**
* description: 读取时间范围内的消息
*
* @param streamKey 读取的消息的key
* @param streamEntryIDList 读取的时间范围
* 例如:
* List<StreamEntryID> streamEntryIDList = new ArrayList<>();
* streamEntryIDList.add(new StreamEntryID(1691460419394L,0L));
* streamEntryIDList.add(new StreamEntryID(1691460498008L,0L));
* 如果传递两个相同的ID,代表查询这个指定ID的消息
* @author litong
* @date 2023/8/8
*/
List<StreamEntry> readRangeStreamMessage(String streamKey, List<StreamEntryID> streamEntryIDList);
3.2.3 删除指定ID的消息
如果只是简单的发送消息并且进行消费,在完成消费之后可以直接删除掉对应的消息
/**
* description: 删除对应key的消息
*
* @param streamKey 指定要删除的key
* @param streamEntryID 指定对应的时间戳的key
* @return Long
* @author litong
* @date 2023/8/8
*/
Long delStream(String streamKey, List<StreamEntryID> streamEntryID);`
3.3 stream消费组
stream有一个类似于kafka的消息队列的概念
3.3.1 消费组概念
消费者组就像一个伪消费者,从流中获取数据,实际上为多个消费者提供服务,提供某些保证:
- 每条消息都提供给不同的消费者,因此不可能将相同的消息传递给多个消费者。
- 消费者在消费者组中通过名称来识别,该名称是实施消费者的客户必须选择的区分大小写的字符串。这意味着即便断开连接过后,消费者组仍然保留了所有的状态,因为客户端会重新申请成为相同的消费者。 然而,这也意味着由客户端提供唯一的标识符。
- 每一个消费者组都有一个第一个ID永远不会被消费的概念,这样一来,当消费者请求新消息时,它能提供以前从未传递过的消息。
- 消费消息需要使用特定的命令进行显式确认,表示:这条消息已经被正确处理了,所以可以从消费者组中逐出。
- 消费者组跟踪所有当前所有待处理的消息,也就是,消息被传递到消费者组的一些消费者,但是还没有被确认为已处理。由于这个特性,当访问一个Stream的历史消息的时候,每个消费者将只能看到传递给它的消息。
在某种程度上,消费者组可以被想象为关于Stream的一些状态
3.3.2 底层原理:
当一个消费者组中的某个消费者消费了一个消息之后,Redis会将该消息的ID从消费者组的"pending"列表中删除,并将该消息从消息哈希表中删除。如果该消费者是该消费者组中的最后一个消费者,则该消费者组的"pending"列表将被删除。
当某个消费者组中的所有消费者都没有消费一个特定的消息时,该消息的ID将存储在该消费者组的"pending"列表中。"pending"列表是一个有序集合,其中每个元素都是一个消息的ID,按照消息的时间戳排序。有序集合中的分值为消息的时间戳,成员为消息的ID。
3.3.3 消费组的基本使用
3.3.3.1 创建消费组
在对应的流名称中创建对一个的消费组,ID传null
/**
* description: 创建消费组
*
* @param streamKey 流名曾
* @param groupName 消费组名称
* @param streamEntryID 消息ID,可为null
* @param makeStream 在没有对应的streamKey时,是否创建流
* @return java.lang.String
* @author litong
* @date 2023/8/15
*/
String createGroup(String streamKey, String groupName, StreamEntryID streamEntryID, boolean makeStream);
3.3.3.2 读取消费组中的消息
读取消费组中没有被消费过的第一条消息
/**
* description: 消费组读取消息,需要手动ack
*
* @param groupname 消费组名称
* @param consumer 消费者的名曾
* @param count 读取多少条,可不传
* @param streamKey 流名称
* @return java.util.List<java.util.Map.Entry < java.lang.String, java.util.List < redis.clients.jedis.StreamEntry>>>
* @author litong
* @date 2023/8/15
*/
List<Map.Entry<String, List<StreamEntry>>> readGroup(String groupname, String consumer, int count, String streamKey);
3.3.3.3 消息确认ack
对消费过的消息进行ack确认
/**
* description: ack消息确认
*
* @param streamKey 流名称
* @param groupName 消费组名称
* @param streamEntryIDs 消息ID
* @return java.lang.Long
* @author litong
* @date 2023/8/15
*/
Long xAck(String streamKey, String groupName, List<StreamEntryID> streamEntryIDs);
3.3.4 消费组示例:
首先我们创建了一个mystream的流,创建了名为test1的消费组
String serviceGroup = streamMessageService.createGroup(streamMsgReq.getStreamKey(), streamMsgReq.getGroupName(), streamMsgReq.getEntryID(), streamMsgReq.getMakeStream());
然后发送消息到流中:
StreamEntryID streamEntryID = streamMessageService.sendStreamMsg(streamMsgReq.getStreamKey(), streamMsgReq.getEntryID(),
streamMsgReq.getField(), streamMsgReq.getContent(), streamMsgReq.getMaxLen());
然后使用消费组进行读取,首先使用testconsumer1进行读取消息
List<Map.Entry<String, List<StreamEntry>>> entries = streamMessageService.readGroup(streamMsgReq.getGroupName(), streamMsgReq.getConsumer(), streamMsgReq.getCount(), streamMsgReq.getStreamKey());
然后再使用consumer2进行消费,发现第一条消息已经被消费了,这就是分组消费的一个简单概念,同一个消费组中的所有消息都不会被重复消费
手动ack消息
在消息完成消费之后,需要对消息进行确认,保证每一条被消费的消息都被ack掉
Long ack = streamMessageService.xAck(streamMsgReq.getStreamKey(), streamMsgReq.getGroupName(), streamMsgReq.getStreamEntryIDS());