Stream:redis5.0 定制版消息队列底层实现(深入解析)

722 阅读24分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。


前言

本文参考源码版本为 redis6.2

redis 从 5.0 版本开始支持提供 stream 数据类型,它可以用来保存消息数据,进而能帮助我们实现一个带有消息读写基本功能的消息队列,并用于日常的分布式程序通信当中。

其中,为了节省内存空间,在 stream 数据类型的底层数据结构中,采用了 radix treelistpack 两种数据结构来保存消息。listpack 是一个紧凑型列表,在保存数据时会非常节省内存;radix tree,这个数据结构的最大特点是适合保存具有相同前缀的数据,从而实现节省内存空间的目标,以及支持范围查询。

一、使用场景

redis 每一种数据结构的出现都是为了高效的解决某一类问题,你有过好奇 redis stream 的出现是解决了什么场景下的问题?

比如,在 stream 出现之前就存在的 lists, sorted sets, and Pub/Sub 等数据结构,有哪些场景它们不能高效的解决实际问题?

  • list 结构是一种线性结构,遍历效率低
  • sorted sets 底层实现上使用了一种叫跳表(skiplist)的结构,这种结构为了快速的检索和排序数据,牺牲了部分内存空间进行折中;因此,当数量大时,会消耗较大内存;另外,不支持客户端的阻塞操作,原因是 sorted sets 是一个按 score 的有序结构,当新插入元素时会根据 score 改变元素顺序。
  • Pub/Sub 是一种发布/订阅模式,redis 对此的实现较为简单,在服务端仅记录订阅关系;当有消息发布时,根据订阅关系推送到客户端;可以看到这是一种及时转发推送的模式,不会记录处理过的消息,也就是说不会持久化,因此高可用性就得不到保证。

接下来考虑的是:如何设计一款时间序列的结构。

以上问题,便是 redis 作者考虑设计一款更加灵活的数据结构来解决问题的思路,大致如下:

  • 支持多消费者订阅模式
  • 消息可重复消费,同时记录消费者 offset
  • 时间序列
  • 增删查等操作要有较高的效率
  • 高效的内存使用

我们来看看 stream 是如何实现以上功能。

  • 消费组:一个消费组可以有多个消费者消费同一个 stream 队列,组内消费者是竞争关系,也就是一条消息只能被一个消费者消费;一个 stream 可以有多个消费组,每个消费组各自记录消费 offset,且组间消费互不影响
  • 提供唯一递增消息 ID,默认自动按时间序列生成
  • 采用 rax 结构存储消息 ID,提高访问效率;
  • listpack 结构存储 value,节省存储空间
  • 提供 pending 队列,针对没有 ACK 的消息可以继续恢复

另外,这里消费组的概念,作者也有说是借鉴于 kafka 消费者组的概念。具体可以看看作者的思路:Streams: a new general purpose data structure in Redis.

二、redis stream 怎么使用

1、基础命令

1、XADD 插入数据

1)当你想简单的往 stream 插入数据,你可以这样写:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> XADD mystream * foo 10
QUEUED
127.0.0.1:6379> XADD mystream * bar 10
QUEUED
127.0.0.1:6379> EXEC
1) "1645925632716-0"
2) "1645925632717-0"

这里用 MULTI/EXEC 语句块包裹,模拟快速插入, 其中使用 * 表示 ID 自动生成;当然你也可以指定 ID,需要满足指定的 ID 大于当前 stream 队列中的最大 ID。

自动生成的 ID 组成部分为 <millisecondsTime>-<sequenceNumber>,即毫秒数 + 当前毫秒內递增数

2)当你想要避免 stream 消息过多带来的内存占用较高问题,你可以这样写:

127.0.0.1:6379> XADD mystream MAXLEN 1000000 * field1 value1 field2 value2
"1645927379432-0"

你可能已经注意到了,这里使用了参数 MAXLEN 来限制 stream 的消息条数,如果超出限制,它会删除最早的一批数据。这个用法类似于 list 的 RPUSH + LTRIM 操作。

3)如果你想 MAXLEN 效率更高效一些,你可以这样写:

127.0.0.1:6379> XADD mystream MAXLEN ~ 1000000 * foo bar
"1645927859121-0"

使用了 ~ 符号,是一个精确的限制,也就是说比限制的条数多一点也没啥大问题;这样可以不用每次都去检测是否要移除消息,从而导致部分 CPU 开销。

2、XRANGE 用来进行范围查询数据

1)如果你知道 ID 范围可以这样查询

127.0.0.1:6379> XRANGE mystream 1645925632717-0 1645925632717-0
1) 1) "1645925632717-0"
   2) 1) "bar"
      2) "10"  

2)如果你想从 stream 队列中查询所有数据并控制输出条数,可以这样查询

127.0.0.1:6379> XRANGE mystream - + COUNT 2
1) 1) "1645844046732-0"
   2) 1) "field3"
      2) "value3"
2) 1) "1645846465369-0"
   2) 1) "field4"
      2) "value4"

其中 - + 表示最小 id 和 最大 id

3)如果你仅知道一个具体 ID,你还可以这样进行范围查询

127.0.0.1:6379> XRANGE mystream 1645925632716-0 + COUNT 2
1) 1) "1645925632716-0"
   2) 1) "foo"
      2) "10"
2) 1) "1645925632717-0"
   2) 1) "bar"
      2) "10"   

3、XREAD 查询数据

1)如果你想读取即将到来的新数据,你可以这样写:

127.0.0.1:6379> XREAD BLOCK 5000 STREAMS mystream otherstream $ $
1) 1) "mystream"
   2) 1) 1) "1645926784029-0"
         2) 1) "hhh"
            2) "111"
(2.41s)
  • 这里 $ 表示取当前 max_id 之后的消息,也就是等待最新消息,该操作类似于 unix 的 tail -f命令;
  • 当然,这里 $ 也可以换成具体 ID
  • 这里监听了 mystream 和 otherstream 两个 stream, 只要有一个 stream 有新消息就立即返回,如果都有消息,就都一起返回。

2)当然,你也可能只想读取已经存在的数据,可以这样写:

127.0.0.1:6379> XREAD COUNT 2 STREAMS mystream 0
1) 1) "mystream"
   2) 1) 1) "1645844046732-0"
         2) 1) "field3"
            2) "value3"
      2) 1) "1645846465369-0"
         2) 1) "field4"
            2) "value4"
  • 其中 count 为可选参数,0 代表读取所有 id 大于 0-0 的记录
  • 相同的,这里 STREAMS 可以读取多个 stream, 比如 xread streams mystream otherstream 0 0

2、消费组

1、XGROUP

用于创建、销毁和管理消费组;这里的消费组类似于 kafka 消费组的概念,组内消费者间消费数据具有互斥性、组间数据相互隔离;不同的是 kafka 有分区的概念,而 redis stream 则没有。

> XGROUP CREATE mystream mygroup $
OK
  • 创建消费组 mygroup,创建组一般需要指明 the last message ID,这里 $ 表示 mygroup 将会消费从此刻开始之后的新消息
  • 当然,如果是 0,表示要从头开始消费,也包括 STREAM 的历史消息;同样,这里也可以指定任意 id 开始消费
> XGROUP CREATE newstream mygroup $ MKSTREAM
OK

当指定的 STREAM 不存在时,可以指定子选项 MKSTREAM 自动创建 STREAM

2、XREADGROUP

127.0.0.1:6379> XREADGROUP GROUP mygroup Alice COUNT 1 STREAMS mystream >
1) 1) "mystream"
   2) 1) 1) "1645846465369-0"
         2) 1) "field4"
            2) "value4"
  • 这里 mygroup 是消费组名,Alice 是 mygroup 下的消费者;>是特定 id,也可以指定为其他值
  • 如果这里指定为特定 id 为 >,则表示从第一条尚未被消费的消息开始读取,并更新消费组的 last ID
  • 如果指定的 id 为其他任意指定的有效 id, 该命令将访问历史pending 队列消息

消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了。也就是说,同一个消费组内,消息是互斥的。

值得注意的是:使用消费组的目的是让组内的多个消费者共同分担读取消息,所以,通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的。这一点与 kafka 是不同的,kafka 有分区的概念,消费组内的消费者负责消费不同的分区数据,不存在多个消费者消费同一个分区的情况。

3、XPENDING 它是 stream 的一个重要实现,是 redis 实现消息队列满足高可用的重要体现。如果大家使用过 list 或者 sort sets 结构作为队列应该都知道,它们都需要一个 备份队列 来应对故障恢复的场景。这里 PEL(Pending entries list),就相当于备份队列

该命令查询消费者已拉取但未ACK 的消息,可用于出现 crash 等未正常执行 ACK 情况下的数据恢复,使用如下:

XPENDING <key> <groupname> [[IDLE <min-idle-time>] <start-id> <end-id> <count> [<consumer-name>]]

如果你想返回 PEL 队列中的前10条数据,你可以这样写:

127.0.0.1:6379> XPENDING mystream mygroup - + 10
1) 1) "1645839040399-0"
   2) "alice"
   3) (integer) 89570973
   4) (integer) 1
2) 1) "1645839593961-0"
   2) "bob"
   3) (integer) 89076509
   4) (integer) 1

为了保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息,Streams 会自动使用 PENDING List 队列,备份消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 streams “消息已经处理完成”。

如果消费者没有成功处理消息,它就不会给 streams 发送 XACK 命令,消息仍然会留存。此时,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。

三、底层原理

1)Redis Stream的结构 主要由消息、生产者、消费者、消费组4部分组成。

Stream 消费组特点

  • 每个消费组通过组名称唯一标识,每个消费组都可以消费该消息队列的全部消息,多个消费组之间相互独立
  • 每个消费组可以有多个消费者,消费者通过名称唯一标识,消费者之间的关系是竞争关系,也就是说一个消息只能由该组的一个成员消费。
  • 组内成员消费消息后需要确认,每个消息组都有一个待确认消息队列(pending entry list, pel),用以维护该消费组已经消费但没有确认的消息。
  • 消费组中的每个成员有一个待确认消息队列,维护着该消费者已经消费尚未确认的消息。

redis stream 的底层实现主要使用了 listpack 以及 rax 树,下面一一介绍

1、radix tree

前缀树是字符串查找时,经常使用的一种数据结构,能够在一个字符串集合中快速查找到某个字符串。

1)前缀树示例图

             (f) ""
               \
               (o) "f"
                 \
                 (o) "fo"
                   \
                 [t   b] "foo"
                 /     \
        "foot" (e)     (a) "foob"
               /         \
     "foote" (r)         (r) "fooba"
             /             \
   "footer" []             [] "foobar"

由于树中每个节点只存储字符串中的一个字符,故而有时会造成空间的浪费。rax 的出现就是为了解决这一问题。

对于连续只有一个子节点的节点,可以将其压缩成一个节点,这样一来可以适当降低树的层级以及多次分配节点空间;换句话说,可以提升检索效率并且更加节省内存。

2)rax 树示例图:

                ["foo"] ""
                   |
                [t   b] "foo"
                /     \
      "foot" ("er")    ("ar") "foob"
               /          \
     "footer" []          [] "foobar"

关于 rax 结构,在之前的文章已经做了深入分析,可以点击查看

那 stream 又是如何利用 rax 来提高检索效率并节省空间的呢?

  • 首先,stream 作为一款基于内存的消息队列来设计,针对每一条消息,都必须设置唯一且递增的消息 ID。
  • 一般我们选择自动生成 ID,这种一段时间内的连续 ID,前缀都有一些高度重复性;你也知道,这种重复的前缀正好符合 rax 结构高效存储的特性。
  • 因此,stream 中的 ID 就是通过 rax 来进行存储的。

既然消息 ID 已经存下了,对应的消息存在哪里的?别着急,继续往下看~

2、listpack

redis 源码对于 listpack 的解释为 A lists of strings serialization format,一个字符串列表的序列化格式,也就是将一个字符串列表进行序列化存储。

listpack 也叫紧凑列表,它的特点就是用一块连续的内存空间来紧凑地保存数据,同时为了节省内存空间,listpack 列表项使用了多种编码方式,来表示不同长度的数据,这些数据包括整数字符串

1)listpack结构图 在这里插入图片描述

listpack 由4部分组成:Total Bytes、Num Elem、Entry 以及 End。

  • Total Bytes 为整个 listpack 的空间大小,占用4个字节。
  • Num Elem 为 listpack 中的元素个数,即 Entry 的个数,占用2个字节。
  • Entry 为每个具体的元素。
  • End 为 listpack 特定的结束标志,占用1个字节,内容为0xFF。

简单的理解 listpack 就是一款专门为节省内存空间,通过特定的编码方式将数据进行编码和解码的数据结构,这种结构天生就是为节省空间而存在的。

相信你已经想到了, 我们实际的消息就是通过 listpack 结构来存储的;上面我们讲到,消息 ID 已经存入了 rax 树中;如果你了解 rax 结构应该知道,树中对应 key 的节点有一个 data 域,这里可以存储数据;相同的,这里写入了 key 对应消息的指针,指向 listpack 结构,也就是 stream 的具体消息内容了。

关于 listpack,在之前的文章已经做了深入分析,可以点击查看

3、stream 结构

redis stream 的实现依赖于 rax 结构以及 listpack 结构。每个消息流都包含一个 rax 结构,以消息ID 为 key、listpack结构为 value 存储在 rax 结构中。每个消息的具体信息存储在这个 listpack 中。 1)示例图:

在这里插入图片描述

  • 每个 listpack 都有一个 master entry,该结构中存储了创建这个 listpack 时待插入消息的所有 field,这种保存方式其实也是为了节省内存空间,这是因为很多消息的键是相同的,保存一份就行。
  • 每个 listpack 中可能存储多条消息

2)stream 结构:

typedef struct stream {
    rax *rax;               /* The radix tree holding the stream. */
    uint64_t length;        /* Number of elements inside this stream. */
    streamID last_id;       /* Zero if there are yet no items. */
    rax *cgroups;           /* Consumer groups dictionary: name -> streamCG */
} stream;
  • rax 存储消息生产者生产的具体消息,每个消息有唯一的 ID。以消息 ID 为键,消息内容为值存储在 rax 中,值得注意的是,rax 中的一个节点可能存储多个消息。
  • length 代表当前 stream 中的消息个数(不包括已经删除的消息)。
  • last_id 为当前 stream 中最后插入的消息的 ID, stream 为空时,设置为0。
  • cgroups 存储了当前 stream 相关的消费组,以消费组的组名为键,streamCG 为值存储在 rax 中。

3)消费组。消费组是 stream 中的一个重要概念,每个 stream会有多个消费组,每个消费组通过组名称进行唯一标识,同时关联一个 streamCG 结构,该结构定义如下:


/* Consumer group. */
typedef struct streamCG {
    streamID last_id;       /* 已经消费的最新的一个消息ID */
    rax *pel;               /* Pending entries list. 已经消费但没有ACK的消息列表*/
    rax *consumers;         /* 消费组所包含的消费者列表 */
} streamCG;

特别说明的是 PEL 结构:pel 为该消费组尚未确认的消息,并以消息ID 为键,以 streamNACK 为值。

消费组的概念是作者受到 kafka 消费组的启发而来;在 stream 的消费组中:

  • 每个消费组通过组名称唯一标识,每个消费组都可以消费该消息队列的全部消息,多个消费组之间相互独立。
  • 每个消费组可以有多个消费者,消费者通过名称唯一标识,消费者之间的关系是竞争关系,也就是说一个消息只能由该组的一个成员消费。
  • 组内成员消费消息后需要确认,每个消息组都有一个待确认消息队列(pending entry list, pel),用以维护该消费组已经消费但没有确认的消息。
  • 消费组中的每个成员也有一个待确认消息队列,维护着该消费者已经消费尚未确认的消息。

实际场景可以有多个生产者不断发布消息,同时可以有多个消费组进行监听消息;另外,也支持非消费组的形势处理消息。如下图: 在这里插入图片描述 4)消费者。每个消费者通过 streamConsumer 唯一标识,该结构如下:

typedef struct streamConsumer {
    mstime_t seen_time;         /* 消费者上一次活跃时间 */
    sds name;                   /* 消费者名字,组内唯一,区分大小写 */
    rax *pel;                   /* 该消费者消费但未 ACK 的消息列表. rax 结构中 key 便是                                                 
                                   消息ID,value 指针指向 streamNACK 结构,记录的是该条消息的处理次数和上次处理时间 */
} streamConsumer;

每一个消费者都有一个 PEL 结构,用来保存未 ACK 消息的元数据;值得注意的是,这里并不会保存完整的消息,仅保存了消息 ID 和 处理情况的元数据;

因此,当你想从 PEL 中恢复数据时,你需要先从 PEL 中拿到消息 ID 列表,然后再从原 stream 列表中根据 ID 查询具体消息信息。

5)未确认消息。未确认消息(streamNACK)维护了消费组或者消费者尚未确认的消息。

/* Pending (yet not acknowledged) message in a consumer group. */
typedef struct streamNACK {
    mstime_t delivery_time;     /* Last time this message was delivered. */
    uint64_t delivery_count;    /* Number of times this message was delivered.*/
    streamConsumer *consumer;   /* The consumer this message was delivered to
                                   in the last delivery. */
} streamNACK;

该结构用于 PEL 队列中存储消息的元数据信息,比如 上次处理时间、处理次数以及上一次被哪个消费者处理的。

相信细心的你,已经发现了 streamCG 结构和 streamConsumer 结构都有一个 PEL 字段,那它们有什么关联?

  • 首先,streamCG 作用范围是整个消费组,而 streamConsumer 范围是一个消费者。
  • streamCG 中包含的是整个消费组的未 ACK 列表,而 streamCG 是单个消费者的未 ACK 列表。

你可能想问,两者是不是包含关系?数据是不是有重复记录? 确实是包含关系,但为什么要这样记录多次?

  • 首先,PEL 也是一颗 rax 树结构,消息 ID 构成这棵树,value 是一个指针;得益于 rax 本身的特性,这棵树本身不会占用多少内存
  • 这样写可以高效的应对多种数据查询,比如查询单个消费者的、或者整个消费组的,指定数量或者全部等
  • 值得注意的是,两者 PEL 结构中,key 对应的 value 指向的是同一个 streamNACK 对象,也就是说,这个元数据是共享的

6)迭代器。为了遍历stream中的消息,Redis 提供了streamIterator 结构。

使用迭代器的好处是,提供一系列抽象去遍历 stream,不用在关心实际的 radix tree + listpack 实现。stream 中一般是内部使用迭代器,比如 streamReplyWithRange(),AOF 操作等。

typedef struct streamIterator {
    stream *stream;         /* The stream we are iterating. */
    streamID master_id;     /* ID of the master entry at listpack head. */
    uint64_t master_fields_count;       /* Master entries # of fields. */
    unsigned char *master_fields_start; /* Master entries start in listpack. */
    unsigned char *master_fields_ptr;   /* Master field to emit next. */
    int entry_flags;                    /* Flags of entry we are emitting. */
    int rev;                /* True if iterating end to start (reverse). */
    uint64_t start_key[2];  /* Start key as 128 bit big endian. */
    uint64_t end_key[2];    /* End key as 128 bit big endian. */
    raxIterator ri;         /* Rax iterator. */
    unsigned char *lp;      /* Current listpack. */
    unsigned char *lp_ele;  /* Current listpack cursor. */
    unsigned char *lp_flags; /* Current entry flags pointer. */
    /* Buffers used to hold the string of lpGet() when the element is
     * integer encoded, so that there is no string representation of the
     * element inside the listpack itself. */
    unsigned char field_buf[LP_INTBUF_SIZE];
    unsigned char value_buf[LP_INTBUF_SIZE];
} streamIterator;
  • stream 为当前迭代器正在遍历的消息流。
  • 消息内容实际存储在 listpack 中,每个 listpack 都有一个 master entry(也就是第一个插入的消息), master_id 为该消息 id。
  • master_fields_count 为 master entry 中 field 域的个数。
  • master_fields_start 为 master entry field 域存储的首地址。
  • 当 listpack 中消息的 field 域与 master entry 的 field 域完全相同时,该消息会复用 master entry 的 field 域,在我们遍历该消息时,需要记录当前所在的field域的具体位置,master_fields_ptr就是实现这个功能的。
  • entry_fags 为当前遍历的消息的标志位。
  • rev 代表当前迭代器的方向。
  • start_key, end_key 为该迭代器处理的消息 ID 的范围。
  • ri 为 rax 迭代器,用于遍历 rax 中所有的key。
  • lp为当前 listpack 指针。
  • lp_ele 为当前正在遍历的 listpack 中的元素。
  • lp_fags 指向当前消息的 fag 域。
  • field_buf, value_buf 用于从 listpack 读取数据时的缓存。

四、stream 结构的实现

stream 可以看作是一个消息流。对一个消息而言,只能新增或者删除,不能更改消息内容,因此,这里主要介绍 stream 相关结构的初始化以及增删查等操作。

1、初始化:

/* Create a new stream data structure. */
stream *streamNew(void) {
    stream *s = zmalloc(sizeof(*s));
    s->rax = raxNew();
    s->length = 0;
    s->last_id.ms = 0;
    s->last_id.seq = 0;
    s->cgroups = NULL; /* Created on demand to save memory when not used. */
    return s;
}

可以看到,主要是分配空间并对相应字段进行初始化。

2、添加元素

1)添加消息

命令如下:

XADD key [MAXLEN [~|=] <count>] [NOMKSTREAM] <ID or *> [field value] [field value] ...

源码实现如下:

void xaddCommand(client *c) {
    streamID id;
    int id_given = 0; /* Was an ID different than "*" specified? */
    long long maxlen = -1;  /* If left to -1 no trimming is performed. */
    
    ...
    
    /* 1. 插入元素并返回消息 ID */
    /* Append using the low level function and return the ID. */
    if (streamAppendItem(s,c->argv+field_pos,(c->argc-field_pos)/2,
        &id, id_given ? &id : NULL)
        == C_ERR)
    {
        addReplyError(c,"The ID specified in XADD is equal or smaller than the "
                        "target stream top item");
        return;
    }
    
    ...
    
    // 2. 如果指定了 maxlen 参数,并且 stream 已经超过了该长度,则进行裁剪
    if (maxlen >= 0) {
        /* Notify xtrim event if needed. */
        if (streamTrimByLength(s,maxlen,approx_maxlen)) {
            notifyKeyspaceEvent(NOTIFY_STREAM,"xtrim",c->argv[1],c->db->id);
        }
        // 近似 maxlen, 也就是可以稍微大一点
        if (approx_maxlen) streamRewriteApproxMaxlen(c,s,maxlen_arg_idx);
    }
 
    ...
    
}

可以看到,真正的元素插入操作是由 streamAppendItem 完成,

int streamAppendItem(stream *s, robj **argv, int64_t numfields, streamID *added_id, streamID *use_id) {

    ...

    /* 1. 生成消息 ID,ID 必须大于当前 last id */
    streamID id;
    if (use_id)
        id = *use_id;
    else
        streamNextID(&s->last_id,&id);
    if (streamCompareID(&id,&s->last_id) <= 0) return C_ERR;

    ...
 
    // 2. 是否需要创建新的 listpack 和 radix tree,比如 这种 stream_node_max_bytes 都是可以配置的限制
    if (lp != NULL) {
        if (server.stream_node_max_bytes &&
            lp_bytes >= server.stream_node_max_bytes)
        {
            lp = NULL;
        } else if (server.stream_node_max_entries) {
            int64_t count = lpGetInteger(lpFirst(lp));
            if (count >= server.stream_node_max_entries) lp = NULL;
        }
    }

    // 2.1 lp == null 就新创建一个
    if (lp == NULL) {
    
        ...
        // 需要注意的是,这里仅将 master-entry 存入了 lp, 当前消息的内容要放在后面存入
        for (int64_t i = 0; i < numfields; i++) {
            sds field = argv[i*2]->ptr;
            lp = lpAppend(lp,(unsigned char*)field,sdslen(field));
        }
        lp = lpAppendInteger(lp,0); /* Master entry zero terminator. */
        // 将当前 消息插入 rax 树结构
        raxInsert(s->rax,(unsigned char*)&rax_key,sizeof(rax_key),lp,NULL);
        ...
    } else {
    
        ...
        // 2.2 如果当前 lp 还能装消息,就继续装当前消息
        // 值得注意的是,需要与当前 lp 的 master entry 判断字段是否都一致,一致的话就可以不再重复保存这些字段
        if (numfields == master_fields_count) {
            for (i = 0; i < master_fields_count; i++) {
                ...
                /* Stop if there is a mismatch. */
                // 2.2.1 如果有不匹配的就立刻停止
                if (sdslen(field) != (size_t)e_len ||
                    memcmp(e,field,e_len) != 0) break;
                lp_ele = lpNext(lp,lp_ele);
            }
            ...
        }
    }

    ...
   
    // 2.3 将 `消息内容` 存入 lp
    if (!(flags & STREAM_ITEM_FLAG_SAMEFIELDS))
        lp = lpAppendInteger(lp,numfields);
    for (int64_t i = 0; i < numfields; i++) {
        sds field = argv[i*2]->ptr, value = argv[i*2+1]->ptr;
        if (!(flags & STREAM_ITEM_FLAG_SAMEFIELDS))
            lp = lpAppend(lp,(unsigned char*)field,sdslen(field));
        lp = lpAppend(lp,(unsigned char*)value,sdslen(value));
    }
    
    // 2.4 将 lp 插入 rax(替换) 树结构
    if (ri.data != lp)
        raxInsert(s->rax,(unsigned char*)&rax_key,sizeof(rax_key),lp,NULL);
    
    ...
    
    return C_OK;
}

① 获取 rax 的最后一个 key 所在的节点,由于 Rax 树是按照消息 id 的顺序存储的,所以最后一个 key 节点存储了上一次插入的消息。

② 查看该节点是否可以插入这条新的消息。

③ 如果该节点已经不能再插入新的消息(listpack 为空或者已经达到设定的存储最大值),在 rax 中插入新的节点(以消息id 为 key,新建 listpack 为value),并初始化新建的listpack;如果仍然可以插入消息,则对比插入的消息与 listpack 中的 master 消息对应的fields 是否完全一致,完全一致则表明该消息可以复用 master 的 field。

④ 将待插入的消息内容插入到新建的 listpack 中或者原来的 rax 的最后一个 key 节点对应的listpack 中,这一步主要取决于前2步的结果。该函数主要是利用了listpack以及rax的相关接口。

2)新增消费组

streamCG *streamCreateCG(stream *s, char *name, size_t namelen, streamID *id) {
    // 如果当前消息流没有消费组,则直接创建
    if (s->cgroups == NULL) s->cgroups = raxNew();
    // 查找是否已经存在同名消费组
    if (raxFind(s->cgroups,(unsigned char*)name,namelen) != raxNotFound)
        return NULL;

    // 创建消费组,
    streamCG *cg = zmalloc(sizeof(*cg));
    cg->pel = raxNew();
    cg->consumers = raxNew();
    cg->last_id = *id;
    // 将消费组插入消费组 rax 树结构中,以消费组 name 为 key, streamCG 为 value
    raxInsert(s->cgroups,(unsigned char*)name,namelen,cg,NULL);
    return cg;
}

在 stream s 下新创建一个消费组 name;如果消费组已经存在,返回 NULL,反之,返回指向streamCreateCG的指针。

3)新增消费者

Stream 允许为某个消费组增加消费者,但没有直接提供在某个消费组中创建消费者的接口,而是在查询某个消费组的消费者时,发现该消费组没有该消费者时选择插入该消费者。

127.0.0.1:6379> xreadgroup group mygroup consumer1 count 1 streams mystream >
1) 1) "mystream"
   2) 1) 1) "1645926784029-0"
         2) 1) "hhh"
            2) "111"

当从消费组mygroup读取消息时,如果消费者 consumer1 不存在时,会自动创建

3、删除元素

1)删除消息

命令如:

XDEL <key> [<ID1> <ID2> ... <IDN>]

源码实现:

void xdelCommand(client *c) {
    robj *o;

    ...

    /* 真正执行删除操作的地方 */
    int deleted = 0;
    for (int j = 2; j < c->argc; j++) {
        deleted += streamDeleteItem(s,&ids[j-2]);
    }

   ...
   
}

可以看到删除操作交给了 streamDeleteItem 处理:

int streamDeleteItem(stream *s, streamID *id) {
    int deleted = 0;
    streamIterator si;
    streamIteratorStart(&si,s,id,id,0);
    streamID myid;
    int64_t numfields;
    if (streamIteratorGetID(&si,&myid,&numfields)) {
        // 执行删除操作
        streamIteratorRemoveEntry(&si,&myid);
        deleted = 1;
    }
    streamIteratorStop(&si);
    return deleted;
}

成功删除返回 1,反之,返回 0。最终的删除操作由 streamIteratorRemoveEntry 来完成:

void streamIteratorRemoveEntry(streamIterator *si, streamID *current) {
    
    ...
    
    /* Change the valid/deleted entries count in the master entry. */
    unsigned char *p = lpFirst(lp);
    aux = lpGetInteger(p);

    if (aux == 1) {
        // 当前 lp 只有待删除消息,则直接删除 lp
        lpFree(lp);
        raxRemove(si->stream->rax,si->ri.key,si->ri.key_len,NULL);
    } else {
        // 反之,仅修改 lp master-entry 统计信息
        lp = lpReplaceInteger(lp,&p,aux-1);
        p = lpNext(lp,p); /* Seek deleted field. */
        aux = lpGetInteger(p);
        lp = lpReplaceInteger(lp,&p,aux+1);

        /* Update the listpack with the new pointer. */
        if (si->lp != lp)
            raxInsert(si->stream->rax,si->ri.key,si->ri.key_len,lp,NULL);
    }

    ...
    
}

该方法通常只是设置待移除消息的标志位为已删除,并不会将该消息从所在的 listpack 中删除。当消息所在的整个 listpack的所有消息都已删除时,则会从 rax 中释放该节点。

2)裁剪信息流

将消息流的大小(未删除的消息个数,不包含已经删除的消息)裁剪到给定大小,删除消息时,按照消息id,从小到大删除。该接口为 streamTrimByLength

3)释放消费组

释放消费组的接口为 streamFreeCG,该接口主要完成2部分内容,首先释放该消费组的pel链表,之后释放消费组中的每个消费者。

/* Free a consumer group and all its associated data. */
void streamFreeCG(streamCG *cg) {
    // 释放该消费组的 pel 结构,同时设置回调函数用于释放每个消费者的streamNACK结构
    raxFreeWithCallback(cg->pel,(void(*)(void*))streamFreeNACK);
    // 释放每个消费者时,需要释放streamConsumer结构
    raxFreeWithCallback(cg->consumers,(void(*)(void*))streamFreeConsumer);
    zfree(cg);
}

4)释放消费者

释放消费者时,需要注意的是,不需要释放该消费者的 pel,因为该消费者的未确认消息结构 streamNACK 是与消费组的 pel 共享的,直接释放相关内存即可。

void streamFreeConsumer(streamConsumer *sc) {
    // 这里仅释放存储 streamFreeNACK的 rax 树结构
    raxFree(sc->pel); /* No value free callback: the PEL entries are shared
                         between the consumer and the main stream PEL. */
    sdsfree(sc->name);
    zfree(sc);
}

4、遍历元素

Redis 提供了 Stream 的迭代器 streamIterator,用于遍历 Stream 中的消息。主要有以下四个方法:

void streamIteratorStart(streamIterator *si, stream *s, streamID *start, streamID *end, int rev);
int streamIteratorGetID(streamIterator *si, streamID *id, int64_t *numfields);
void streamIteratorGetField(streamIterator *si, unsigned char **fieldptr, unsigned char **valueptr, int64_t *fieldlen, int64_t *valuelen);
void streamIteratorStop(streamIterator *si);
  • streamIteratorStart 用于初始化迭代器,需要指定迭代器的方向。
  • streamIteratorGetID 与 streamIteratorGetField 配合使用,用于遍历所有消息的所有 field-value。
  • streamIteratorStop 用于释放迭代器的相关资源。



为了更好的帮助你理解 stream 的底层原理,可以查看 listpackrax 的原理。