Redis 8种底层数据结构简介

这篇不介绍Redis的9种数据类型及适用场景,而是为了通过较少的字数快速介绍实现它们的底层数据结构。

SDS

SDS(简单动态字符串)与我们熟知的 C 语言字符串有显著不同。这主要体现在以下几个方面:

1.SDS 不仅可以存储文本数据,还能够保存二进制数据。这是因为 SDS 使用长度属性(len)来判断字符串的结束,而不是依赖于空字符(‘\0’),所以SDS是二进制安全的(字符串本身有’\0’导致截断)。因此,SDS 的 API 在处理其内部 buf[] 数组的数据时,能够以二进制的方式进行处理。这使得 SDS 不仅可以存储文本信息,还能处理图片、音频、视频以及压缩文件等多种二进制数据格式。

2.SDS 获取字符串长度的时间复杂度为 O(1)。与 C 语言字符串不同,后者并不记录自身的长度,因此获取长度的复杂度为 O(n)。而 SDS 结构中通过 len 属性保存字符串长度,使得获取长度的操作可以在常数时间内完成。

3.Redis 中的 SDS API 是安全的,拼接字符串不会导致缓冲区溢出的问题。在拼接字符串之前,SDS 会检查当前空间是否足够。如果空间不足,它会自动扩容,从而有效避免了缓冲区溢出的风险。而C语言字符串不记录长度,每次添加或缩减字符串时需重新分配内存。若未扩展内存,可能导致缓冲区溢出;未释放不再使用的内存,则可能导致内存泄漏。所谓缓冲区溢出就是假如一块连续的空间存放 2个字符串,前面字符串在做拼接操作时,把后面连续的字符串覆盖掉的问题。



链表

当Redis的List元素较少时会使用压缩列表实现,否则会使用(双向)链表。不过在 Redis 3.2 后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。

Redis 的链表(list)结构设计主要由 list 和 listNode 两个部分构成。list 结构体包含:链表头节点 (head)、链表尾节点 (tail)、节点数量 (len)。 listNode 包含前置节点、后置节点和节点值。可自定义的节点值复制函数 (dup)、释放函数 (free) 和比较函数 (match)

优势
高效操作:通过prev和next指针,访问节点的前后节点为 O(1)。
快捷访问:通过head和tail获取链表的头尾节点也为 O(1)。
节点计数:获取链表节点数量的时间复杂度为 O(1)。
灵活性:使用 void* 指针保存节点值,可支持多种类型,且可以自定义函数进行操作。
缺陷
内存不连贯:链表节点的内存分配不连续,导致不易利用 CPU 缓存,相比于数组性能较差。
内存开销:每个节点都需分配内存,造成较高的内存开销。

为了在小数据量场景中节省内存,Redis 3.0 引入了“压缩列表”作为底层实现,但其性能存在问题。随后,Redis 3.2 采用了新的数据结构 quicklist,还在 Redis 5.0 中引入了 listpack,以优化内存布局并结合压缩列表的优势。



压缩列表

压缩列表是Redis为节省内存而设计的一种内存紧凑型数据结构,类似于数组,由连续的内存块组成,各个节点支持针对不同长度的数据进行编码,从而有效减少内存开销(优点)。
当Redis的Hash、List和Zset元素较少时会使用压缩列表实现。在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

主要特点
内存效益:压缩列表使用连续内存块,利用CPU缓存,有效节省内存。

访问复杂度:查找第一个和最后一个元素的复杂度为O(1);查找其他元素的复杂度为O(N),不适合存储过多元素。

结构组成
表头字段:zlbytes:记录列表占用的内存字节数;zltail:记录尾部节点距离起始地址的字节数(偏移量);zllen:包含的节点数量;zlend:标记结束点,固定值0xFF(255)。

查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位, O(1)。而查找其他元素时只能逐个查找,就是 O(N) 了,因此压缩列表不适合保存过多的元素。

节点(entry)结构:prevlen:记录前一个节点的长度,以方便倒序遍历;encoding:记录当前节点数据的类型及长度(字符串或整数);data:实际存储的数据,长度由encoding确定。

插入数据时,会根据数据类型是字符串还是整数,以及数据的大小,使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种不同的空间大小分配的设计思想,正是 为了节省内存而采用的。

连锁更新问题(缺点)

在插入或修改元素时,若空间不足,可能导致内存重新分配,并引发“连锁更新”,即多个节点的prevlen需要重分配空间,影响访问性能。

适用场景
由于存在连锁更新的缺陷,压缩列表适合于节点数量较少、数据较小的场景。超过一定数量和大小的元素时,Redis会选择其他数据结构,例如quicklist(3.2引入)和listpack(5.0引入),来优化内存使用及访问性能。



哈希表与渐进式Rehash

之前说过,Redis的Hash有两种底层实现:压缩列表(最新代码已改为listpack)和哈希表,当元素较多时会使用哈希表,下面介绍哈希表。

Zset也使用到了哈希表,但是主要用于以常数复杂度获取元素权重,而大多数操作则由跳表实现。

数据结构

哈希表由 dictht 结构体定义,包含一个指向哈希表节点的数组(dictEntry **table)、哈希表大小(size)、掩码(sizemask,用于计算索引值)以及已使用节点数量(used)。

哈希表节点 dictEntry 包含键、值和指向下一个节点的指针,以支持链式哈希以解决哈希冲突。

哈希冲突

冲突定义:当多个键经过哈希函数得到相同的哈希值,分配到同一个哈希桶。
解决方法:采用链式哈希,通过next指针将冲突的节点链接起来。

Rehash机制

当哈希表负载因子(load factor)超过一定阈值时,会触发 rehash 操作。Redis 使用两个哈希表(ht[2]),在正常插入过程中数据写入哈希表 1。当需要进行 rehash 时,会:

为哈希表 2 分配更大的空间。
将哈希表 1 中的元素迁移到哈希表 2。
完成后释放哈希表 1,并将哈希表 2 设置为哈希表 1。

为避免在大量数据迁移中造成阻塞,Redis 采用渐进式 rehash。在插入、删除等操作中逐步迁移数据,分摊开销。在此期间,查找操作会在两个哈希表中都进行,确保数据的一致性。

具体来说:
对于查询操作,首先在哈希表1中查找,如果未找到,则继续在哈希表2中查找。
对于删除操作,若在哈希表1中找到相应key,则将其从哈希表1中移除;如果是在哈希表2中找到,则在哈希表2中执行删除操作。
对于新增操作,所有新增加的key-value对都会直接存储在哈希表2中,而哈希表1不再接受新增。
逐步迁移数据:在处理客户端请求(如新增、删除等)时,会顺序将哈希表1中相应的key-value对按需迁移至哈希表2。随着操作的增多,哈希表1中的元素数量将逐渐减少。
完成rehash:经过一段时间的操作和数据逐渐迁移,最终哈希表1会变为空表,rehash过程便完结。
这种渐进式rehash方法将一次性的大规模数据迁移拆分为多个小步骤,有效避免了性能下降的问题。

Rehash触发条件

负载因子:定义为(哈希表已保存节点数量/哈希表大小)。
触发条件:
当负载因子 ≥ 1 且不在执行 RDB 快照或 AOF 重写时,进行rehash。
当负载因子 ≥ 5 时,强制进行rehash。
通过以上设计和机制,Redis的哈希表实现了高效的存储和数据查询,同时通过链式哈希和渐进式rehash确保了在高负载情况下的性能稳定。


整数集合

之前说过,Redis的Set有两种实现(整数集合和哈希表),整数集合主要用于只有少量整数元素的场景。整数集合的结构由一个包含编码方式、元素数量和元素内容的数组组成。

typedef struct intset {
    uint32_t encoding;     // 编码方式 决定了contents数组中元素的类型,可以是int16_t、int32_t或int64_t。
    uint32_t length;       // 元素数量
    int8_t contents[];     // 保存元素的数组 之所以声明为int8_t 是为了兼容不同的整数类型。

} intset;

升级操作

当往整数集合中加入新元素时,如果这个新元素的类型比当前集合中存储的元素类型更长,整数集合会进行升级:
1、扩展contents数组的内存空间;2、将现有元素全部转换为新的类型,并保持有序。
例如,如果当前储存的是int16_t类型的元素,且要添加一个需要int32_t存储的新元素,就会将整个数组扩展为int32_t类型,并转换之前的元素。

优点 节省内存:如果大多数元素都是int16_t类型,整数集合仍然能保持为int16_t类型,而不是统一使用int64_t,避免了内存浪费。

缺点 整数集合不支持降级操作。一旦进行了升级,就无法回退到原来的类型,即使删除了较大的元素,集合仍然保持升级后的状态。

总的来说,Redis的整数集合通过动态调整其内部存储方式,提供了高效且灵活的整数存储解决方案。



跳表

Redis中的跳表(Skip List)是为有序集合(Zset,元素值有权重来排序)提供高效查找的一种数据结构,当Zset中元素较多时会使用跳表。

Zset的结构:

typedef struct zset {
    dict *dict; // 哈希表
    zskiplist *zsl; //跳表 
} zset;

Zset的主要优势在于结合了两种数据结构:跳表和哈希表。在执行数据插入或更新的过程中,在两个表中都操作,从而保证了记录的信息一致。
这种结合使得Redis在进行范围查询(如ZFRESH操作)时既高效又能在常数时间内获取元素权重(如ZSCORE操作),能够实现平均O(logN)的节点查找效率。

跳表基本结构

跳表是在链表的基础上改进而来的,采用多层级的有序链表结构,从而实现更快的查找效率。在跳表中,一个节点可以同时存在多个层次,每一层通过指针连接,允许“跳过”多个节点,这样查询时间显著减少,复杂度降低到 O(logN)。

跳表的节点结构中包含元素值(ele)、用于排序的权重值(score)、后向指针以及指向下一层节点的指针数组和跨度(span)。跨度用于计算节点的排位,即从头节点到该节点的路径上所有层的跨度之和。

跳表中的层数并非固定,而是在节点创建时随机生成。Redis利用随机数生成策略来确定每个节点的层级,最高允许64层。这种设计确保了相邻两层的节点数量理想比率(2:1),从而降低查找复杂度。也避免增删节点时用调整跳表节点以维持比例的方法的话,带来的额外开销。

不是严格维持相邻两层的节点数量比例为 2 : 1 。
当创建新节点时,以 [0, 1] 范围内的随机数来确定其层数。如果生成的随机数小于 0.25(即 25% 的概率),则将节点的层数增加 1 层。该过程会持续进行,直到随机数大于 0.25 为止,从而确定最终的层数。该机制保证了每增加一层的概率不超过 25%,导致层数增加的几率逐渐降低。从而维持相邻两层的节点数量的比例为 2 : 1。

查询过程

查找一个跳表节点时,从头节点的最高层开始,如果当前节点的权重小于目标权重,则继续沿着这一层的指针查找;如果权重相等但节点数据较大,则跳转到下一层。通过这种层层跳跃的方式,快速找到目标节点。

为什么选择跳表而不是平衡树(如红黑树)实现Zset

Redis选择跳表作为Zset的实现主要基于以下几方面的优势:
内存占用:跳表的内存利用更加灵活,平均每个节点占用的指针数(由随机性决定)优于平衡树的固定结构。
范围查找支持:跳表在范围查找操作上更为简单,只需通过指针依次遍历就能得到结果,而平衡树需要复杂的中序遍历。
实现简易性:跳表的插入和删除操作逻辑简单,修改指针即可,而平衡树需要复杂的节点平衡调整。

结合上述优势,Redis的跳表在性能和实现的复杂性之间找到了良好的平衡,为Zset提供了高效的操作能力。



quicklist

Redis 自 3.2 版本起引入了名为 quicklist 的新数据结构,用于高效实现 List 类型(替代了双向链表 和 压缩列表)。quicklist 实际上是 双向链表 和 压缩列表 的结合,旨在充分利用两者的优点,同时避免各自的缺陷。

在这里插入图片描述

结构设计

Quicklist 结构体与传统链表相似,包含链表头、链表尾、总元素个数以及节点个数等信息:

typedef struct quicklist {
    quicklistNode *head;      // quicklist 的链表头
    quicklistNode *tail;      // quicklist 的链表尾
    unsigned long count;       // 总元素个数
    unsigned long len;         // quicklistNodes 个数
    ...
} quicklist;

QuicklistNode 结构体则是构成链表的每个节点,其中包含指向前一个和后一个节点的指针,以及指向压缩列表的指针:

typedef struct quicklistNode {
    struct quicklistNode *prev; // 前一个节点
    struct quicklistNode *next; // 后一个节点
    unsigned char *zl;           // 指向的压缩列表
    unsigned int sz;            // 压缩列表的字节大小
    unsigned int count : 16;    // 压缩列表中的元素个数 
    ....
} quicklistNode;

优势与性能

内存效率:quicklist 通过压缩列表存储数据,从而节省内存。
减少连锁更新风险:通过控制每个 quicklistNode 中压缩列表的大小和元素数量,quicklist 能有效降低“连锁更新”的风险。压缩列表的元素越少或越小,连锁更新的影响就越小,这样能够提升访问性能。

插入操作
在向 quicklist 添加新元素时,系统会首先检查压缩列表是否有足够的空间。如果空间充足,元素将被直接添加到现有的压缩列表中;如果不够,则会创建新的 quicklistNode 来容纳新元素。这种设计确保了高效插入与操作性能。

虽然 quicklist 有效控制了性能与内存开销,但连锁更新的问题并没有被完全解决,依然可能在某些情况下影响性能。因此,在使用 quicklist 时,仍需关注其设计中的潜在限制。



listpack

listpack 是 Redis 在 5.0 版本中引入的一种新型数据结构,旨在替代传统的压缩列表(ziplist)。listpack 的设计核心在于解决压缩列表中存在的连锁更新问题。

背景与问题

虽然快速列表(quicklist)通过控制每个节点的压缩列表大小来尽量减少连锁更新的性能问题。然而,依然难以完全消除这一问题,因为压缩列表中的每个节点都需保存前一个节点的长度字段,导致在插入或删除节点时,必须更新多个节点的信息,从而引发连锁更新。

设计初衷

listpack 的主要特性在于节点不再保存前一个节点的长度,而是仅记录当前节点的长度,这样在对 listpack 进行插入或删除时,不会影响其他节点的长度,从而彻底消除了连锁更新的问题。

在这里插入图片描述

头部:保存 listpack 的总字节数和元素数量。
尾部:标识 listpack 的结束。
节点(listpack entry):每个节点由以下几点构成:
encoding:定义节点数据的编码类型,采用不同的编码方式以节省内存。
data:实际存储的数据内容。
len:节点的总长度,即 encoding 与 data 的组合长度。
这种设计允许 listpack 紧凑地存储数据,同时不再需要额外的字段来维护节点之间的状态,从而优化了内存使用和操作性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值