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的存储结构就一目了然了:
为了便于操作,Redis采用redisObject结构来抽象了不同的数据类型,这样所有的数据类型就可以用相同的形式在函数之间传递,而不是使用特定的类型结构。同时,为了识别不同的数据类型,
redisObject中定义了type和encoding字段对不同的数据类型加以区分。简单地说,redisObject就是String,hash,list,set,zset的父类,可以在函数间传递的时候隐藏具体的基本类型信息,所以作者抽象了redisObject。
2.程序员使用redis时的底层思维
我们刚开始学习redis的时候,只会调用调用顶层的api,所以我们看到的redis是这个样子的:
但是我们学习了redis的的底层数据结构后,我们将会看到这样子的redis:
3.String底层数据结构
Redis的String类型,其实底层是由三种数据结构组成的:
1)int: 整数且小于二十位整数以下的数字数据才会使用这个类型
2)embstr (embedded string,表示嵌入式的String):代表embstr格式的SDS(Simple Dynamic String 简单动态字符串),保存长度小于44字节的字符串。
3)raw :保存长度大于44的字符串
我们先对上面这个案例做一个测试:
我们发现保存的数据内容,会随着保存内容的变化而发生变化。
这就是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只存一份。
2) embstr
当保存的字符串数据小于等于44字节的时候,embstr类型将会调用内存分配函数,只分配一块连续的空间,空间中一次包含redisObject和sdshdr两个结构,让元数据,指针和sds是一块连续的区域,避免内存碎片。
3) raw
字符串大于44字节时,SDS的数据量变多变大了,SDS和RedisObject布局会分开,会给SDS分配多的空间并用指针指向SDS结构,raw 类型将会调用两次内存分配函数,分配两块内存空间,一块用于包含 redisObject结构,而另一块用于包含 sdshdr 结构。
分配流程图:
4.Hash数据结构介绍
在查看Hash的数据结构之前,我们先来看这样的一个配置:
我们可能看不太懂,我来给解释一下:
hash-max-ziplist-entries:使用压缩列表保存哈希集合中的最大元素个数。
hash-max-ziplist-value:使用压缩列表保存时哈希集合中单个元素的最大长度。
可能我们这么说还是不太懂,上案例:
Hash数据类型也和String有相似之处,到达了一定的阈值之后就会对数据结构进行升级。
数据结构:
1)hashtable 就是和java当中使用的hashtable一样,是一个数组+链表的结构。
2)ziplist 压缩链表
我们先来看一下 压缩链表的源码:
ziplist是一种比较紧凑的编码格式,设计思路是用时间换取空间,因此ziplist适用于字段个数少,且字段值也较小的场景。压缩列表内存利用率高的原因与其连续性内存特性是分不开的。
当一个hash对象,只包含少量的键,且每个键值对的值都是小数据,那么ziplist就适合做为底层实现。
ziplist的结构:
它是一个经过特殊编码的双向链表,它虽然是双向链表,但它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点的长度和当前节点的长度,通过牺牲部分读写性能,来换取高空间利用率。
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的默认配置:
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。
]
源码:
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的配置:
set-max-intset-entries
set数据类型集合中,如果没有超出了这个数量,且数据元素都是整数的话,类型为intset,否则为hashtable。
intset类型:
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
看到源码后,我们可以得出,结论,intset的数据结构本质是一个数组。而且**存储数据的时候是有序的,因为在查找数据的时候是通过二分查找来实现的。
**
我们稍微分析一下set的单个元素的添加流程。
如果set已经是hashtable的编码,那么走hashtable的添加流程。
如果原来是intset:
1)能转化为int对象,就用intset保存。
2)如果长度超过设置,就用hashtable保存
3)其它情况统一用hashtable保存。
7.ZSet数据结构介绍
我们看一下ZSet的配置:
当有序集合中包含的元素数量超过服务器属性 zset_max_ziplist_entries 的值(默认值为 128 ),
或者有序集合中新添加元素的 member 的长度大于服务器属性zset_max_ziplist_value 的值(默认值为 64 )时,redis会使用跳跃表(下文会解释)作为有序集合的底层实现。
否则会使用ziplist作为有序集合的底层实现
看一下源码:
当元素个数大于设置的个数或者元素的列表本来就是skiplist编码的时候,用skiplist存储,否则就用ziplist存储。
skiplist(跳表)是什么:
跳表是一种以空间换取时间的结构。
由于链表是无法进行二分查找的,因此借鉴了数据库索引的思想,提取出链表中关键节点(索引),现在关键节点上进行查找,再进入链表进行查找。
提取了很多关键节点,就形成了跳表。
因为跳表是以一种跳跃式的数据存在,当我们查询‘14’这个数据的时候,可以跳过很多无用的数据,减少遍历的次数。
跳表是一个典型的空间换时间的解决方案,而且只有在数据量较大的情况下才能体现出来优势。还是要读多写少的情况才适合使用,所以它的适用范围还是比较有限的,新增或者删除的时候要把所有数据都更新一遍。
8.总结
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。