一、介绍
redis 从 5.0 版本开始支持提供 stream 数据类型,它可以用来保存消息数据,进而能帮助我们实现一个带有消息读写基本功能的消息队列,并用于日常的分布式程序通信当中。
其中,为了节省内存空间,在 stream 数据类型的底层数据结构中,采用了 radix tree
和 listpack
两种数据结构来保存消息。
listpack 是一个紧凑型列表
,在保存数据时会非常节省内存;radix tree,这个数据结构的最大特点是适合保存具有相同前缀
的数据,从而实现节省内存空间的目标,以及支持范围查询。
二、特性
- Stream 会在内存中使用 rax 树和 listpack 结构来存储消息。
- Stream 借鉴 Kafka 引入了 Consumer Group 的概念,不同的消费者可以划分到不同的 Consumer Group 中,消费同一个 Stream
- Stream 借鉴了 Kafka 中 offset 的思想,引入了 position 的概念。Consumer 可以通过移动 position 重新消费历史消息,为故障恢复带来更多便利。
- 引入了 ACK 确认机制,保证消息 “at least once” 消费(也就是一条消息至少被消费一次)。
- Stream 可以设置其中消息保存的上限阈值,在超过该阈值的时候,Redis 会将历史消息抛弃掉,避免内存被打爆。
三、使用场景
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 的消息可以继续恢复
以上信息来自
Stream:redis5.0 定制版消息队列底层实现(长文)
四、Stream 结构体分析
在使用 Stream 的时候,最常用的命令就是 XADD 命令
,它可以向 Stream 中追加消息,例如:
127.0.0.1:6379> XADD mystream * name kouzhaoxuejie age 25
"1659530063532-0"
-
mystream: Stream 的名称,
-
星号* : 表示自动生成消息 ID,
-
name kouzhaoxuejie age 25: 这部分就是一条消息(Redis 代码中的注释称其为 entry),一个消息可以包含多个 field value 部分。写入成功之后,Redis 会返回这条消息的 ID
Stream 中每条消息都有唯一 ID,这个 ID 在 Redis 内部是使用 streamID 结构体进行抽象。如下所示,streamID 中维护了一个毫秒级时间戳(ms 字段)以及一个毫秒内的自增序号(seq 字段),之所以有这个自增序号的存在,是为了区分在同一毫秒内写入的两条 entry。
typedef struct streamID {
uint64_t ms; // 毫秒级时间戳
uint64_t seq; // 毫秒内的自增序列号
} streamID;
接下来看 stream 结构体,它是对 Stream 数据结构的抽象。Redis 始终是一个 KV 存储,一个 stream 实例会作为 Value 值,封装成 redisObject 存储到 redisDb 中,该 redisObject 实例的 type 值为 OBJ_STREAM,其对应的 Key 值就是 Stream 的名称。
Stream 底层依赖了 Rax、listpack 等数据结构来存储 entry,同时还维护了消费当前 stream 的 Consumer Group 信息。stream 结构体的结构如下图所示:
下面是上图中 stream 结构体中关键字段的功能说明。
- rax 字段是一个 rax 指针,该 rax 树用来存储该 Stream 中的全部消息。其中,entry ID 为 Key,entry 的内容为 Value。
- length:当前 Stream 中的消息条数。
- last_id:记录了当前 Stream 中最后一条消息的 ID。
- first_id:记录了当前 Stream 中第一条消息的 ID。
- max_deleted_entry_id:记录了当前 Stream 中被删除的最大的消息 ID。
- entries_added:记录了总共有多少消息添加到当前 Stream 中,该值是已经被删除的消息条数以及未删除的消息条数的总和。
- cgroups:rax 指针,该 rax 树用于记录当前消费该 Stream 的所有 Consumer Group。其中,Consumer Group 的名称是它的唯一标识,也是它在这个 rax 树的 Key,Consumer Group 实例本身是对应的 Value。
们先来关注 stream->cgroups 这棵 rax 树中存储的 Consumer Group。Redis 定义了一个 streamCG 结构体来抽象 Consumer Group,其核心字段如下
/* Consumer group. */
typedef struct streamCG {
streamID last_id; /* 已经消费的最新的一个消息ID */
rax *pel; /* Pending entries list. 已经消费但没有ACK的消息列表*/
rax *consumers; /* 消费组所包含的消费者列表 */
} streamCG;
- last_id:streamID 实例,当前 Consumer Group 消费的位置,即已经确认的最后一条消息的 ID。该 Consumer Group 中的全部 Consumer 会共用一个 last_id 值,这与 Kafka 中 Consumer Group 中的 offset 值功能类似。
- pel:rax 指针,该字段的全称是 “pending entries list”,其中记录了已经发送给当前 Consumer Group 中 Consumer,但还没有收到确认消息的消息 ID。其中 Key 是消息 ID,Value 为对应的 streamNACK 实例。在 streamNACK 结构体中,记录了该消息最后一次推送给 Consumer 的时间戳(delivery_time 字段)、被推送的次数(delivery_count 字段)以及推送给了哪个 Consumer 客户端(consumer 字段)。
- consumers:rax 指针,记录了当前 Consumer Group 中有哪些 Consumer 客户端,其中 Key 为 Consumer 的名称,Value 为对应的 streamConsumer 实例。在 streamConsumer 结构体中主要记录了当前 Consumer 的相关信息,例如,最近一次被激活的时间戳(seen_time 字段)、名称(name 字段)以及对应的 pending entries list(pel 字段)。注意,对于一个消息 ID 来说,在 streamCG->pel 和 streamConsumer->pel 中的 streamNACK 实例是同一个
五、 Stream 存储消息的格式
前面提到,Stream 在使用 Rax 树和 listpack 两种结构来存储消息,其中的 Key 是消息 ID,Value 是一个 listpack,这个 listpack 里面存储了多个消息,其中的消息 ID 都会大于等于 Key 中的消息 ID。
下面我们展开介绍一下 listpack 是如何存储多条消息的。
在新建 listpack 的时候,插入到新 listpack 的第一个消息并不是真实的消息数据,而是一个叫做 “master entry” 的消息(entry),然后才会插入真正的消息数据。master entry 中记录了一些元数据,格式如下图所示,其中每一部分都是一个 listpack 元素。
-
count: 记录了当前 listpack 中有效的消息个数
-
deleted: 记录了当前 listpack 中无效的、已被标记删除的消息个数,count + deleted 即为 listpack 中消息的总和
-
num-fields: 记录了 master entry 中 field 个数(也就是第一条消息有多少个 field/value 对)
-
field_1…field_N: 该 listpack 实例第一次插入消息时携带的 field 集合。
-
最后一个 “0”: 表示 master entry 这条消息的结束
在 master entry 消息之后,Stream 才开始真正存储有效的消息。下面的介绍同时适用于新 listpack 插入第一条消息以及向老 listpack 追加消息的场景。消息的具体格式如下图所示
有效消息中的第一部分是 flags,它是一个 int 类型的值,其中可以设置下面三个标识位
。
- SAMEFIELDS:它表示当前消息中的 field 与 master entry 完全一致,可以进行优化存储,如上图中(a)结构所示,此时无需再存储 field 信息了,直接存储 value 值即可。listpack 中的第一条有效消息,必然是符合该优化条件的。
- NONE:它与上面的 SAMEFIELDS 标记位互斥,它表示当前消息无法进行优化,需要同时存储 field 和 value 的信息,消息格式如上图(b)所示。
- DELETED:它表示当前消息已经被删除了。
之所以会有 master entry 消息,是因为实际情况中,写入同一个 Stream 的消息格式,几乎是完全一模一样的;通过 master entry 消息的优化,可以不用在每条消息中存储 field,进而节省内存空间。
entry-id 部分,其中并不会直接存储完整的 streamID 值,而是存储当前插入消息与 master entry id(即该 raxNode 节点的 key 值)的差值,其中包括时间戳和序列号两部分的差值( ms-diff、seq-diff),之所以用差值的方式进行存储,是为了减小存储的值,这也是存储有序数据时,常见的一种优化策略。
然后是 num-fields 部分,记录了该条消息中的 field 个数。因为在 flags 为 NONE 场景中,消息与master entry 的 field 不一致,所以才需要 num_field 部分。在 flags 为 SAMEFIELDS 场景中,不需要存储 num-field 部分。
之后,就是 field 以及 value 部分了,其中存储了消息的有效负载数据,无需过多介绍了。
最后是 lp_count 部分,它用于记录当前这条消息涉及到多少个 listpack 元素。例如,在 flags 为 NONE 的存储场景中,lp_count 为 num_fields*2(field和value的总个数) + 3 + 1
。其中,3 是指 flags、ms-diff、seq-diff 三部分,1 是指 num-fields 这部分。SAMEFIELDS 场景中,lp_count 为master_entry_num_fields (只有value个数) + 3 (指flags、ms-diff、seq-diff 这三部分)
。
下面这个图就是rax节点下streamId指向消息体的图例
redis stream 的实现依赖于 rax 结构以及 listpack 结构。每个消息流都包含一个 rax 结构,以消息ID
为 key、listpack
结构为 value 存储在 rax 结构中。每个消息的具体信息存储在这个 listpack 中。
示例图:
- 每个 listpack 都有一个
master entry
,该结构中存储了创建这个 listpack 时待插入消息的所有 field,这种保存方式其实也是为了节省内存
空间,这是因为很多消息的键是相同的,保存一份就行。 - 每个 listpack 中可能存储多条消息
六、关于Rax结构
在 Redis 需要有序数据结构
的时候,都使用到了 Rax 树
Stream,它是 Redis 用来实现消息队列特性的结构,其中的消息 ID 就会存储在 Rax 树中,这样就可以按照消息 ID 的顺序进行查找了;再比如,Redis 中会维护连接到自身的客户端列表,这个列表就是通过 Rax 树实现的,其中存储的是客户端的 ID,每个客户端 ID 关联的客户端指针也会记录到 Rax 节点中,这样我们就可以根据客户端 ID 查找到对应客户端了。
首先是 raxNode 结构体,它是对 Rax 树节点的抽象,具体定义如下:
typedef struct raxNode {
uint32_t iskey:1; // 标识当前节点是否包含一个key,占用1位
uint32_t isnull:1; // 标识当前节点是否需要存储value-ptr指针,占1位
uint32_t iscompr:1;// 当前节点是否为压缩节点,占1位
uint32_t size:29; // 占29位,如果当前节点是压缩节点,则表示压缩的字符
// 串长度;如果当前节点是非压缩节点,则为子节点个数
unsigned char data[]; // 具体存储数据的地方
} raxNode;
RaxNode 结构体中的 isKey、isnull 等字段都是标识位,用于标识当前 raxNode 节点中存储了什么样的数据,而真正存储数据的是 data 这个数组字段。下面我们就展开介绍一下 raxNode 存储数据的格式。
如果当前节点是非压缩节点,size 字段存储的是子节点的个数,在 data 中会用 size 个字符来存储对应的节点信息,同时会存储 size 个 raxNode 节点指针,这些指针会指向子节点。例如,一个非压缩节点中存储了 a、b、c 三个子节点,那么其结构如下图所示:
可以看到,在 size 字段之后,使用三个字节存储节点的数据,也就是 “a”“b”“c” 三个字符;之后是 a-ptr、b-ptr、c-ptr 三个 raxNode 指针,指向三个子节点;最后存储一个 value-ptr 指针,指向该节点表示 key 所对应的 value 值。上图展示的节点如果展开成树型结构,逻辑上就如下图所示,一个节点内的字符其实是树中的兄弟关系
:
如果当前节点是压缩节点,当前节点只会有一个子节点,size 字段记录的是 data 中存储的字符串长度。例如,有 “x”“y”“z” 三个字符被压缩到了一个节点中,内存结构就如下图所示,其中 z-ptr 指针指向了下一个节点,value-ptr 指向该节点表示的 key 所对应的 value 值。
上图展示的节点如果展开成树型结构,逻辑上如下图所示,一个节点内的字符其实是树中的父子关系
raxNode 节点组成的 Rax 树在 Redis 中使用 rax 结构体进行抽象,其具体定义如下:
typedef struct rax {
raxNode *head; // 指向Rax树的根节点
uint64_t numele; // 记录Rax树中有多少元素
uint64_t numnodes; // 记录Rax树中有多少节点
} rax;
七、Consumer Group
在使用 Consumer Group 消费之前,我们要先通过 XGROUP CREATE 命令创建一个 Consumer Group,XGROUP CREATE 命令的完整格式如下:
XGROUP CREATE key groupname id | $ [MKSTREAM] [ENTRIESREAD entries_read]
在执行 XGROUP CREATE 命令时,xgroupCommand() 函数会先对命令参数进行解析和检查。
- 这里 key 是 Stream 的名称,这里要保证 key 对应的目标 Stream 存在。
- XGROUP CREATE 命令的第四个参数指定的是这个 Consumer Group 从哪个 streamID 开始消费,如果它的值是 “$”,这里会将该参数替换成 Stream 中最后一条消息的 ID,也就是 stream->last_id 字段的值。
- 如果包含 MKSTREAM 参数,则会在目标 Stream 不存在的时候,自动创建目标 Stream 实例。
XGROUP 命令除了可以接 CREATE 子命令,来创建 Consumer Group ,还支持非常多其他的操作,比如:
// 设置指定Consumer Group的消费位置,即修改其last_id字段值
XGROUP SETID key groupname id | $ [ENTRIESREAD entries_read]
// 销毁指定的Consumer Group
XGROUP DESTROY key groupname
// 在指定的Consumer Group中创建Consumer,实际上就是创建对应的streamConsumer实例
// 并插入到streamCG->consumers这棵rax树中
XGROUP CREATECONSUMER key groupname consumername
// 销毁指定Consumer Group中的Consumer
XGROUP DELCONSUMER key groupname consumername
消费组是 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),用以维护该消费组已经消费但没有确认的消息。
- 消费组中的每个成员也有一个待确认消息队列,维护着该消费者已经消费尚未确认的消息。
实际场景可以有多个生产者不断发布消息,同时可以有多个消费组进行监听消息;另外,也支持非消费组的形势处理消息。如下图:
消费者。每个消费者通过 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 查询具体消息信息。
未确认消息。未确认消息(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 对象,也就是说,这个元数据是共享的
个人公众号: 行云代码