Redis(二)--- Redis的底层数据结构

2021年11月25日 阅读数:0
这篇文章主要向大家介绍Redis(二)--- Redis的底层数据结构,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

一、Redis的数据结构

Redis 的底层数据结构包含简单的动态字符串(SDS)、链表、字典、压缩列表、整数集合等等;五大数据类型(数据对象)都是由一种或几种数结构构成。java

在命令行中可使用 OBJECT ENCODING key 来查看key的数据结构。redis

二、简单动态字符串SDS

 redis是使用C语言编写的,可是string数据类型并无使用C语言的字符串,而是从新编写一个简单的动态字符串(simple dynamic string,SDS)。算法

 1 /*
 2   * 保存字符串对象的结构
 3   */
 4 struct sdshdr {
 5   
 6     // buf 中已占用空间的长度
 7     int len;
 8   
 9     // buf 中剩余可用空间的长度
10     int free;
11  
12     // 数据空间
13     char buf[]
14 };

 

使用SDS保存字符串Redis,具体表示以下:数据库

                                              图片来自《Redis设计与实现》 黄健宏著数组

 

    • free 表示buf数组中剩余的空间数量
    • len 记录了buf数组中已存储的字节长度
    • buf 数组是一个char类型的数据,记录具体存储的字符串,而且以 ‘\0’(空字符) 做为结束标识符

 SDS定义较C语言的字符串几乎相同,就是多出两个属性free,len;那为什么不直接使用C语言的字符串呢?安全

一、获取字符串长度复杂度为O(1)服务器

        因为C语言没有存储字符串长度,每次获取字符串长度多须要进行循环整个字符串计算,时间复杂度为O(N);而SDS记录了存储的字符串的长度,获取字符串长度时直接获取len的属性值便可,时间复杂度为O(1);而SDS中设置和更新长度是API中自动完成,无需手动进行操做。网络

二、杜绝缓冲区溢出数据结构

 C语言在进行两个字符串拼接时,一旦没有分配足够的内存空间,就会形成溢出;而SDS在修改字符串时,会先根据len的值,检查内存空间是否足够,若是不足会先分配内存空间,再进行字符串修改,这样就杜绝了缓冲区溢出。函数

三、减小修改字符串时带来的内存从新分配次数

C语言不记录字符串长度,因此当修改时,会从新分配内存;若是是正常字符串,内存空间不够会产生溢出;若是是缩短字符串,不重重分配会产生泄露。

SDS采用空间预分配和惰性释放空间两种优化策略

空间预分配:对字符串进行增加操做,会分配出多余的未使用空间,这样若是之后的扩展,在必定程度上能够减小内存从新分配的次数。

惰性释放空间:对字符串通过缩短操做,并不会当即释放这些空间,而是使用free来记录这些空间的数量,当进行增加操做时,这些记录的空间就能够被从新利用;SDS提供了响应的API进行手动释放空间,因此不会形成内存浪费。

四、二进制安全

C语言的字符串中不能包含空字符(由于C语言是以空字符判断字符串结尾的),因此不能保存一些二进制文件(有可能包含空字符,如图片);SDS则是以len来判断字符串结尾,因此SDS结构能够存储图片等,而且都是以二进制方式进行处理。

五、兼容部分C字符串函数

SDS结构中buf保存字符串一样是以空字符结尾,因此能够兼容C语言的部分字符串操做API。

总结:

                                             表来源:《Redis设计与实现》

 

三、链表

Redis使用C语言编写,但并无内置链表这种数据结构,而是本身构建了链表的实现;构成链表结构的节点为链表节点。

链表用的很是普遍,如列表键、发布与订阅、慢查询、监视器等。

1 typedef struct listNode {
2     // 前置节点
3     struct listNode * prev;
4     // 后置节点
5     struct listNode * next;
6     // 节点的值
7     void * value;
8 }listNode;

多个listNode能够经过prev和next指针构成双端链表,使用list持有链表

 1 typedef struct list {
 2     // 表头节点
 3     listNode * head;
 4     // 表尾节点
 5     listNode * tail;
 6     // 链表所包含的节点数量
 7     unsigned long len;
 8     // 节点值复制函数
 9     void *(*dup)(void *ptr);
10     // 节点值释放函数
11     void (*free)(void *ptr);
12     // 节点值对比函数
13     int (*match)(void *ptr,void *key);
14 } list;
    • head 表头指针
    • tail 表尾指针
    • len 链表长度计数器
    • dup、free、match 多态链表所需的类型特定的函数

Redis链表实现的特性以下:

一、双端

链表节点带有prev和next指针,能够快速获取前置和后置节点,时间复杂度都是O(1)。

二、无环

 头节点prev指针和尾节点next指针都指向null,对链表访问以NULL为终点。

三、带表头指针和表尾指针

能够快速的获取表头节点和表尾节点。

四、有链表长度计数器

能够快速获取链表长度。 

五、多态

链表能够保存各类不一样类型的值,经过list的dup,free,match三个属性为节点设计值类型特定的函数。

 

四、字典

字典又称为符号表(symbol table)、关联数组(associative array)或者映射(map);字典中存储key-value键值对,而且key不重复;

字典在Redis中普遍应用,如Redis数据库就是使用字典做为底层实现的。

Redis使用的C语言没有内置这种结构,因此Redis构建了本身的字典实现。

字典使用哈希表做为底层试下,一个哈希表包含多个哈希节点,每一个哈希节点保存一个键值对。

哈希表

 1 typedef struct dictht {
 2     // 哈希表数组
 3     dictEntry **table;
 4     // 哈希表大小
 5     unsigned long size;
 6     // 哈希表大小掩码,用于计算索引值
 7     // 老是等于size-1
 8     unsigned long sizemask;
 9     // 该哈希表已有节点的数量
10     unsigned long used;
11 } dictht;

图中是一个大小为4的空哈希表

    • table是一个数组,数组元素是dictEntry结构的指针,每一个dictEntry保存一个键值对
    • size 记录哈希表的大小
    • sizemask 值老是等于size-1,这个属性和哈希值一块儿决定一个键应该被方法table数组的哪一个索引上
    • used 记录哈希表目前已有节点的数量

哈希表节点 

 1 typedef struct dictEntry {
 2     //
 3     void *key;
 4     //
 5     union{
 6         void *val;
 7         uint64_tu64;
 8         int64_ts64;
 9     } v;
10     // 指向下个哈希表节点,造成链表
11     struct dictEntry *next;
12 } dictEntry;
    • key属性保存着键值对中的键,v属性保存着键值对中的值
    • 键值对中的值可使指针val、一个uint64_t整数,或是一个int64_t整数
    • next是指向另外一个哈希表节点的指针,用以解决多个哈希值冲突问题

下图为将两个索引值相同的键连在一块儿

 

字典结构

 1 typedef struct dict {
 2     // 类型特定函数
 3     dictType *type;
 4     // 私有数据
 5     void *privdata;
 6     // 哈希表
 7     dictht ht[2];
 8     // rehash索引
 9     //当rehash不在进行时,值为-1
10     in trehashidx; /* rehashing not in progress if rehashidx == -1 */
11 } dict;
12 
13 typedef struct dictType {
14     // 计算哈希值的函数
15     unsigned int (*hashFunction)(const void *key);
16     // 复制键的函数
17     void *(*keyDup)(void *privdata, const void *key);
18     // 复制值的函数
19     void *(*valDup)(void *privdata, const void *obj);
20     // 对比键的函数
21     int (*keyCompare)(void *privdata, const void *key1, const void *key2);
22     // 销毁键的函数
23     void (*keyDestructor)(void *privdata, void *key);
24     // 销毁值的函数
25     void (*valDestructor)(void *privdata, void *obj);
26 } dictType;
    • type 属性是一个指向dictType结构的指针,每一个dictType机构保存了一簇用于操做特定类型键值对的函数,Redis货位用途不一样的字典设置不一样的类型特定函数。
    • privdata 属性保存了须要传给那些类型特定函数的可选参数。
    • ht 属性是一个长度为2的数组,数组中的每一个元素都是一个哈希表,通常状况下自字典只使用ht[0],ht[1]只会在进行rehash时使用.
    • trehashidx 属性记录了rehash目前的进度,若是没有进行rehash则它的值为-1。

下图为普通状态下的字典结构

当一个新的键值对要添加到字典中去时,会涉及到一系列的操做,如计算索引、解决冲突、扩容等等,下面对这些操做进行描述。

一、哈希算法

添加键值对时,首先要根据键值对的键计算出哈希值和索引值,而后再根据索引值进行放入

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

二、结局键冲突

当有两个或以上数量的键值被分配到了哈希表数组的同一个索引上时,就发生了键冲突。

Redis的哈希表使用单向链表解决键冲突问题,每一个新的键老是添加到单项链表的表头。

三、rehash(扩展或收缩)

哈希表具备负载因子(load factor),其始终须要保持在一个合理的范围以内,当hashI表保存的键值对过多或过少时,就须要对哈希表进行rehash(从新散列)操做,步骤许下

(1) 为字典的ht[1]分配空间,空间大小:若是是扩展操做则为ht[0].used * 2 ,也就是扩展为当前哈希表已使用空间的1倍;若是是收缩,则减少1倍。

(2) 将ht[0]内的数据从新计算哈希值和索引,并放到新分配的ht[1]空间上。

(3) 所有迁移完成后,将ht[1]设置为ht[0],释放ht[0]并建立一个空白的哈希表为ht[1],为下次rehash作准备。

四、哈希表的扩展与收缩触发条件

(1) 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,而且哈希表的负载因子大于等等于1。

(2) 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,而且哈希表的负载因子大于等于5。

以上条件中任意一条被知足,程序自动开始对哈希表进行扩展;

负载因子算法:负载因子 = 哈希表以保存的节点数量 / 哈希表大小

当负载因子小于0.1时,程序自动进行收缩操做。

五、渐进式rehash

渐进式rehash就是,当ht[1]的键值对向ht[1]迁移的过程当中,若是数据量过大,则不能一次性迁移, 不然会对服务器性能形成影响,而是分红屡次,渐进式的进行迁移。

在rehash期间,会维持一个索引计数器rehashidx,并把每次的迁移工做分配到了添加、删除、查找、更新操做中,当rehash工做完成后rehashidx会增长1,这样全部的ht[0]的值所有迁移完成后,程序会将rehashidx这是为-1,标识最终的rehash完成。

六、渐进式rehash之情期间的表操做

因为渐进式rehash期间,ht[0]和ht[1]中都有数据,当查找时,会先在ht[0]中进行,没找到继续到ht[1]中找;而添加操做一概会添加到ht[1]中。

 

字典总结: 

Redis字典底层机构实现与java(1.6以前) 中的hashmap很是相像,都是使用单项链表解决键冲突问题。

我的疑问:jdk1.8以上已是用红黑树解决多个键冲突问题,不知redis的键冲突是否也能够用红黑树?

 

五、跳跃表

跳跃表(skiplist)数据结构特色是每一个节点中有多个指向其余节点的指针,从而快速访问节点。

跳跃表结构由跳跃表节点(zskiplistNode)和zskiplist两个结构组成

跳跃表节点

 1 typedef struct zskiplistNode {
 2     //
 3     struct zskiplistLevel {
 4         // 前进指针
 5         struct zskiplistNode *forward;
 6         // 跨度
 7         unsigned int span;
 8     } level[];
 9     // 后退指针
10     struct zskiplistNode *backward;
11     // 分值
12     double score;
13     // 成员对象
14     robj *obj;
15 } zskiplistNode;
    • 层:为一个数组,数组中的每一个数据都包含前进指针和跨度。
    • 前进指针:指向表尾方向的其余节点的指针,用于从表头方向到表尾方向快速访问节点。
    • 跨度:记录两个节点之间的距离,跨度越大,两个节点相聚越远,全部指向NULL的前进指针的跨度都为0。
    • 后退指针:用于从表尾节点向表头节点访问,每一个节点都有后退指针,而且每次只能后退一个节点。
    • 分值:节点的分值是一个double类型的浮点数,跳跃表中的说有分值按从小到大排列。
    • 成员对象:是一个指向字符串的指针,字符串则保存着一个SDS值。

跳跃表

1 typedef struct zskiplist {
2     // 表头节点和表尾节点
3     structz skiplistNode *header, *tail;
4     // 表中节点的数量
5     unsigned long length;
6     // 表中层数最大的节点的层数
7     int level;
8 } zskiplist;

    • header 指向跳跃表的表头节点,tail指向跳跃表的表尾节点,level记录节点中的最大层数(不含表头节点),length跳跃表包含节点数量(不含表头节点)。
    • 跳跃表由不少层构成(L一、L2 ...),每一个层都带有两个属性前进指针和跨度。
    • 每一个节点都包含成员对象(obj)、分值(score)、后退指针(backward),头结点也包含这些属性但不会被用到

在此处只是介绍跳跃表的结构相关,关于跳跃表的层的造成,对象的插入、删除、查询等操做的原理在此处不作详解,另外会有文章进行说明。

 

六、整数集合

整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数元素,而且元素的个数很少时,Redis就会使用整数集合做为集合键的底层实现。

整数集合能够保存int16_t、int32_t、int64_t的整数值,而且不会出现重复元素

1 typedef struct intset {
2     // 编码方式
3     uint32_t encoding;
4     // 集合包含的元素数量
5     uint32_t length;
6     // 保存元素的数组
7     int8_t contents[];
8 } intset;
    • contents数组存储的是集合中的每一个元素,他的类型是int8_t,但存储数据的实际类型取决于编码方式encoding
    • encoding编码方式有三种INTSET_ENC_INT1六、INTSET_ENC_INT3二、INTSET_ENC_INT64分别对应的是int16_t、int32_t、int64_t类型
    • length记录整数集合的元素数量,即contents数组的长度

整数集合的升级操做

整数集合中原来保存的是小类型(如:int16_t)的整数,当插入比其类型大(如:int_64_t)的整数时,会把整合集合里的元素的数据类型都转换成大的类型,这个过程称为升级

升级整数集合并添加新元素步骤以下:

(1)根据新元素的类型,扩展整数集合的底层数据的空间大小,并为新元素分配空间。

(2)将现有的全部元素的类型转换成与新元素相同的类型,保持原有数据有序性不变的状况下,把转换后的元素放在正确的位置上。

(3)将新元素添加到数组里。

新元素引起升级,因此新元素要么比全部元素都大,要么比全部元素都小。

    • 当小于全部元素时,新元素放在底层数组的最开头
    • 当大于全部元素时,新元素放在底层数据的最末尾

升级操做的好处

    • 提高整数的灵活性,能够任意的向集合中放入3中不一样类型的整数,而不用担忧类型错误。
    • 节约内存,整数集合中只有大类型出现的时候才会进行升级操做。

整数集合不支持降级操做

 

七、压缩列表

压缩列表(ziplist)是Redis为了节约内存而开发,是一系列特殊编码的连续内存块组成的顺序型数据结构。

一个压缩列表能够包含任意多个节点,每一个节点能够保存一个字节数组或者一个整数值。

下图为压缩列表的结构

每一个压缩列表含有若干个节点,而每一个节点都由三部分构成,previous_entry_length、encoding、content,如图:

 

    • previous_entry_length 存储的是前一个节点的长度,因为压缩列表内存块连续,使用此属性值能够计算前一个节点的地址,压缩列表就是使用这一原理进行遍历。
    • previous_entry_length 若是前一节点长度小于254字节,那么previous_entry_length属性自己长度为1字节,存储的指就是前一节点的长度;若是大于254个字节,那么previous_entry_length属性自己长度为5个字节,前一个字节为0xFE(十进制254),以后四个字节存储前一节点的长度。
    • encoding 记录本节点的content属性所保存数据的类型及长度,其自己长度为一字节、两字节或五字节,值得最高位为00、01或10的是字节数组的编码,最高位以11开头的是整数编码。
    • content 保存节点的值,能够是一个字节数组或者整数。

连锁更新

当对压缩列表进行添加节点或删除节点时有可能会引起连锁更新,因为每一个节点的 previous_entry_length 存在两种长度1字节或5字节,当全部节点previous_entry_length都为1个字节时,有新节点的长度大于254个字节,那么新的节点的后一个节点的previous_entry_length原来为1个字节,没法保存新节点的长度,这是就须要进行空间扩展previous_entry_length属性由原来的1个字节增长4个字节变为5个字节,若是增长后原节点的长度超过了254个字节则后续节点也要空间扩展,以此类推,最极端的状况是一直扩展到最后一个节点完成;这种现象称为连锁更新。在平常应用中所有连锁更新的状况属于很是极端的,不常出现。

 

八、总结

Redis的底层数据结构共有六种,简单动态字符串(SDS)、链表、字典、跳跃表、整数集合、压缩列表。

Redis中的五大数据类型的底层就是由他们中的一种或几种实现,数据的存储结构最终也会落到他们上。

但是在redis命令下使用 OBJECT ENCODING 命令查看键值对象的编码方式,也就是是以哪一种结构进行的底层编码。

 

 参考:

《Redis设计与实现》黄健宏著,网上对Redis的详解等

 

此博客为笔者使用redis好久以后,参考网络上各种文章总结性书写,原创手打,若有错误欢迎指正。