1

1.Redis的基础类型dictEntry和redisObject

2.程序员使用redis时的底层思维

3.String底层数据结构

4.Hash数据结构介绍

5.List数据结构介绍

6.Set数据结构介绍

7.ZSet数据结构介绍

8.总结

1.Redis的基础类型dictEntry和redisObject

我们可以先去redis的github上下载源码:https://github.com/redis/redis

就像我们的JAVA对象,顶层全是Object一样,我们的redis的顶层都是dictEntry,让我们来看这样一段源码(dict.h中):

typedef struct dictEntry {
    void *key;  //表示字符串 就是redis KV结构中的KEY
    union {
        void *val;   //val指向的是redisObject中
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;     /* Next entry in the same hash bucket. */
    void *metadata[];           /* An arbitrary number of bytes (starting at a
                                 * pointer-aligned address) of size as returned
                                 * by dictType's dictEntryMetadataBytes(). */
} dictEntry;

我们以最简单的set k1 v1 为例,因为Redis是以KV为结构的数据库,每个键值对都会有一个dictEntry, 这里面指向了key和value的指针,next指向下一个dictEntry。
key是字符串,但是Redis没有直接使用C的char数组,而是存在了redis的自定义字符串中(等等下面会解释),value因为会有不同的类型,redis将这几种基本的类型抽象成了redisObject中,实际上五种常用的数据类型,都是通过redisObject来存储的。
我们看看redisObject的源码(server.h):

typedef struct redisObject {
    unsigned type:4;       //当前对象的类型
    unsigned encoding:4;   //当前对象的底层编码类型
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or  LRU或者LFU的访问时间数据
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;           //对象引用计数的次数
    void *ptr;              //真正指向底层数据结构的指针
} robj;

接下来我们便可以获得这张图片,我们对redis的存储结构就一目了然了:

image.png

为了便于操作,Redis采用redisObject结构来抽象了不同的数据类型,这样所有的数据类型就可以用相同的形式在函数之间传递,而不是使用特定的类型结构。同时,为了识别不同的数据类型,
redisObject中定义了typeencoding字段对不同的数据类型加以区分。简单地说,redisObject就是String,hash,list,set,zset的父类,可以在函数间传递的时候隐藏具体的基本类型信息,所以作者抽象了redisObject。

2.程序员使用redis时的底层思维
我们刚开始学习redis的时候,只会调用调用顶层的api,所以我们看到的redis是这个样子的:

image.png

但是我们学习了redis的的底层数据结构后,我们将会看到这样子的redis:

image.png

3.String底层数据结构
Redis的String类型,其实底层是由三种数据结构组成的
1)int: 整数且小于二十位整数以下的数字数据才会使用这个类型
image.png
2)embstr (embedded string,表示嵌入式的String):代表embstr格式的SDS(Simple Dynamic String 简单动态字符串),保存长度小于44字节的字符串。
3)raw :保存长度大于44的字符串

我们先对上面这个案例做一个测试:
image.png
我们发现保存的数据内容,会随着保存内容的变化而发生变化。

这就是redis中,String类型没有直接复用C语言的字符串,而是新建了属于自己的结构————SDS(简单动态字符串)。在Redis数据库里,包含字符串的键值对都是由SDS实现的,Redis中所有的值对象包含的字符串对象底层也是由SDS实现

我们点开sds.h,发现sds由多种类型构成:

struct __attribute__ ((__packed__)) sdshdr5 { //被废弃
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */                                       //已用长度
    uint8_t alloc; /* excluding the header and null terminator */ //字符串最大字节长度
    unsigned char flags; /* 3 lsb of type, 5 unused bits */       //用来展示的sds类型
    char buf[];                                                   //真正有效的字符串数据,长度由alloc控制
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

它用
sdshdr5、(2^5=32byte)
sdshdr8、(2 ^ 8=256byte)
sdshdr16、(2 ^ 16=65536byte=64KB)
sdshdr32、 (2 ^ 32byte=4GB)
sdshdr64,2的64次方byte=17179869184G

来存储不同长度的字符串,len表示长度,这样获取字符串长度就可以在O(1)的情况下,拿到字符串,而不是像C语言一样去遍历。
alloc可以计算字符串未被分配的空间,有了这个值就可以引入预分配空间的算法了,而不用去考虑内存分配的问题。
buf 表示字符串数组,真存数据的。

为什么Redis要重新设计一个字符串SDS,而不直接使用char[]数组呢?

我们可以从redis和sds的对比中可以发现:

C语言SDS
字符串长度处理要从数组的头部开始遍历,直到遇到'\0',时间复杂度O(n)直接读取长度,时间复杂度O(1)
内存重新分配内存超出分配的空间后,会导致下标越界或者溢出预分配: SDS分配后,如果长度小于1M,那么会额外分配一个与len相同长度的未使用的空间。 惰性释放:SDS短时并不会收回多余的空间,而是将多余的空间记录下来,如果有变更操作,直接使用多余的空间,减少分配频率。
二进制安全数据内容可能包含'\0',C语言遇上'\0'会提前结束,不会读取后面的内容有了len作为长度,就不会有遍历这种问题了

结论:
只有整数才会使用int,如果是浮点数,就是用字符串保存。
embstr 与 raw 类型底层的数据结构其实都是 SDS (简单动态字符串,Redis 内部定义 sdshdr5, sdshdr8等等)。

存储结构:
1)int
当数据类型为整数的时候,RedisObject中的prt指针直接赋值为整数数据,不会额外指向整数了,节省内存开销。redis在一万以内只会存一份,就像JAVA的Integer -128~127只存一份。

image.png

2) embstr
当保存的字符串数据小于等于44字节的时候,embstr类型将会调用内存分配函数,只分配一块连续的空间,空间中一次包含redisObject和sdshdr两个结构,让元数据,指针和sds是一块连续的区域,避免内存碎片。
image.png
3) raw
字符串大于44字节时,SDS的数据量变多变大了,SDS和RedisObject布局会分开,会给SDS分配多的空间并用指针指向SDS结构,raw 类型将会调用两次内存分配函数,分配两块内存空间,一块用于包含 redisObject结构,而另一块用于包含 sdshdr 结构。
image.png

分配流程图:
image.png

4.Hash数据结构介绍
在查看Hash的数据结构之前,我们先来看这样的一个配置:

image.png

我们可能看不太懂,我来给解释一下:

hash-max-ziplist-entries:使用压缩列表保存哈希集合中的最大元素个数。

hash-max-ziplist-value:使用压缩列表保存时哈希集合中单个元素的最大长度。

可能我们这么说还是不太懂,上案例:

image.png

Hash数据类型也和String有相似之处,到达了一定的阈值之后就会对数据结构进行升级。

数据结构:
1)hashtable 就是和java当中使用的hashtable一样,是一个数组+链表的结构

2)ziplist 压缩链表

我们先来看一下 压缩链表的源码:

image.png
ziplist是一种比较紧凑的编码格式,设计思路是用时间换取空间,因此ziplist适用于字段个数少,且字段值也较小的场景。压缩列表内存利用率高的原因与其连续性内存特性是分不开的。

当一个hash对象,只包含少量的键,且每个键值对的值都是小数据,那么ziplist就适合做为底层实现。

ziplist的结构:

它是一个经过特殊编码的双向链表,它虽然是双向链表,但它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点的长度和当前节点的长度,通过牺牲部分读写性能,来换取高空间利用率。

image.png

zlbytes 4字节,记录整个压缩列表占用的内存字节数。
zltail 4字节,记录压缩列表表尾节点的位置。
zllen 2字节,记录压缩列表节点个数。
zlentry 列表节点,长度不定,由内容决定。
zlend 1字节,0xFF 标记压缩的结束。

节点的结构源码:

typedef struct zlentry {
    unsigned int prevrawlensize; //上一个节点的长度 
    unsigned int prevrawlen;     //存储上一个链表节点长度所需要的的字节数                  
    unsigned int lensize;        //当前节点所需要的的字节数                 
                                                      
                                                       
    unsigned int len;            //当前节点占用的长度  
                                                                                                    
                                                      
    unsigned int headersize;     //当前节点的头大小   
    unsigned char encoding;      //编码方式            
                                                      
    unsigned char *p;            //指向当前节点起始位置                  
                                    

因为保存了这个结构,可以让ziplist从后往前遍历。

为什么有链表了,redis还要整出一个压缩链表?
1)普通的双向链表会有两个前后指针,在存储数据很小的情况下,我们存储的实际数据大小可能还没有指针占用的内存大。而ziplist是一个特殊的双向链表,并没有维护前后指针这两个字段,而是存储上一个entry的长度和当前entry的长度,通过长度推算下一个元素在什么地方。牺牲读取的性能,获取高效的空间利用率,因为(简短KV键值对)存储指针比存储entry长度更费内存,这是典型的时间换空间。

2)链表是不连续的,遍历比较慢,而ziplist却可以解决这个问题,ziplist将一些必要的偏移量信息都记录在了每一个节点里,使之能跳到上一个节点或者尾节点节点。

3)头节点里有头结点同时还有一个参数len,和SDS类型类似,这就是用来记录链表长度的。因此获取链表长度时不再遍历整个链表,直接拿到len值就可以了,获取长度的时间复杂度是O(1)。

遍历过程:
通过指向表尾节点的位置指针zltail,减去节点的previous_entry_length,得到前一个节点的起始地址的指针。如此循环,从表尾节点遍历到表头节点

5.List数据结构介绍

我们先来看一下list的默认配置:
image.png

ziplist压缩配置 :list-compress-depth 0
表示一个quicklist两端不被压缩的节点个数,这里的quicklist(下文会解释)是指quickList双向链表的节点,而不是指ziplist里面的数据项个数。

参数取值含义:
0:是个特殊值,表示都不压缩,这是redis的默认值。
1: 表示quicklist两端各有1个节点不压缩,中间的节点压缩。
2: 表示quicklist两端各有2个节点不压缩,中间的节点压缩。
3: 表示quicklist两端各有3个节点不压缩,中间的节点压缩。
依此类推…

(2) ziplist中entry配置:list-max-ziplist-size -2

当取正值的时候,表示按照数据项个数来限定每个quicklist节点上的ziplist长度。比如当这个配置为5的时候,ziplist里最多有5个数据项。

当取负值的时候,表示按照占用字节数来限定quicklist节点上的ziplist长度。这时,它只能取-1~-5这几个值。

-5: 每个quicklist节点上的ziplist大小不能超过64 Kb。(注:1kb => 1024 bytes)
-4: 每个quicklist节点上的ziplist大小不能超过32 Kb。
-3: 每个quicklist节点上的ziplist大小不能超过16 Kb。
-2: 每个quicklist节点上的ziplist大小不能超过8 Kb。(-2是Redis给出的默认值)
-1: 每个quicklist节点上的ziplist大小不能超过4 Kb。

quicklist结构介绍:
list用quicklist来存储,quicklist存储了一个双向链表,每一个节点都是一个ziplist。

image.png

image.png

image.png]

源码:

typedef struct quicklist {
    quicklistNode *head;        //指向双向列表的表头
    quicklistNode *tail;        //指向双向链表的表尾
    unsigned long count;        //所有ziplist中共存储了多少个元素 /* total count of all entries in all listpacks */
    unsigned long len;          //双向链表的长度,node的数量 /* number of quicklistNodes */
    signed int fill : QL_FILL_BITS;       /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS;  //压缩深度 /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;
typedef struct quicklistNode {
    struct quicklistNode *prev; //前指针
    struct quicklistNode *next; //后指针
    unsigned char *entry; 指向实际的ziplist
    size_t sz;             /* entry size in bytes */
    unsigned int count : 16;     /* count of items in listpack */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* PLAIN==1 or PACKED==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

6.Set数据结构介绍

我们来看一下set的配置:

image.png

set-max-intset-entries
set数据类型集合中,如果没有超出了这个数量,且数据元素都是整数的话,类型为intset,否则为hashtable

image.png

intset类型:

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

看到源码后,我们可以得出,结论,intset的数据结构本质是一个数组。而且**存储数据的时候是有序的,因为在查找数据的时候是通过二分查找来实现的。
**
image.png

我们稍微分析一下set的单个元素的添加流程。
如果set已经是hashtable的编码,那么走hashtable的添加流程。
如果原来是intset:
1)能转化为int对象,就用intset保存。
2)如果长度超过设置,就用hashtable保存
3)其它情况统一用hashtable保存。

7.ZSet数据结构介绍

我们看一下ZSet的配置:

image.png
当有序集合中包含的元素数量超过服务器属性 zset_max_ziplist_entries 的值(默认值为 128 ),
或者有序集合中新添加元素的 member 的长度大于服务器属性zset_max_ziplist_value 的值(默认值为 64 )时,redis会使用跳跃表(下文会解释)作为有序集合的底层实现

否则会使用ziplist作为有序集合的底层实现

看一下源码:

image.png
image.png

当元素个数大于设置的个数或者元素的列表本来就是skiplist编码的时候,用skiplist存储,否则就用ziplist存储。

skiplist(跳表)是什么:
跳表是一种以空间换取时间的结构。

由于链表是无法进行二分查找的,因此借鉴了数据库索引的思想,提取出链表中关键节点(索引),现在关键节点上进行查找,再进入链表进行查找。

提取了很多关键节点,就形成了跳表。

image.png

因为跳表是以一种跳跃式的数据存在,当我们查询‘14’这个数据的时候,可以跳过很多无用的数据,减少遍历的次数。

跳表是一个典型的空间换时间的解决方案,而且只有在数据量较大的情况下才能体现出来优势。还是要读多写少的情况才适合使用,所以它的适用范围还是比较有限的,新增或者删除的时候要把所有数据都更新一遍。

8.总结

image.png


苏凌峰
73 声望38 粉丝

你的迷惑在于想得太多而书读的太少。