水煮Redisson(二四)-Stream能力全解

前言

在Redis5.0之前,如果用redis来模拟MQ功能,要么使用list数据结构,要么使用PUB/SUB发布订阅模式。前者可以使用bpop来阻塞等待消息,后者在消息发布之后,主动推送消息到订阅的客户端,看似比较完美,但其实都有不小的隐患。先说说PUB/SUB,一旦redis服务或者消费者重启,那么会丢失在重启的这段时间之内所有的消息,因为服务端没有持久化这种模式的缓存,只是做了内存缓冲区 + 消息转发;再来说一下list,这也是stream出现之前,可靠度比较高的功能,首先rdb或者aof可以在一定范围内降低消息丢失率;其次数据在redis里可以长期保存,如果消费者重启,可以从之前消费的进度继续读取数据;最后list可以使用bpop指令阻塞等待消息,不用一直轮询,降低网络交互产生的IO损耗。

Redis stream的前身disque,是redis作者开发的一个redis module,因为一些原因,在Redis5.0版本被内置为一种全新的数据结构,提供了消息持久化和主备复制功能,可以记住任何一个消费者的消费位置,还能保证数据不丢失。

命令说明

  • XACK:结束Pending,将消息标记为"已处理"
  • XADD:生成消息
  • XCLAIM:转移消息的归属权
  • XDEL:删除消息
  • XGROUP:消费组管理
  • XINFO:查看流和消费者组的相关信息
  • XLEN:消息队列长度
  • XPENDING:Pending列表,显示待处理消息的相关信息
  • XRANGE:获取消息队列中消息
  • XREAD:消费消息
  • XREADGROUP:分组消费消息
  • XREVRANGE:逆序获取消息队列中消息
  • XTRIM:消息队列容量

消息堆积

Redis是基于内存的,不可能无限制保存历史数据,对stream数据结构来说,也是做了一些默认的设置,保存固定长度【容量】的数据。

  • stream-node-max-bytes 4096
  • stream-node-max-entries 100

参数讲解

stream-node-max-bytes

Redis 流式数据结构使用 Radix 树来存储内容。参数指定可用于在单个树节点中存储内容的最大字节数。达到此限制后,新内容将存储在新的树节点中。 整数 0 及以上。(默认值 = 4096)0表示无限大小的树节点。
stream-node-max-entries

Redis 流式数据结构使用 Radix 树来存储内容。参数指定可以存储在单个节点中的内容数。当达到此限制时,新内容将存储在新的树节点中。 整数 0 及以上。(默认值 = 100)0 表示不受限制的树节点。

创建stream

如果要创建group,需要先实例化stream,因为目前还不支持创建无数据的stream【但是无数据的stream实例可以存在】。

ini 复制代码
        RStream<Object, Object> stream = redissonClient.getStream(STREAM_NAME);
        // 在redis里创建一个指定名称的stream实例,否则group创建不成功
        StreamMessageId messageId = stream.add("test", 1);
        // 请注意:目前还不能为不存在的Stream创建消费者组,但有可能在不久的将来我们会给XGROUP命令增加一个选项,以便在这种场景下可以创建一个空的Stream。
        stream.createGroup(DEAD_LINE_GROUP_NAME);
        stream.createGroup(GROUP_NAME);
        stream.remove(messageId);

生产数据

在生产数据的时候,可以使用MAXLEN指令来实时精确修改stream数据结构的容量,在插入新数据时,删除旧数据,使stream保持一个恒定的容量。但是这个操作比较昂贵,需要阻塞删除多余的旧数据,基于此我们可以指定一个模糊边界【参数"~"】,让redis服务自动判断长度范围,这样可以使指令更高效的执行。

每条stream数据,又两部分组成,消息id和消息体,其中消息id是redis服务自动生成,消息体是一个键值对,保存实际业务数据。

ini 复制代码
        RStream<Object, Object> stream = redissonClient.getStream(STREAM_NAME);
        for (int i = 0; i < 100; i++) {
            // trimLen参数是限制stream长度,以免内存撑爆,发生OOM,默认为0,表示不限制长度
            // trimStrict设置为false,表示不需要太精确,stream的长度可以在设定范围内上下浮动
            stream.add("test" + i, i);
//            stream.add("test" + i, i, 3, true);
        }
        // 设置stream容量的两种方式:1. 在生产消息之后,直接调用trim指令;2. 生成消息时,指定容量;
        // 一定要在生产动作之后设置,否则不会生效。这其实是一个指定动作,用于剪除容量之外的老数据
//        stream.trim(10);
        stream.trimNonStrict(10);

从上面的代码可以看出,指定容量的方式有两种

  • 在xadd指令里,添加trimLen参数,trimStrict参数表示是否需要精确,默认为false。
  • 添加数据之后,使用xtrim独立指令来设定容量,与上面的操作类似,可以设置是否需要精确。

消息id - StreamMessageId

生产数据以后,Redis服务会返回一个StreamMessageId,此id由两位long类型的数据组成,其中高位是毫秒级时间戳,低位是递增变量,用来标记唯一一条消息。

消费数据

消费也分为几种形式,下面一一介绍。

直接消费

直接消费时,不需要指定group参数。如果stream中没有数据,则可以设置超时等待,在读取到指定条数之后,返回给客户端。

ini 复制代码
        RStream<Object, Object> stream = redissonClient.getStream(STREAM_NAME);
        Map<StreamMessageId, Map<Object, Object>> messageIdMap =
                stream.read(1000, 100, TimeUnit.SECONDS, StreamMessageId.NEWEST);
        System.out.println("消费消息:" + messageIdMap);

分组消费

分组消费与kafka比较类似,是一种更加成熟的消费方式,可以部署多个消费者并发读取,保证消费效率,以免滞后处理,影响业务正常流程。

ini 复制代码
        RStream<Object, Object> stream = redissonClient.getStream(STREAM_NAME);
        // 消费之前一定要创建group,否则会报错:org.redisson.redissonClient.RedisException: NOGROUP No such key 'TEST_STREAM' or consumer group 'myGroupName' in XREADGROUP with GROUP option.
        Map<StreamMessageId, Map<Object, Object>> result =
                stream.readGroup(GROUP_NAME, CONSUMER_NAME, 50, 10, TimeUnit.SECONDS, StreamMessageId.NEVER_DELIVERED);
        if (CollectionUtils.isEmpty(result)) {
            log.info("没有读取到数据。");
            return;
        }
        result.forEach((k, v) -> {
            log.info("key:{},消费消息:{}", k, v);
            // 消费应答
            long ack = stream.ack(GROUP_NAME, k);
            // 如果正常响应
            if (ack == 1){
                // 删除消息
                stream.remove(k);
            }
        });

这里需要注意,如果group没有提前创建,会返回报错信息:
org.redisson.redissonClient.RedisException: NOGROUP No such key 'TEST_STREAM' or consumer group 'myGroupName' in XREADGROUP with GROUP option.

与之前的消费模式类似,也是可以设定等待时间,唯一不同的是,分组消费需要应答【发送ACK指令】,来标记消息已经被处理,被处理的消息可以删除,或者自动清除【XSTRIM容量设定】。更加保险的方式当然是标记删除,以免未被消费的数据被自动清理。

重复应答

如果对同一个消息应答多次,则第一次返回正常【1】,后续所有的应答都返回【0】。

另外,可以应答已经删除的数据。

消息转移

将同一个group中,某个消费者未应答的数据转移给另外的消费者,比如下面的例子,将【consumer_claim】中闲置超过10秒的未应答数据,转移给消费者【consumer_1】。

在现实场景,可以根据此特性来实现【死信队列】,多次为成功应答的消息转移到同一个消费者下面,统一记录和处理。

ini 复制代码
        RStream<Object, Object> stream = redissonClient.getStream(STREAM_NAME);
        PendingResult pendingInfo = stream.getPendingInfo(GROUP_NAME);
        System.out.println(GROUP_NAME + " 组积压:" + JacksonUtil.toJSONString(pendingInfo));
        StreamMessageId lowestId = pendingInfo.getLowestId();
        StreamMessageId highestId = pendingInfo.getHighestId();
        // 闲置超过10秒的,必须指定需要认领的consumer和messageId
        // 认领一条消息的副作用是会重置它的闲置时间,避免被重复认领
        List<StreamMessageId> idList =
                stream.fastClaim(DEAD_LINE_GROUP_NAME, DEAD_LINE_CONSUMER_NAME, 10, TimeUnit.SECONDS, lowestId, highestId);
        System.out.println("FAST 闲置超过10秒的:" + JacksonUtil.toJSONString(idList));
        /*Map<StreamMessageId, Map<Object, Object>> claim =
                stream.claim(GROUP_NAME, DEAD_LINE_CONSUMER_NAME, 1000, TimeUnit.SECONDS, lowestId, highestId);
        System.out.println("闲置超过10秒的:" + JacksonUtil.toJSONString(claim));*/
        pendingInfo = stream.getPendingInfo(GROUP_NAME);
        System.out.println(GROUP_NAME + " 组积压:" + JacksonUtil.toJSONString(pendingInfo));

执行日志
testGroup1 组积压:{"total":2,"lowestId":{"id0":1628061788394,"id1":0},"highestId":{"id0":1628061788396,"id1":0},"consumerNames":{"consumer_claim":2}}
FAST 闲置超过10秒的:[{"id0":1628061788394,"id1":0},{"id0":1628061788396,"id1":0}]
testGroup1 组积压:{"total":2,"lowestId":{"id0":1628061788394,"id1":0},"highestId":{"id0":1628061788396,"id1":0},"consumerNames":{"consumer_1":2}}

Info,积压数据,范围查询

ini 复制代码
RStream<Object, Object> stream = redissonClient.getStream(STREAM_NAME);
        StreamInfo<Object, Object> info = stream.getInfo();
        System.out.println("数据-info:" + JacksonUtil.toJSONString(info));
        // 上面的结果我们可以看到,我们之前读取的消息,都被记录在Pending列表中,说明全部读到的消息都没有处理,仅仅是读取了。那如何表示消费者处理完毕了消息呢?使用命令 XACK 完成告知消息处理完成
        PendingResult pendingInfo = stream.getPendingInfo(GROUP_NAME);
        System.out.println(GROUP_NAME + " 组积压:" + JacksonUtil.toJSONString(pendingInfo));
        // 查询数据
        Map<StreamMessageId, Map<Object, Object>> range =
                stream.range(StreamMessageId.MIN, StreamMessageId.MAX);
        System.out.println("RANGE:" + JacksonUtil.toJSONString(range));

以下是执行输出日志
数据-info:{"length":20,"radixTreeKeys":1,"radixTreeNodes":2,"groups":2,"lastGeneratedId":{"id0":1628145680799,"id1":0},"firstEntry":{"id":{"id0":1628145680773,"id1":0},"data":{"test80":80}},"lastEntry":{"id":{"id0":1628145680799,"id1":0},"data":{"test99":99}}}
testGroup1 组积压:{"total":2,"lowestId":{"id0":1628133984452,"id1":0},"highestId":{"id0":1628133984453,"id1":0},"consumerNames":{"consumer_1":2}}
RANGE:{"1628145680773-0":{"test80":80},"1628145680774-0":{"test81":81},"1628145680776-0":{"test82":82},"1628145680777-0":{"test83":83},"1628145680778-0":{"test84":84},"1628145680780-0":{"test85":85},"1628145680781-0":{"test86":86},"1628145680783-0":{"test87":87},"1628145680784-0":{"test88":88},"1628145680786-0":{"test89":89},"1628145680787-0":{"test90":90},"1628145680788-0":{"test91":91},"1628145680790-0":{"test92":92},"1628145680791-0":{"test93":93},"1628145680792-0":{"test94":94},"1628145680793-0":{"test95":95},"1628145680795-0":{"test96":96},"1628145680796-0":{"test97":97},"1628145680798-0":{"test98":98},"1628145680799-0":{"test99":99}}

这三个指令放到一起来讲

  • info中返回了stream的基本信息,方便对接监控系统,从返回数据可以看到,stream中海油20条数据,有两个宏节点,两个分组,以及首尾两条数据的完整信息。
  • pending返回了group中个consumer没有应答【ACK】的消息数量,从返回数据可以看到,消费者【consumer_1】有两个消息未应答。
  • range指令比较简单,只是返回一定范围内的数据,参数可以设定为具体的时间戳作为查询条件。
相关推荐
P.H. Infinity41 分钟前
【RabbitMQ】04-发送者可靠性
java·rabbitmq·java-rabbitmq
生命几十年3万天44 分钟前
java的threadlocal为何内存泄漏
java
caridle1 小时前
教程:使用 InterBase Express 访问数据库(五):TIBTransaction
java·数据库·express
^velpro^1 小时前
数据库连接池的创建
java·开发语言·数据库
苹果醋31 小时前
Java8->Java19的初步探索
java·运维·spring boot·mysql·nginx
秋の花1 小时前
【JAVA基础】Java集合基础
java·开发语言·windows
小松学前端1 小时前
第六章 7.0 LinkList
java·开发语言·网络
Wx-bishekaifayuan1 小时前
django电商易购系统-计算机设计毕业源码61059
java·spring boot·spring·spring cloud·django·sqlite·guava
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
全栈开发圈1 小时前
新书速览|Java网络爬虫精解与实践
java·开发语言·爬虫