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

1、Redis的数据结构

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

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

2、简单动态字符串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语言的字符串呢?

1、获取字符串长度复杂度为O(1)

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

2、杜绝缓冲区溢出

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

3、减少修改字符串时带来的内存重新分配次数

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

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

空间预分配:对字符串进行增长操作,会分配出多余的未使用空间,这样如果以后的扩展,在一定程度上可以减少内存重新分配的次数。

惰性释放空间:对字符串经过缩短操作,并不会立即释放这些空间,而是使用free来记录这些空间的数量,当进行增长操作时,这些记录的空间就可以被重新利用;SDS提供了响应的API进行手动释放空间,所以不会造成内存浪费。

4、二进制安全

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

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

SDS结构中buf保存字符串同样是以空字符结尾,所以可以兼容C语言的部分字符串操作API。

总结:

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

 

3、链表

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链表实现的特性如下:

1、双端

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

2、无环

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

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

可以快速的获取表头节点和表尾节点。

4、有链表长度计数器

可以快速获取链表长度。 

5、多态

链表可以保存各种不同类型的值,通过list的dup,free,match三个属性为节点设计值类型特定的函数。

 

4、字典

字典又称为符号表(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、哈希算法

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

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

2、结局键冲突

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

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

3、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做准备。

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

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

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

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

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

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

5、渐进式rehash

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

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

6、渐进式rehash之情期间的表操作

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

 

字典总结: 

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

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

 

5、跳跃表

跳跃表(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跳跃表包含节点数量(不含表头节点)。
    • 跳跃表由很多层构成(L1、L2 ...),每个层都带有两个属性前进指针和跨度。
    • 每个节点都包含成员对象(obj)、分值(score)、后退指针(backward),头结点也包含这些属性但不会被用到

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

 

6、整数集合

整数集合(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_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64分别对应的是int16_t、int32_t、int64_t类型
    • length记录整数集合的元素数量,即contents数组的长度

整数集合的升级操作

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

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

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

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

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

新元素引发升级,所以新元素要么比所有元素都大,要么比所有元素都小。

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

升级操作的好处

    • 提升整数的灵活性,可以任意的向集合中放入3中不同类型的整数,而不用担心类型错误。
    • 节约内存,整数集合中只有大类型出现的时候才会进行升级操作。

整数集合不支持降级操作

 

7、压缩列表

压缩列表(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个字节则后续节点也要空间扩展,以此类推,最极端的情况是一直扩展到最后一个节点完成;这种现象称为连锁更新。在日常应用中全部连锁更新的情况属于非常极端的,不常出现。

 

8、总结

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

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

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

 

 参考:

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

 

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

posted @ 2019-07-24 17:50  MouseDong  阅读(2397)  评论(4编辑  收藏  举报