水煮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指令比较简单,只是返回一定范围内的数据,参数可以设定为具体的时间戳作为查询条件。
相关推荐
用户9047066835715 分钟前
如何使用 Spring MVC 实现 RESTful API 接口
java·后端
刘某某.16 分钟前
数组和小于等于k的最长子数组长度b
java·数据结构·算法
程序员飞哥21 分钟前
真正使用的超时关单策略是什么?
java·后端·面试
用户9047066835722 分钟前
SpringBoot 多环境配置与启动 banner 修改
java·后端
小old弟43 分钟前
后端三层架构
java·后端
花花鱼44 分钟前
spring boot 2.x 与 spring boot 3.x 及对应Tomcat、Jetty、Undertow版本的选择(理论)
java·后端
温柔一只鬼.1 小时前
Docker快速入门——第二章Docker基本概念
java·docker·容器
要争气1 小时前
5 二分查找算法应用
java·数据结构·算法
郑..方..醒1 小时前
java实现ofd转pdf
java·pdf
道可到1 小时前
阿里面试原题 java面试直接过06 | 集合底层——HashMap、ConcurrentHashMap、CopyOnWriteArrayList
java·后端·面试