本文已参与「新人创作礼」活动,一起开启掘金创作之路。
前言
本文参考源码版本为 redis6.2
redis 从 5.0 版本开始支持提供 stream 数据类型,它可以用来保存消息数据,进而能帮助我们实现一个带有消息读写基本功能的消息队列,并用于日常的分布式程序通信当中。
其中,为了节省内存空间,在 stream 数据类型的底层数据结构中,采用了 radix tree
和 listpack
两种数据结构来保存消息。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 用于释放迭代器的相关资源。