上一篇介绍了 Redis 是如何存储和索引数据的,这一篇将介绍 Redis 定义的八大数据结构,这些高效的数据结构,不仅帮助 Redis 实现了高性能,更能够应用于各种场景。
八大数据结构如下:SDS、双向链表、压缩列表、哈希表、整数集合、跳表、quicklist、listpack。
simple dynamic string,简单动态字符串。
Redis 是用 C 语言实现的,但是它没有直接使用 C 语言的 char* 字符数组来实现字符串,而是自己封装了一个名为 SDS 的数据结构来表示字符串,主要是因为 C 语言提供的字符串实现方式有部分缺陷。
C 语言的字符串其实就是一个字符数组,即数组中每个元素是字符串中的一个字符。最后一个字符是“\0”,表示字符串的结束。
缺陷:
SDS 结构中的每个成员变量如下:
flags 表示的是 SDS 类型,不同类型的区别在于,它们数据结构中的 len 和 alloc 成员变量的数据类型不同。之所以设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间。
除了设计不同类型的结构体,Redis 在编程上还使用了专门的编译优化来节省内存空间,即在 struct 声明了
__attribute__ ((packed))
,它的作用是:告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐。默认情况下,编译器是按照字节对齐的方式给变量分配内存的,比如一个 char 类型变量和一个 int 类型变量,会被分配到 8 个字节(int 占四个字节,char 占一个字节但是会被分配到四个字节)。
SDS 相比于 C 的原生字符串:
SDS
使用 len
属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[]
数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。len
属性记录了字符串长度,所以复杂度为 O(1)
。在修改字符串的时候,可以通过 alloc - len
计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小。就不会出现前面所说的缓冲区溢出的问题。
当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小:
在扩容 SDS 空间之前,SDS API 会优先检查未使用空间是否足够,如果不够的话,API 不仅会为 SDS 分配修改所必须要的空间,还会给 SDS 分配额外的「未使用空间」,从而减少内存分配次数。
C 语言本身没有链表这个数据结构的,所以 Redis 自己设计了一个链表数据结构。
typedef struct list {
//链表头节点
listNode *head;
//链表尾节点
listNode *tail;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值比较函数
int (*match)(void *ptr, void *key);
//链表节点数量
unsigned long len;
} list;
链表的缺陷:
因此,Redis 3.0 的 List 对象在数据量比较少的情况下,会采用「压缩列表」作为底层数据结构的实现。
ziplist,压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。
压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组,可以节省指针使用的空间。
压缩列表在表头有三个字段,在表尾有一个字段:
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。
压缩列表节点包含三部分内容:
encoding
决定;当我们往压缩列表中插入数据时,压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的。
分别说下,prevlen 和 encoding 是如何根据数据的大小和类型来进行不同的空间大小分配。
prevlen 属性的空间大小跟前一个节点长度值有关,比如:
encoding 属性的空间大小跟数据是字符串还是整数,以及字符串的长度有关:
压缩列表的缺陷:
压缩列表新增某个元素或修改某个元素时,如果空间不不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
这种在特殊情况下产生的连续多次空间扩展操作就叫做「连锁更新」。
哈希表是一种保存键值对(key-value)的数据结构。
哈希表的结构在上一节中已经讲过了,这里就再简单提一提。
哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。同一个哈希槽中的节点会通过指针链接成一个链表,当哈希冲突发生时,会将节点添加到链表后面。
当一个键值对的键经过哈希函数计算后得到哈希值,再将(哈希值 % 哈希表大小)取模计算,得到的结果值就是该 key-value 对应的数组元素位置。当有两个以上数量的 kay 被分配到了哈希表中同一个哈希桶上时,此时称这些 key 发生了哈希冲突。
在哈希表大小固定的情况下,随着数据不断增多,哈希冲突的可能性会越高。带来的影响是查找的速度会越来越慢(因为每次都要遍历一个长链表)。
Redis 采用了「链式哈希」来解决哈希冲突。
每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来。
不过,链式哈希局限性也很明显,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加。
Redis 通过 rehash 策略来解决哈希冲突导致性能降低的问题。
在 dict 结构体里定义了两个哈希表(ht[2]),在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。
随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:
rehash 存在问题:如果哈希表的数据量非常大,那么在迁移的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。
rehash 的触发条件跟**负载因子(load factor)**有关系。负载因子 = 哈希表已保存节点数量 / 哈希表大小。
当负载因子大于 1,则说明一定出现了 hash 冲突,负载因子越大冲突的键值对越多。
触发 rehash 操作的条件,主要有两个:
为了避免 rehash 在数据迁移过程中,因拷贝大量数据而影响 Redis 性能,Redis 采用了渐进式 rehash,也就是将数据的迁移的工作分多次进行。
渐进式 rehash 步骤如下:
整数集合用于保存整数型元素。
整数集合本质上是一块连续内存空间,可以节省指针使用的空间,它的结构定义如下:
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
可以看到,保存元素的容器是一个 contents 数组,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组的真正类型取决于 encoding 属性的值。比如:
不同类型的 contents 数组,意味着数组的大小也会不同。
整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(如 int32_t)比整数集合现有所有元素的类型(如 int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。
整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后再将每个元素按间隔类型大小分割。
整数集合升级可以节省内存资源,因为会尽量使用较小的整数集合来存储数据,在必要时再进行升级。
整数集合不支持降级操作,且内部仍然存在部分资源浪费,比如部分元素原本可以用更小的类型进行存储,但是都需要采用最大元素类型进行存储。
跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。跳表的查找复杂度可以达到 O(logN)。缺点是需要的存储空间比较大。
// 跳表
typedef struct zskiplist {
//头尾节点;
struct zskiplistNode *header, *tail;
//长度;
unsigned long length;
//最大层数;
int level;
} zskiplist;
// 跳表节点
typedef struct zskiplistNode {
//Sorted Set 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
跨度是为了计算这个节点在跳表中的排位。因为跳表中的节点都是按序排列的,那么计算某个节点排位的时候,从头节点点到该结点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。
跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断:
如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。
跳表的相邻两层的节点数量的比例会影响跳表的查询性能。
跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)。
但是在新增或者删除节点时,如果调整跳表节点以维持比例的话,会带来额外的开销。
Redis 则采用一种巧妙的方法是,跳表在创建节点的时候,随机生成每个节点的层数,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。
具体的做法是,跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于 25% 的概率,可以修改),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。得出层数后,再链入到相应的位置。
这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。
Sorted Set 的实现用跳表而不用平衡树(如 AVL树、红黑树等)?
- 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p (0.25,即增加一层的概率)的大小。如果像 Redis 里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
- 在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
- 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。
quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个双向链表,而链表中的每个元素又是一个压缩列表。
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
之前提到压缩列表存在连锁更新的问题,quicklist 解决办法是,通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。
但是这并没有完全解决连锁更新的问题。
Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。这样就从根源上解决了连锁更新问题。
listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。
每个节点包含三个内容:
listpack 没有压缩列表中记录前一个节点长度的字段了,只记录当前节点的长度。当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。
新版本的 Redis 中,已经将使用到压缩列表的地方替换为 listpack 了。
本文介绍了 Redis 的八大数据结构。下一节将介绍在这些数据结构基础上实现的九大数据类型。