<返回更多

Redis Stream 数据结构实现原理真的很强

2023-09-13  微信公众号  码哥字节
加入收藏

你好,我是码哥,一个拥抱硬核技术和对象,面向人民币编程的男人,设置星标不迷路。

我在【redis 使用 List 实现消息队列的利与弊】说过使用 List 实现消息队列有很多局限性。

1、是什么

Stream 是 Redis 5.0 版本专门为消息队列设计的数据类型,借鉴了 Kafka 的 Consume Group 设计思路,提供了消费组概念。

同时提供了消息的持久化和主从复制机制,客户端可以访问任何时刻的数据,并且能记住每一个客户端的访问位置,从而保证消息不丢失。

以下几个是 Stream 类型的主要特性。

需要注意的是,Redis Stream 是一种超轻量级的 MQ,并没有完全实现消息队列的所有设计要点,所以它的使用场景需要考虑业务的数据量和对性能、可靠性的需求。

适合系统消息量不大,容忍数据丢失,使用 Redis Stream 作为消息队列就能享受高性能快速读写消息的优势

2、修炼心法

每个 Stream 都有一个唯一的名称,作为 Stream 在 Redis 的 key,在首次使用 xadd 指令添加消息的时候会自动创建。

可以看到 Stream 在一个 Redix Tree 树上,树上存储的是消息 ID,每个消息 ID 对应的消息通过一个指针指向 listpack。

Stream 流就像是一个仅追加内容的消息链表,把消息一个个串起来,每个消息都有一个唯一的 ID 和消息内容,消息内容则由多个 field/value 键值对组成。底层使用 Radix Tree 和 listpack 数据结构存储数据。

为了便于理解,我画了一张图,并对 Radix Tree 的存储数据做了下变形,使用列表来体现 Stream 中消息的逻辑有序性。

这张图涉及很多概念,但是你不要慌。我一步步拆开说,最后你再回头看就懂了。

先带你屡下全局思路。

Stream 结构

Streams 结构的源码定义在 stream.h 源码中的 stream 结构体中。

typedef struct stream {
    rax *rax;
    uint64_t length;
    streamID last_id;
    streamID first_id;
    streamID max_deleted_entry_id;
    uint64_t entries_added;
    rax *cgroups;
} stream;

typedef struct streamID {
    uint64_t ms;
    uint64_t seq;
} streamID;

Consumer Group

Consumer Group 由 streamCG 结构体定义,每个 Stream 可以有多个 Consumer Group,一个消费组可以有多个消费者同时对组内消息进行消费。

/* Consumer group. */
typedef struct streamCG {
    streamID last_id;
    long long entries_read;
    rax *pel;
    rax *consumers;
} streamCG;

streamNACK

streamCG -> *pel 对应的 value 是一个 streamNACK 实例,用于抽象消费者已经读取,但是未 ACK 的消息 ID 相关信息。

/* Pending (yet not acknowledged) message in a consumer group. */
typedef struct streamNACK {
    mstime_t delivery_time;
    uint64_t delivery_count;
    streamConsumer *consumer;
} streamNACK;

streamConsumer

Consumer Group 中对 Consumer 的抽象。

/* A specific consumer in a consumer group.  */
typedef struct streamConsumer {
    mstime_t seen_time;
    sds name;
    rax *pel;
} streamConsumer;

最后来一张图,便于你理解。

肖材积:“Redis 你好,Stream 如何结合 Radix Tree 和 listpack 结构来存储消息?为什么不使用散列表来存储,消息 ID 作为散列表的 key,散列表的 value 存储消息键值对内容。’”

在回答之前,先插入几条消息到 Stream,让你对 Stream 消息的存储格式有个大体认知。

该命令的语法如下。

XADD key id field value [field value ...]

Stream 中的每个消息可以包含不同数量的多个键值对,写入消息成功后,我会把消息的 ID 返回给客户端。

执行如下指令把用户购买书籍的下单消息存放到 hotlist:books队列,消息内容主要由 payerID、amount 和 orderID。

> XADD hotlist:books * payerID 1 amount 69.00 orderID 9
1679218539571-0
> XADD hotlist:books * payerID 1 amount 36.00 orderID 15
1679218572182-0
> XADD hotlist:books * payerID 2 amount 99.00 orderID 88
1679218588426-0
> XADD hotlist:books * payerID 3 amount 68.00 orderID 80
1679218604492-0

hotlist:books 是 Stream 的名称,后面的 “*” 表示让 Redis 为插入的消息自动生成一个唯一 ID,你也可以自定义。

消息 ID 由两部分组成。

肖材积:“如何理解 Stream 是一种只执行追加操作(Append only)的数据结构?”

通过将元素 ID 与时间进行关联,并强制要求新元素的 ID 必须大于旧元素的 ID, Redis 从逻辑上将 Stream 变成了一种只执行追加操作(append only)的数据结构。

用户可以确信,新的消息和事件只会出现在已有消息和事件之后,就像现实世界里新事件总是发生在已有事件之后一样,一切都是有序进行的。

❝肖材积:“插入的消息 ID 大部分相同,比如这四条消息的 ID 都是 1679218 前缀。另外,每条消息键值对的键通常都是一样的,比如这四条消息的键都是 payerID、amount 和 orderID。使用散列表存储的话会很多冗余数据,你这么抠门,所以不使用散列表对不对?”

没毛病,小老弟很聪明。为了节省内存,我使用了 Radix Tree 和 listpack。Radix Tree 的 key 存储消息 ID,value 使用 listpack 数据结构存储多个消息, listapck 中的消息 ID 都大于等于 key 存储的消息 ID。

我在前面已经讲过 listpack,这是一个紧凑型列表,非常节省内存。而 Radix Tree 数据结构的最大特点是适合保存具有相同前缀的数据,从而达到节省内存。

到底 Radix Tree 是怎样的数据结构,继续往下看。

Radix Tree

Radix Tree,也被称为 Radix Trie,或者 Compact Prefix Tree),用于高效地存储和查找字符串集合。它将字符串按照前缀拆分成一个个字符,并将每个字符作为一个节点存储在树中。

当插入一个键值对时,Redis 会将键按照字符拆分成一个个字符,并根据字符在 Radix tree 中的位置找到合适的节点,如果该节点不存在,则创建新节点并添加到 Radix tree 中。

当所有字符都添加完毕后,将值对象指针保存到最后一个节点中。当查询一个键时,Redis 按照字符顺序遍历 Radix tree,如果发现某个字符不存在于树中,则键不存在;否则,如果最后一个节点表示一个完整的键,则返回对应的值对象。

如下图展示一个简单的前缀树,将根节点到叶子节点的路径对应字符拼接起来,就得到了两个 key(“他说碉堡了”、“他说碉炸了”)。

你应该发现了,这两个 key 拥有公共前缀(他说碉),前缀树实现了共享使用,这样就可以避免相同字符串重复存储。如果采用散列表的保存方式,那个 key 的相同前缀就会被多次存储,导致内存浪费。

Radix Tree 改进

每个节点只保存一个字符,一是会浪费内存空间,二是在进行查询时,还需要逐一匹配每个节点表示的字符,对查询性能也会造成影响。

所以,Redis 并没有直接使用标准前缀树,而是做了一次变种——Compact Prefix Tree(压缩前缀树)。通俗来说,当多个 key 具有相同的前缀时,那就将相同前缀的字符串合并在一个共享节点中,从而减少存储空间。

如下几个 key(test、toaster、toasting、slow、slowly)在 Radix Tree 上的布局。

由于 Compact Prefix Tree 可以共享相同前缀的节点,所以在存储一组具有相同前缀的键时,Redis 的 Radix tree 比其他数据结构(如哈希表)具有更低的空间消耗和更快的查询速度。

Radix Tree 节点的数据结构由 rax.h文件中的 raxNode 定义。

typedef struct raxNode {
    uint32_t iskey:1;
    uint32_t isnull:1;
    uint32_t iscompr:1;
    uint32_t size:29;
    unsigned char data[];
} raxNode;

Radix Tree 最大的特点就是适合保存具有相同前缀的数据,实现节省内存的目标,以及支持范围查找。而这个就是 Stream 采用 Radix Tree 作为底层数据结构的原因。

关键词:Redis      点击(5)
声明:本站部分内容来自互联网,如有版权侵犯或其他问题请与我们联系,我们将立即删除或处理。
▍相关推荐
更多Redis相关>>>