深入浅出 Redis 底层数据结构


深入浅出 Redis 底层数据结构

1、前置知识

我们知道,Redis 很快,但是 Redis 为啥能这么快呢,首先,因为它是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。另一方面,就要归功于它的数据结构了。Redis 是一个键值对数据库,键值对是按照一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作,所以高效的数据结构是 Redis 快速处理数据的基础。

那 Redis 中的数据结构是啥呢?你可能会说 String(字符串)、List(列表)、Hash(哈希表)、Set(集合)和 Sorted Set(有序集合)。其实这知识 Redis 键值对中值的数据类型,也就是数据的保存形式。而我们说的数据结构是它们的底层实现。

简单来说,底层数据结构一共有6中,在 Redis 3.2版本后,增加了一种,下面我通过图表的方式展示:

image-20220327120509116
数据类型 底层数据结构
String sds
List 3.2之前:ziplist/linkedlist 3.2之后:quicklist
Hash ziplist/hashtable
Set intset/hashtable
Sorted Set ziplist/zskiplist

可以看到,String 类型的底层实现只有一种数据结构,也就是sds(简单动态字符串),而其他的四种数据类型都有两种底层实现结构,通常会把这四种类型称为集合类型,他们的特点是一个键对应一个集合的数据。

这里提一下 Redis 对象,干嘛用的,想象成对象头,不管你什么类型,都必须要带的,里面包含数据类型等等相关信息:由下面代码可以知道,一个 RedisObject 占用的字节数:4 + 4 + 24 + 32 + 64 = 128 位 / 8 = 16 字节。

/*
* Redis 对象
*/
typedef struct redisObject {
    // 类型 4bits,即上面[String,List,Hash,Set,Zset]中的一个
    unsigned type:4;
    // 编码方式 4bits,encoding表示对象底层所使用的编码。
    unsigned encoding:4;
    // LRU 时间(相对于 server.lruclock) 24bits
    unsigned lru:22;
    // 引用计数 Redis里面的数据可以通过引用计数进行共享 32bits
    int refcount;
    // 指向对象的值 64-bit
    void *ptr;
} robj;// 16bytes

下面我们就来具体讲讲这些底层的数据结构。

2、底层数据结构

2、简单动态字符串

SDS(simple dynamic string):简单动态字符串。SDS 中包含了 free(当前可用空间大小),len(当前存储字符串长度),buf[](存储的字符串内容),下面是 SDS 源码:

struct sdshdr{
    //记录buf数组中已使用字节的数量
    //等于 SDS 保存字符串的长度 4byte
    int len;
    //记录 buf 数组中未使用字节的数量 4byte
    int free;
    //字节数组,用于保存字符串 字节\0结尾的字符串占用了1byte
    char buf[];
}

包含了 len、free、buf[] 三个属性,占用的字节数最少是:4 + 4 + 1 = 9 byte。(仅限 Redis3.2 之前,3.2版本之后 SDS 结构发生了变化)

比如你执行 set hello world ,会创建出两个 SDS,一个存 Key:hello,一个存 Value:word,比如如下:

image-20220327120515397

说了这么多,那为什么要用 SDS 呢,Redis 是 C 编写的,为啥没有使用 C 中的字符串呢?

优化获取字符串长度

C 语言要想获取字符串长度必须遍历整个字符串的每一个字符,然后自增做累加,时间复杂度是O(n),SDS 直接维护了一个 len 变量,时间复杂度为 O(1)。

减少内存分配

当我们对一个字符串类型进行追加的时候,可能会发生两种情况:

  • 当剩余空间(free)足够容纳追加内容时,就不需要再去分配内存空间,这样可以减少内存分配次数。
  • 当前剩余空间不足以容纳追加内容,需要重新为其申请内存空间。

而 C 语言字符串在进行字符串的扩充和收缩的时候,都会面临内存空间的重新分配问题,如果忘记分配或者分配大小不合理还会造成数据污染。

那么 SDS 的 free 值哪来的呢?也就是字符串扩容策略。

  • 当给 SDS 的值追加一个字符串,而当前剩余空间不够时,就会触发 SDS 的扩容机制,扩容采用了空间预分配的策略,即分配空间的时候:如果 SDS 值大小 < 1M,则增加一倍;反之如果 > 1M,则当前空间增加 1M 作为新空间。
  • 当 SDS 的字符串缩短了,SDS 的 buf 会多出来一些空间,这个空间并不会马上被回收,而是暂时留着以防再用的时候进行多余的内存分配,这个是惰性空间释放的策略。

惰性释放空间

当我们截断字符串时,Redis 会把截断部分置空,只保留剩余部分,且不立即释放阶段部分的内存空间,这样做的好处就是当下次再对这个字符串追加的时候,如果当前剩余空间足以容纳追加内容时,就不需要再去重新申请空间,避免了频繁的内存申请。暂时用不到的空间可以被 Redis 定时删除或惰性删除。

防止缓冲区溢出

其实和减少内存分配是成套的,都是因为 SDS 会预先检查内存自动分配来做到防止缓冲区溢出的。当需要对一个 SDS 进行修改的时候,Redis 会在执行拼接操作之前,预先检查给定 SDS 空间是否足够,如果不够,会先拓展 SDS 的空间,然后再执行拼接操作。

二进制安全

在 C 语言中通过判断当前字符是否是’\0’来确定字符串是否结束,对于一些二进制文件(如图片等),内容可能包含空字符串,因此无法正确读取。而在 SDS 结构中,只要遍历长度没有达到 len,即使遇到’\0’也不会认为字符串结束,不会发生上述问题。

兼容部分 C 字符串函数

虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用一部分 C 语言库(<string.h>)的函数。

总结:

C 字符串 SDS
获取字符串长度的复杂度为 O(n) 获取字符串长度的复杂度为 O(1)
API 是不安全的,可能造成缓冲区溢出 API 是安全的,不会造成缓冲区溢出
修改字符串长度 N 次必然需要执行 N 次内存重分配 修改字符串长度N次最多执行N次内存重分配
只能保存文本数据,二进制不安全 可以保存二进制数据和文本数据,二进制安全
可以使用所有<string.h> 库中的函数 可以使用一部分 <string.h> 库中的函数

3、双向链表

链表作为一种常用的数据结构,内置在很多高级的编程语言里面了,但是 C 语言中并没有内置这种数据结构,所以 Redis 构建了自己的链表实现。

链表的每个节点使用一个 listNode 结构来表示:

typedef struct listNode {
    //前置节点
    struct listNode * prev;
    //后置节点
    struct listNode * next;
    //节点的值
    void * value;
}listNode;

多个 listNode 可以通过 prev 和 next 指针组成双端链表,如图:

image-20220327120522429

虽然仅仅使用多个 listNode 结构就可以组成链表,但是使用 list 操作起来会更方便:

typedef struct list {
//表头节点
listNode * head;
//表尾节点
listNode * tail;
//链表所包含的节点数量
unsigned long len;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值对比函数
int (*match)(void *ptr,void *key);
} list;

链表的特性总结:

  • 双端链表便于在表的两端进行 push 和 pop 操作,但是它的内存开销比较大。
  • 双端链表每个节点除了要保存数据之外,还额外保存两个指针(pre/next)。
  • 双端链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内粗碎片。

4、压缩列表

ziplist 是一个经过特殊编码的双向链表,它的设计目标就是为了提高存储效率。

ziplist 结构如下:

image-20220327120528007
  • zlbytes:ziplist 的长度,32位无符号整数。
  • zltail:ziplist最后一个节点的偏移量,反向遍历ziplist或者pop尾部节点的时候用来提升性能。
  • zllen:ziplist的entry(节点)个数。
  • entry:节点,并不是一个数组,然后里面存的值是一个数据结构。
  • zlend:值为255,用于标记ziplist的结尾。

entry 的布局:

  • prevlen:记录上一个节点的大小,为了方便反向遍历ziplist。
  • encoding:当前的编码规则,记录了节点的content属性所保存数据类型以及长度。
  • entry-data:保存节点的值,可以是字符串或者数字,值的类型和长度由encoding决定。

当 entry 中存储的是 int 类型的时候,encoding 和 entry-data 会合并在 encoding 中表示,此时没有 entry-data 字段。

prevlen 表示前一个节点的长度,如果这个长度小于254字节,那么它占用1字节;当长度大于或等于254字节时,prevlen 占用5个字节,第一个字节被设置为 0xFF(十进制254),而之后的四个字节用于保存前一个节点的长度。用254不用255作为分界是因为255是zlen的值,它用于判断ziplist是否达到了尾部。

利用此原理即当前节点位置减去上一个节点的长度即可得到上一个节点的其实位置,压缩列表可以从尾部向头部遍历,这么做有效减少了内存的浪费。

image-20220327120533624

压缩列表总结:

  • ziplist 是为了节省内存空间而生的。
  • ziplist 是一个为 Redis 专门提供的底层数据结构之一,本身也可以有序也可以无序。当作为 List 和 Hash 的底层实现时,节点之间没有顺序,当作为 ZSet 的底层实现时,节点之间会按照大小顺序排序。
  • ziplist 的弊端也很明显,对于较多的 entry 或者 entry 长度较大时,需要大量的连续内存,并且节省的空间比例相对不占优势,就可以考虑使用其他结构了。

5、字典(哈希表)

Redis 里面的字典其实就是哈希表。一个哈希表,其实就是一个数组,数组中的每个元素称为一个哈希桶,每个哈希桶中保存了键值对数据。 Redis 中哈希表的定义为:

typedef struct dictht {
    //哈希表数组,数组中的每个元素指向一个 dictEntry
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码, 用于计算索引值,总是等于size-1
    unsigned long sizemask;
    //该哈希表已有节点的数量
    unsigned long used;
} dictht;

下图展示了一个大小为4的有2个节点的哈希表:

image-20220327120539347

哈希表节点使用 dictEntry结构表示,每个 dictEntry 结构都保存这一个键值对:

typedef struct dictEntry {
    //键
    void *key;
    //值,可以是一个指针,或者是uint64_t 整数,或者是int64_t整数
    union{
        void *val;
        uint64_tu64;
        int64_ts64;
    } v;
    //指向下个哈希表节点, 形成链表,以此解决哈希冲突问题
    struct dictEntry *next;
} dictEntry;

Redis 中计算哈希值和索引值的方法如下:

#使用字典设置的哈希函数, 计算键key的哈希值
hash = dict->type->hashFunction(key);
#使用哈希表的sizemask属性和哈希值, 计算出索引值
#根据情况不同, ht[x]可以是ht[0]或者ht[1]
index = hash & dict->ht[x].sizemask;

Redis 中的字典结构如下:

typedef struct dict {
    //类型特定函数
    dictType *type;
    //私有数据
    void *privdata;
    // 一个包含两个项的数组,数组中的每个项都是一个哈希表,一般情况下只使用 ht[0]哈希表
    // ht[1] 哈希表只会在对 ht[0] 哈希表进行rehash时使用。
    dictht ht[2];
    // rehash索引,记录了rehash目前的进度,当rehash不在进行时, 值为-1
    in trehashidx;
} dict;

没有进行 rehash 的字典如下:

image-20220327120545293

当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,这些键就发生了冲突。Redis 的哈希表使用链地址法来解决冲突,如上图所示,k0和k1都被分配到了索引为2的桶中,于是这两个dictEntry就是用next指针链接起来,且是头插法。

当哈希表保存的键值对太多或者太少时,就要通过 rehash(重新散列)来对哈希表进行相应的扩容或者收缩,具体步骤如下:

  • 上面我们说过了一个字段含有两个哈希表,哈希表1和哈希表2,一开始刚插入数据的时候,默认使用哈希表1,此时哈希表2并没有被分配空间,随着数据的增多,Redis 开始执行 rehash。
  • 给哈希表2分配更大的空间,例如是当前哈希表1大小的2倍。
  • 把哈希表1中的数据重新映射并拷贝到哈希表2中。
  • 释放哈希表1的空间。

到此,我们就可以从哈希表1切换到哈希表2,用增大的哈希表2保存更多的数据,而原来的哈希表1留作下次rehash扩容备用。

这个过程看是简单,但是第二部涉及大量的数据拷贝,如果一次性把哈希表1中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时 Redis 就无法快速访问数据了。

为了避免这个问题,Redis 采用了渐进式 rehash。

简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户请求,每处理一个请求时,从哈希表1中的第一个索引位置开始,顺带着将这个索引位置上的所有 dictEntry 拷贝到哈希表2中;等处理下一个请求时,再顺带拷贝哈希表1中的下一个索引位置的 entries。

过程如下所示:

image-20220327120551610

这样就巧妙地把一次性大量拷贝的开销,分摊到多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。

6、整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合值包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。

源码结构:

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

contents 数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按照值的大小从小到大有序的排列,并且数组中不包含任何重复项。

length 属性记录了整数集合包含的元素数量,也即是 contents 数组的长度。

虽然insert 结构将contents属性声明为 int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于 encoding 属性的值。

encoding表示编码方式,取值有三个:INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64

分别表示contents数组里的每个项都是一个int16_t类型、int32_t类型,int64_t类型的整数值。

下面展示了一个整数集合示例:

image-20220327120606142

整数集合的升级:当在一个 int16 类型的整数集合中插入一个 int32 类型的值,整个集合的所有元素都会转换成 32 类型,整个过程有三步:

  • 根据新元素的类型(比如 int32),扩展整个集合底层数组的空间大小,并为新元素分配空间。
  • 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
  • 最后改变 encoding 的值,length + 1。

整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。比如我们删除刚刚加入的 int32 类型的整数时,不会做降级操作,主要是为了减少开销的权衡。

7、跳表

跳表(zskiplist)是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而快速访问节点的目的。使普通链表的查找时间复杂度由O(n) 变为平均O(logN),最坏O(n)。

Redis 中只有两个地方用到了跳表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。

跳表在 Redis 中的实现:由 zskiplistNode 和 zskiplist 两个结构实现。

zskiplistNode 表示跳表的节点,而zskiplist 结构用于保存跳表节点的相关信息。

zskiplistNode 的源码如下:

typedef struct zskiplistNode {
    //层,用于标记节点的各个层
    struct zskiplistLevel {
        //前进指针:用于访问位于表尾方向的其他节点
        struct zskiplistNode *forward;
        //跨度:记录了前进指针所指向节点和当前节点的距离
        unsigned int span;
    } level[];
    //后退指针,指向位于当前节点的前一个节点
    struct zskiplistNode *backward;
    //分值,节点按各自的分值从小到大排列,当分值相同时,节点按照成员对象的大小进行排序
    double score;
    //成员对象:是一个指针,指向一个字符串对象,而字符串对象则保存着一个 SDS 值
    robj *obj;
} zskiplistNode;

zskiplist 的源码如下:

typedef struct zskiplist {
    //表头节点和表尾节点
    structz skiplistNode *header, *tail;
    //表中节点的数量
    unsigned long length;
    //表中层数最大的节点的层数,不包括表头节点
    int level;
} zskiplist;

整体的查找过程如下:

image-20220327120615792

为什么 Redis 使用跳表也不是红黑树来实现有序集合呢?

首选 Redis 中的有序集合支持的操作:

  • 插入一个元素
  • 删除一个元素
  • 查找一个元素
  • 有序输出所有元素
  • 按照范围区间查找元素

其中,前四个操作红黑树也可以完成,且时间复杂度和跳表是一样的,但是按照区间来查找数据这个操作,红黑树的效率没有跳表高。按照区间查找数据时,跳表可以做到O(logn)的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了,非常高效。

8、快表

快表(quicklist)这个结构是Redis3.2版本后新加的,作为 List 数据类型的底层实现。

它是一种以 ziplist 为节点的双端链表结构,也就是说一个 quicklist 由多个 quicklistNode 组成,每个 quicklistNode 指向一个 ziplist,一个 ziplist 包含多个 entry,每个 entry 元素就是我们 push 的 list 的元素。ziplist 本身也是一个能维持数据项先后顺序的列表,而且数据项中保存在一个连续的内存块中。意味着 quicklist 结合了 ziplist 和 linkedlist 的特点,更为优化了,还省去了 ziplsit 和 linkedlist 之间的转换步骤了。

image-20220327120630933

具体的源代码我就不贴了。

quicklist 的特点:

  • 空间换时间,之前linkedlist 需要两个指针,浪费空间,现在不用 linkedlist,采用ziplist,然后上面封装一层quicklistnode,底层存储还是 ziplist,只是空间上多了一层指针用于检索。
  • 结合了双端链表和压缩列表的优点。

9、总结

各个数据结构的时间复杂度:

名称 时间复杂度
哈希表 O(1)
跳表 O(logn)
双向链表 O(n)
压缩列表 O(n)
整数集合 O(n)

3、对象

上面我们详解了 Redis 底层数据结构的知识,下面我们再来看看 Redis 中的数据类型都是由哪些底层数据结构组成的呢?

1、字符串对象

字符串是 Redis 最基本的数据类型,不仅所有 key 都是字符串类型,其他几种数据结构组成的元素也是字符串,且字符串的长度不能超过 512 M。

字符串对象的编码可以是 int、raw、embstr。

int:如果一个字符串内容可转为 long,那么该字符串会被转化为long 类型,redisObject 的对象 ptr 指向该 long ,并将 encoding 设置为 int,那样就不需要重新开辟空间,算是长整形的一个优化。

embstr:当字符串长度特别短(Redis3.2 之前是39字节,Redis3.2 之后是44字节)的时候,Redis 使用 embstr 来存储字符串。

raw:当字符串长度超过39字节(Redis 3.2 之后是44字节)的时候,就需要用 raw 来存储。

下面是 embstr 和 raw 的结构:

image-20220327120641245

embstr 的存储方式是将 RedisObject 对象头和 SDS 结构放在内存中连续的空间位置,也就是使用 molloc 方法一次分配,而 raw 需要两次 malloc,分别分配对象头和 SDS 的空间。释放空间也一样,embstr 释放一次,raw 释放两次,所以 embstr 是一种优化。但为什么是 39 字节才采取 embstr 呢?

原因:对象头占 16 字节,空的 sdshdr 占用9字节,也就是一个数据至少占用 16 + 9 = 25 字节。

其次操作系统使用 jmalloc 和 tmalloc 进行内存分配,而内存分配的单位都是2的N次方,所以是2,4,8,16, 32, 64 等字节,但是 Redis 如果采用32的话,那么32 - 25 = 7 字节,太少了,所以 Redis 采取的是64字节,所以:64 - 25 = 39 字节。

仅限在 Redis3.2版本之前,3.2版本之后 sds 的结构发生了变化,最小的是 sdshdr5,空的话占用3字节+1个空白=4字节,16 + 4 = 20,64 - 20 = 44 字节。

2、列表对象

List 列表,它是简单的字符串列表,按照插入的顺序排序,可以添加一个元素到列表的头部或尾部,底层的链表结构。

列表对象的编码可以是ziplist或者linkedlist。但是在 Redis 3.2 版本之后,列表对象的编码方式变成了 quicklist。

编码转换:

Redis 的 List 类型什么时候使用 ziplist 编码,什么时候又会使用 linkedlist 编码呢?

当列表对象可以同时满足下列两个条件时,列表对象采用 ziplist 编码,否则采用 linkedlist 编码。

  • 列表对象保存的所有字符串元素的长度都小于 64 字节。
  • 列表元素对象保存的元素数量小于 512 字节。

上述像个参数可以更改配置进行自定义。

3、哈希对象

哈希对象的键是一个字符串类型,值是一个键值对集合。哈希对象的编码可以是 ziplist 或 hashtabl。

ziplist 编码的哈希对象使用压缩列表作为底层实现,每当有新的键值对要加入哈希对象时,会先将保存了键的压缩列表节点推入到压缩列表的表尾,然后在将保存了值的压缩列表节点推入到压缩列表表尾,这样保存了同一键值对的两个节点总是紧紧挨在一起的,保存键的节点在前,保存值的节点在后。

举个例子:

redis> HSET profile name "Tom"
(integer) 1
redis> HSET profile age 25
(integer) 1
redis> HSET profile career "Programmer"
(integer) 1

如果 profile 键的值对象使用的是 ziplist 编码,那么这个对象如图所示:

image-20220327120647204

hashtable 编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存。

要是上面的例子使用 hashtable 编码如下:

image-20220327120652041

编码转换:

当哈希对象可以同时满足以下两个条件时,哈希对象使用ziplist编码:

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节。
  • 哈希对象保存的键值对数量小于512个;

不能满足这两个条件的哈希对象需要使用hashtable编码。

注意:上述两个参数可以更改配置进行自定义。

4、集合对象

集合中的元素是无序的,不能通过索引来操作元素,且不能含有重复元素。

集合对象的编码可以是 inset 或 hashtable。

insert 编码的集合对象使用整数集合中卫底层实现,集合对象包含的所有元素都被保存在整数集合里面。

举个例子:

redis> SADD numbers 1 3 5
(integer) 3

结构图如下:

image-20220327120657455

hashtable 编码的集合对象使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值全部被设置为 NULL。

举个例子:

redis> SAD Dfruits "apple" "banana" "cherry"
(integer)3

结构图如下:

image-20220327120702329

编码转换:

当集合对象可以同时满足以下两个条件时,对象使用insert编码:

  • 集合对象保存的所有元素都是整数值。
  • 集合对象保存的元素数量不超过512个。

不能满足这两个条件的集合对象需要使用 hashtable 编码。

第二个条件的上限值是可以修改的。

5、有序集合对象

有序集合对象是有序的。与列表使用索引下标作为排序依据不同,有序集合为每个元素设置一个分数(score)作为排序依据。

有序集合的编码可以是 ziplist 或者skiplist。

ziplist 编码的压缩列表对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)。

压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。

举个例子:

redis> ZADD price 8.5 apple 5.0 banana 6.0 cherry
(integer) 3

结构如下所示:

image-20220327120708893

skiplist 编码的有序集合对象使用 zset 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表:

typedef struct zset {
    zskiplist *zsl;
    dict *dict;
} zset;

zset 结构中的 zskiplist 跳表按分值从小到大保存了所有集合元素,每个跳表都保存了一个集合元素:跳表节点的 object 属性保存了元素的成员,而跳表节点的 score 属性则保存了元素的分值。通过这个跳表,程序可以对有序集合进行范围操作。

zset 结构中的 dict 字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定成员的分值,ZSCORE 命令就是根据这个特性实现的。

值得注意的是:虽然 zset 结构使用跳表和字典来保存有序集合元素,但是这两种数据结构都会通过指针来共享相同元素的成员和分值,所以不会产生任何重复成员或者分值,不会造成内存浪费。

为什么同时使用跳表和字典来实现?

理论上可以单独使用一种数据结构来实现,但是无论是单独使用哪种,在性能上对比起同时使用都会降低。

结构如下:

image-20220327120714826

编码转换:

当有序集合对象可以同时满足以下两个条件时,对象使用 ziplist编码:

  • 有序集合保存的元素数量少于 128个。
  • 有序集合保存的所有元素成员的长度都小于 64 字节。

不能满足以上两个条件的有序集合对象将使用 skiplist 编码。

注意:两个条件的上限值是可以修改的。

到这里,我们的 Redis 的底层数据结构就讲解完了。

巨人的肩膀:

Redis 设计与实现

极客时间 Redis 核心技术原理与实战


文章作者: Gtwff
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Gtwff !
  目录