Redis的五大数据类型的底层实现

本文深入探讨Redis的五大数据类型:字符串、列表、哈希、集合和有序集合的底层实现,包括不同编码方式的选择和转换条件,以及这些选择如何影响性能和内存使用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

简介:
Redis的五大数据类型也称为五大数据对象,前面介绍过6大数据结构,Redis并没有直接使用这些结构来实现键值对数据库,而是使用这些结构构建了一个对象系统redisObject;这个对象系统包含了五大数据对象,字符串对象(string)、列表对象(list)、哈希对象(hash)、集合(set)对象和有序集合对象(zset);而这五大对象的底层数据编码可以用命令OBJECT ENCODING来进行查看。

typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 指向底层实现数据结构的指针
    void *ptr;
    // ...
} robj;

Redis是已键值对存储数据的,所以对象又分为键对象和值对象,即存储一个key-value键值对会创建两个对象,键对象和值对象。
键对象总是一个字符串对象,而值对象可以是五大对象中的一种。
1、type属性存储的是对象的类型,也就是我们说的 string、list、hash、set、zset中的一种,可以使用命令 TYPE key 来查看。
2、encoding属性记录了队形所使用的编码,即这个对象底层使用哪种数据结构实现。
在这里插入图片描述
表中列出了底层编码常量及对应的OBJECT ENCODING 命令的输出,前三项都是字符串结构

我们在存入key-value键值对时并不会指定对象的encoding,而是Redis会根据不统的使用场景来为一个对象设置不同的编码,可以达到节约内存、加快访问速度等目的。

一、字符串对象String

字符串对象底层数据结构实现为简单动态字符串(SDS)和直接存储,但其编码方式可以是int、raw或者embstr,区别在于内存结构的不同。
(1)int编码
字符串保存的是整数值,并且这个整数可以使用long类型来表示,那么其就会保存在redisObject的ptr属性里,并将编码设置为int,如图:
在这里插入图片描述
(2)raw编码
字符串保存的大于32字节的字符串值,则使用简单动态字符串(SDS)结构,并将编码设置为raw,此时内存结构与SDS结构一致,内存分配次数为两次,创建redisObject对象和sdshdr结构,如图
在这里插入图片描述
(3)embstr编码
字符串保存的小于等于32字节的字符串值,使用的也是简单的动态字符串(SDS结构),但是内存结构做了优化,用于保存顿消的字符串;内存分配也只需要一次就可完成,分配一块连续的空间即可,如图
在这里插入图片描述
字符串对象总结:
1、在Redis中,存储long、double类型的浮点数是先转换为字符串再进行存储的。
2、raw与embstr编码效果是相同的,不同在于内存分配与释放,raw两次,embstr一次。
3、embstr内存块连续,能更好的利用缓存在来的优势
4、int编码和embstr编码如果做追加字符串等操作,满足条件下会被转换为raw编码;embstr编码的对象是只读的,一旦修改会先转码到raw。

二、列表对象List

列表对象的编码可以是ziplist和linkedList之一。
(1)ziplist编码
ziplist编码的底层实现是压缩列表,每个压缩列表节点保存了一个列表元素。
在这里插入图片描述

struct ziplist<T> {
  int32 zlbytes;   
  int32 zltail_offset; 
  int16 zllength; 
 T[] entries; 
 int8 zlend; 
}

各部分的含义如下:

**zlbytes:**整个压缩列表占用字节数,包含本身
**zltail_offset:**最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点,从而可以在ziplist尾部快速的执行push,pop操作
**zllength:**元素个数,该字段只有16bit所以可以表达的最大值为2^16-1,
如果ziplist元素超了该值呢?这里规定,如果zllength小于等于 2^16-2,该字段表示为ziplist中元素的个数,否则想知道ziplist长度需要遍历整个ziplist
**entries:**元素内容列表,挨个挨个紧凑存储。
**zlend:**ziplist最后一个字节,标志压缩列表的结束,值恒为 0xFF(255)。
压缩列表为了支持双向遍历,所以才会有 ztail_offset 这个字段,用来快速定位到最后一个元素,然后倒着遍历。我们知道ziplist是一块连续的内存,entry的大小不确定,我们倒着遍历ziplist怎么能找到上个一个节点的开始位置呢?

entry 块随着容纳的元素类型不同,也会有不一样的结构

struct entry {
    int<var> prevlen; # 前一个 entry 的字节长度
    int<var> encoding; # 元素类型编码
    optional byte[] content; # 元素内容
}

它的 prevlen 字段表示前一个 entry 的字节长度,当压缩列表倒着遍历时,需要通过这个字段来快速定位到下一个元素的位置。它是一个变长的整数,当字符串长度小于 254(0xFE) 时,使用一个字节表示;如果达到或超出 254(0xFE) 那就使用 5 个字节来表示。

(2)linkedList编码
linkedlist编码底层采用双端链表实现,每个双端链表节点都保存了一个字符串对象,在每个字符串对象内保存了一个列表元素。
在这里插入图片描述
列表对象编码转换:
1、列表对象使用ziplist编码需要满足两个条件:一是所有字符串长度都小于64字节,二是元素数量小于512,不满足任意一个都会使用linkedlist编码。
2、两个条件的数字可以在Redis的配置文件中修改,list-max-ziplist-value选项和list-max-ziplist-entries选项。
3、图中StringObject就是上一节讲到的字符串对象,字符串对象是唯一个在五大对象中作为嵌套对象使用的。

三、哈希对象Hash

哈希对象的编码可以是ziplist和hashtable之一。
(1)ziplist编码
ziplist编码的哈希对象的底层是压缩列表,在ziplist编码的哈希对象中,key-value键值对是以紧密相连的方式放入压缩列表的,先把key放入表尾,再放入value;键值对总是向表尾添加。
在这里插入图片描述
(2)hashtable编码
hashtable编码的哈希对象底层实现是字典,哈希对象中的每个key-value对都使用一个字典键值对来保存。
字典键值对即是,字典的键和值都是字符串对象,字典的键保存key-value的key,字典的值保存key-value的value。
在这里插入图片描述
哈希对象编码转换:
1、哈希对象使用ziplist编码需要满足两个条件:一是所有键值对的值和键的字符串长度都小于64字节;二是键值对的数量小于512个;不满足任何一个都使用hashtable编码。
2、以上两个条件可以在redis配置文件中修改hash-max-ziplist-value选线和hash-max-ziplist-entrities选项。

四、 集合对象Set

集合对象的编码可以是intset和hashtable之一。
(1)intset编码
intset编码的集合对象底层实现是整数集合,所有元素都保存在整数集合中。
在这里插入图片描述
(2)hashtable编码
hashtable编码集合的底层实现原理是字典表,字典的每个键都是一个字符串对象,保存一个集合元素,不同的是字典的值都是NULL;可以参考java中的hashset结构。
在这里插入图片描述
集合对象编码转换过程:
集合对象使用intset编码需要满足两个条件:一是所有元素都是整数值;二是元素个数小于等于512个;不满足其中任意一个条件都使用hashtable编码。

五、有序集合对象Zset

有序集合的编码可以是ziplist和skiplist之一。
(1)ziplist编码
ziplist编码的有序集合对象底层实现是压缩列表,其结构与hash对象类似,不同的是两个紧密相连的压缩列表节点,第一个保存元素的成员,第二个保存元素的分值,而且分值小的靠近表头,大的靠近表尾。
在这里插入图片描述
(2)skiplist编码
skiplist编码的有序集合对象的底层实现是跳跃表和字典表两种。
每个跳跃表节点都保存一个集合元素,并按分值从小到大排列;节点的Object属性保存了元素的成员,score属性保存分值;
字典的每个键值对保存一个集合元素,字典的键保存元素的成员,字典的值保存分值。
在这里插入图片描述
跳跃表的节点zskiplistNode的定义:

typedef struct zskiplistNode {
    robj *obj;                              /* a */
    double score;                           /* b */
    struct zskiplistNode *backward;         /* c */
    struct zskiplistLevel {                 /* d */
        struct zskiplistNode *forward;     
        unsigned int span;                  
    } level[];
} zskiplistNode;

分析:
1、robj是redisObject的别名,在跳跃表中它的类型是一个sds字符串。
2、score是一个浮点类型的数值,obj和score共同构成了跳跃表元素的排序依据。score为排序的第一关键字,obj为排序的第二关键字(score不同,按照score从小到大排序,score相同,按照obj字符串进行字节排序)。
3、backward是指向跳跃表当前节点的前一个节点的指针。
4、每个跳跃表节点有一个level数组,数组的最大长度为32,数组元素类型为zskiplistLevel。它记录了每个level下当前节点链接到下一个节点的前进指针,以及跨度。
在这里插入图片描述

跳跃表:
跳跃表结点被跳跃表结构 zskiplist 管理,定义在 server.h中:

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;   /* a */
    unsigned long length;                  /* b */
    int level;                             /* c */
} zskiplist;

分析:
1、header指针指向跳跃表表头节点,一旦创建后固定不变,tail指向尾节点(当表尾空时值为NULL)。
2、length记录整个跳跃表的长度,便于在O(1)的时间内获取表长度。
3、level代表跳跃表的最高层数,初始化为1 。

为何skiplist编码要同时使用跳跃表和字典实现?
跳跃表优点是有序,但是查询分值复杂度为O(logn);字典查询分值复杂度为O(1),但是无序,所以结合两个结构的优点进行实现。
虽然采用两个结构但是集合的元素成员和分值是共享的,两种结构通过指针指向同一个地址,不会浪费内存 。
在这里插入图片描述

跳跃表具有以下性质:
1、由很多层结构组成
2、每一层都是一个有序的链表
3、最底层(level1)的链表包含所有的元素
4、如果一个元素出现在leveli的链表中,则它在leveli之下的链表也都会出现。
5、每个节点包含两个指针,一个指向同一链表的下一个元素,一个指向下面一层的元素

跳表的搜索:
在这里插入图片描述
例子:查找元素117
(1)比较21,比21大,往后面找
(2)比较37,比37大,比链表的最大值小,从37的下面一层开始找
(3)比较71,比71大,比链表最大值小,从71的下面一层开始找
(4)比较85,比85大,从后面找
(5)比较117,等于117,找到了节点

具体的搜索算法如下:

/* 如果存在 x, 返回 x 所在的节点, 
 * 否则返回 x 的后继节点 */  
find(x)   
{  
    p = top;  
    while (1) {  
        while (p->next->key < x)  
            p = p->next;  
        if (p->down == NULL)   
            return p->next;  
        p = p->down;  
    }  
}  

计算节点在跳跃表中的排序Rank:

unsigned long zslGetRank(zskiplist *zsl, double score, robj *o) {
    zskiplistNode *x;
    unsigned long rank = 0;
    int i;
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        while (x->level[i].forward &&                                       /* a */
            (x->level[i].forward->score < score ||                          /* b */
                (x->level[i].forward->score == score &&                     
                compareStringObjects(x->level[i].forward->obj,o) <= 0))) {
            rank += x->level[i].span;                                       /* c */
            x = x->level[i].forward;                                        
        } 
        if (x->obj && equalStringObjects(x->obj,o)) {                       /* d */
            return rank;
        }
    }
    return 0;
}

a、从最高层开始枚举,对每一层找到 (score, obj) 的前驱结点;
b、前驱结点的 score 要么小于 当前结点的 score,要么 score 和当前结点相等且 obj 的字典序比 当前结点的obj 小;
c、对跨度进行累加,所有层的前驱结点的跨度之和就是最后要求的 Rank;
d、为了避免找到的 x 是头结点,需要判断 x->obj 不为 NULL;

跳表的插入:
先确定该元素要占据的层数K(采用随机的方式)
然后在level1…levelK各个层的链表都插入元素。
例子:插入119,K=2
在这里插入图片描述
如果K大于链表的层数,则要添加新层。
例子:插入119,K=4
在这里插入图片描述
丢硬币决定K
插入元素的时候,元素所占有的层数完全是随机,通过如下算法实现:

int random_level()  
{  
    K = 1;  
  
    while (random(0,1))  
        K++;  
  
    return K;  
}  

相当与做一次丢硬币的实验,如果遇到正面,继续丢,遇到反面,则停止,
用实验中丢硬币的次数 K 作为元素占有的层数。显然随机变量 K 满足参数为 p = 1/2 的几何分布,
K 的期望值 E[K] = 1/p = 2. 就是说,各个元素的层数,期望值是 2 层。

跳跃表的高度:
n 个元素的跳表,每个元素插入的时候都要做一次实验,用来决定元素占据的层数 K,
跳表的高度等于这 n 次实验中产生的最大 K。

跳跃表的删除:
在各个层中找到包含X的节点,使用标准的delete from list方法删除该节点。
在这里插入图片描述

有序集合编码转换:
1、有序集合对象使用ziplist编码需要满足两个条件:一是所有元素长度小于64字节;而是元素个数小于128个;不满足任意一个条件使用skiplist编码。
2、以上两个条件可以在redis配置文件中修改zset-max-ziplist-entries选项和zset-max-ziplist-value选项。

六、总结

在Redis的五大数据对象中,string对象是唯一个可以被其他四种数据对象作为内嵌对象的;

列表(list)、哈希(hash)、集合(set)、有序集合(zset)底层实现都用到了压缩列表结构,并且使用压缩列表结构的条件都是在元素个数比较少、字节长度较短的情况下;

四种数据对象使用压缩列表的优点:

(1)节约内存,减少内存开销,Redis是内存型数据库,所以一定情况下减少内存开销是非常有必要的。

(2)减少内存碎片,压缩列表的内存块是连续的,并分配内存的次数一次即可。

(3)压缩列表的新增、删除、查找操作的平均时间复杂度是O(N),在N再一定的范围内,这个时间几乎是可以忽略的,并且N的上限值是可以配置的。

(4)四种数据对象都有两种编码结构,灵活性增加。

具体可以查看
Redis底层详解(六) 跳跃表
redis -跳跃表原理
Redis的五大数据类型的底层实现

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值