这篇不介绍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 紧凑地存储数据,同时不再需要额外的字段来维护节点之间的状态,从而优化了内存使用和操作性能。