Redis底层数据结构详解(一)

《Redis数据类型与应用场景》一文中介绍了Redis五大数据类型的使用以及各类的应用场景,今天来介绍一下Redis五大数据类型底层的数据结构的实现,这篇文章是第一节,关于底层数据结构的剖析,第二节会将底层数据结构与五大数据类型联系起来讲解

一、简单动态字符串SDS

1. SDS

        SDS(simple dynamic string),即简单动态字符串的抽象类型,是Redis默认的字符串,其定义如下(这是3.2之前的版本,后续深入讲解SDS的优化时会讲3.2之后的版本SDS的定义,目前只为了学习SDS大体的设计原理):

struct sdshdr{
     //记录buf数组中已使用的数量, 也为字符串长度
     int len;
     //记录 buf 数组中未使用的数量
     int free;
     //字节数组,用于保存字符串
     char buf[];
}

在这里插入图片描述

2. 为什么Redis没用C语言原生字符串?

Redis是由C语言开发的,一开始我也有疑问,为什么Redis没有用C语言原生的字符串来作为string类型的底层数据结构呢?那么首先要先了解一下C语言的字符串了

2.1 C语言中的字符串

        所谓字符串本质上就是以\0(空字符)作为结尾的特殊字符数组,所以有以下几个点需要注意

① 当定义的字符数组最后没有以\0结尾且没有给定字符长度为实际长度加一时,它仅仅是字符数组而非字符串

# 它仅仅是一个字符数组而非字符串
char s1[] = {'h','e','l','l','0'};
# 这才是一个字符串
char s2[] = {'h','e','l','l','0', '\0'};

② 如果没有以\0结尾,那么你需要定义数组的长度比实际长度多一位

# 这也是一个字符串
char s3[6] = {'h','e','l','l','0'};
# 这仅仅是字符数组
char s4[5] = {'h','e','l','l','0'};

③ 直接定义字符串

char s5 = "hello";

        以上不管是哪种方式,在C的底层,都会为这段字符串加上\0代表一个字符串的结尾

2.2 使用SDS的好处

  1. 二进制安全
    Redis不仅可以存储文本,还可以存储二进制文件,例如图片,而图片的内容很可能包含\0,如果用C语言原生的字符串表示,那遇到\0后会认为字符串结束了,导致无法正常获取。而SDS虽然底层也采用了char数组且以\0结尾,但并非以\0来确定字符串是否结束的,而是以len属性来判断字符串是否结束,所以可以确保二进制数据安全
  2. 查询字符串长度迅速
    C语言中,查询字符串的长度,往往采用循环的方式,如此,时间复杂度为O(N),而SDS封装了char并扩展了len属性,用它作为字符串长度,查询是直接返回即可,时间复杂度为O(1)。可以使用命令查看string类型的值的字符串长度
127.0.0.1:6379> set key str
OK
127.0.0.1:6379> strlen key
(integer) 3
  1. 减少内存重新分配次数
    C语言在对字符串操作前,需要进行至少一次的内存分配,而C语言也并不会记录字符串的长度,所以修改也是会进行内存重新分配的,如果不重新分配,字符串变长就会导致内存溢出,如果字符串变短,那么会有内存泄漏的问题,而内存的分配是比较耗费性能的,针对这一弊端,SDS进行了优化,利用属性lenfree设计了空间预分配和惰性空间释放机制。
    1. 空间预分配: 所谓预分配,也就是说在一次扩展操作中,扩展的空间大小会大于实际需要的空间大小,这样就可以减少连续对字符串进行增长操作时,对内存的重分配次数。
    1.1 预分配规则:
    SDS len<1M:分配len长度空间作为预分配空间,即长度翻倍;
    SDS len>=1M:分配1M空间作为预分配空间,即多分配1M长度;
    这样,在下次进行字符操作的时候,如果所需要的空间小于当前SDS free空间,则可以直接行操作,而不需要再执行内存扩展,重分配操作。如此一来,使得扩展操作所需的内存重分配次数变为<=1
    2. 惰性空间释放: 直白地说,当字符串有部分删除的时候,并不会立即执行内存重新分配,而是更改free属性的值,释放的内存都赋给free,以备下次字符串扩展的时候使用。此外Redis也提供了主动释放未使用内存的方法,还是比较灵活的
  2. 缓冲区内存溢出问题规避
    内存溢出即分配内存小于实际需要内存,C语言在对字符串进行扩展时,要特别注意内存分配情况,比如两个字符串拼接,要严格保证内存足够。而SDS在进行字符串修改的时候,会判断添加的字符串的长度加上原字符串的长度是否小于原字符串本身的内存长度,如果小,则直接拼接返回,如果大,则根据预分配规则进行自动扩容

二、链表linkedlist

        linkedlist是一个标准的双向链表数据结构,其定义如下:

typedef  struct listNode{
       //前置节点
       struct listNode *prev;
       //后置节点
       struct listNode *next;
       //节点的值
       void *value;  
}listNode
typedef struct list{
     //表头节点
     listNode *head;
     //表尾节点
     listNode *tail;
     //链表所包含的节点数量
     unsigned long len;
     //节点值复制函数
     void (*free) (void *ptr);
     //节点值释放函数
     void (*free) (void *ptr);
     //节点值对比函数
     int (*match) (void *ptr,void *key);
}list;

其特性如下:
        ① 双向:listNode具有前后指针
        ② 记录长度:通过len属性记录了链表长度,时间复杂度为O(1)
        ③ 值可存任意类型:可以看到listNodevalue采用指针的方式,可以保存不同类型的值
        ④ 保存双端:获取头和为的时间复杂度为O(1)

三、压缩列表(ziplist)

        压缩列表并不是对数据利用某种算法进行压缩,而是将数据按照一定规则编码在一块连续的内存区域,目的是节省内存。官网对ziplist的定义如下:

ziplist是一种特殊编码的双链表,它的设计非常高效。它存储字符串和整数值,其中整数被编码为实际整数而不是一系列字符。它允许在O(1)时间内对列表的任一侧执行推送和弹出操作。但是,由于每个操作都需要重新分配ziplist使用的内存,因此实际的复杂性与ziplist使用的内存量有关

ziplist 是由一系列特殊编码的内存块构成的列表(像内存连续的数组,但每个元素长度不同), 一个 ziplist 可以包含多个节点(entry)ziplist 将表中每一项存放在前后连续的地址空间内,每一项因占用的空间不同,而采用变长编码。
当元素个数较少时,Redisziplist 来存储数据,当元素个数超过某个值时,链表键中会把 ziplist 转化为 linkedlist

1. ziplist底层存储结构

在这里插入图片描述

字段类型长度作用
zlbytesnint32_t4字节记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重新分配或者计算zlend位置的时候使用
zltailnint32_t4字节记录到达尾节点的偏移量,通过偏移量可以在不遍历所有节点的情况下,直接找到尾节点
zllennint16_t2字节ziplist中节点的数量。当这个值小于65535时,通过它就可以得出列表中的节点数量,但是当这个值等于65535时,就需要通过实际遍历计数得出列表节点数了
entry?列表节点不定长压缩列表中的各个节点,数据存储于节点之上,由于数据内容不同,所以长度不定,采用了变长编码
zlendnint8_t1字节记录压缩列表的末端

2. entry节点的内部结构

在这里插入图片描述
previous_entry_length: 记录压缩列表前一个节点的字节长度,通过该值可以算出前一个节点的位置,用于从尾节点向前遍历。当前指针位置减去前一个节点的长度就是前一个节点的位置。

previous_entry_length是变长编码,有两种表示方法:
如果前一节点的长度小于 254 字节,则使用1字节(uint8_t)来存储prevrawlen;
如果前一节点的长度大于等于 254 字节,那么将第 1 个字节的值设为 254 ,然后用接下来的 4 个字节保存实际长度。

encoding/len: 当前节点的编码类型以及字节长度,用来解析content用的。encoding类型一共有两种,一种字节数组一种是整数,encoding区域长度为1字节、2字节或者5字节长
content: 保存了当前节点的值

四、字典dict

        字典是一种存储键值的抽象数据结构,学过Java的朋友应该都知道HashMapHashtable,如果你知道这个,那么理解起来Redis的字典可以说毫不费力。
Redis中的字典以哈希表为底层数据结构,其定义如下:

typedef struct dictht{
     //哈希表数组
     dictEntry **table;
     //哈希表大小
     unsigned long size;
     //哈希表大小掩码,用于计算索引值
     //总是等于 size-1
     unsigned long sizemask;
     //该哈希表已有节点的数量
     unsigned long used;
 
}dictht

由代码可以知道,哈希表是由dictEntry类型的数组组成的,dictEntry的结构如下:

typedef struct dictEntry{
     //键
     void *key;
     //值
     union{
          void *val;
          uint64_tu64;
          int64_ts64;
     }v;
 
     //指向下一个哈希表节点,形成链表
     struct dictEntry *next;
}dictEntry

在这里插入图片描述

看到这里,想必学Java的同学已经非常通透了,数据结构与HashMap非常类似,采用数组加链表的形式,解决哈希冲突也是利用了链地址法,且采用头插法,不了解的可以看下这篇HashMap底层原理

1. 扩容与缩容

        当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。步骤如下:

  1. 如果执行扩展操作,会基于原哈希表创建一个大小等于 原哈希表已使用的空间的2倍大小的哈希表。相反如果执行的是缩容操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。
  2. 重新利用哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
  3. 所有键值对都迁徙完毕后,释放原哈希表的内存空间

2. 渐进式rehash

        上述介绍了扩容与缩容,那么Redis并不是一次性将数据迁移到新哈希表的,这也是考虑到了性能问题,如果我们的数据量小还好,一次性迁移,对性能没什么影响,那如果就几十万几百万的数据,进行一次性迁移,肯定会影响到性能。所以Redis采用了渐进式rehash,也就是迁移分为多次渐进式完成,有两种迁移策略:

  1. 主动:当访问老记录时,则迁移一部分,不是按访问顺序进行迁移的,它有一定的迁移顺序
  2. 事件轮询:以时间轮询的方式触发迁移,每次迁移一批

3. 在rehash过程中数据如何存取

        查询可能会设计两张哈希表,先从旧哈希表查,如果查不到,则会从新哈希表查。而插入数据只会往新的哈希表添加

五、整数集合intset

        整数集合(intset)是Redis用于保存整数值的集合抽象数据类型,它可以保存类型为int16_t、int32_t 或者int64_t 的整数值,并且保证集合中不会出现重复元素,其定义如下:

typedef struct intset{
     //编码方式
     uint32_t encoding;
     //集合包含的元素数量
     uint32_t length;
     //保存元素的数组
     int8_t contents[];
}intset;

contents[]: 整数集合的每个元素都是 contents 数组的一个数据项,它们按照从小到大的顺序排列,并且不包含任何重复项。虽然该数组生命为int8_t,但实际上并不保存任何int8_t类型的值,具体的类型是由属性encoding来决定的,而encoding提供了三个值,分别为INTSET_ENC_INT16INTSET_ENC_INT32INTSET_ENC_INT64

length: 记录了 contents 数组的大小。

注意: 当我们新加入的元素的长度大于集合原来的元素长度时,需要对整数集合进行升级,根据新元素类型扩展整数集合中数组的大小,给新元素分配空间,将数组原有元素逐个转换成与新元素类型相同的元素存入数组,最后将新元素加入,这种升级是不可逆操作,一旦升级只能保持升级后的状态

六、跳表skiplist

        在我看来,跳表是对标准链表的一种优化,我们都知道链表的查询时间复杂度是O(N),而将链表优化为跳表后,查询便趋近于二分查找。我们先来看一下它的结构:
在这里插入图片描述
        跳表的数据结构有很多层,最底层链表包含了所有元素,每个节点都有两个指针,一个指向同层下一个节点,一个指向下一层同一个节点,我们看图可以理解为将节点向上冗余,做出索引层,仔细思考,有点二分搜索树的味道,如此一来,可以让查询趋近于二分查找,而且跳表还支持区间查找

跳表定义如下:

typedef struct zskiplist{
     //表头节点和表尾节点
     structz skiplistNode *header, *tail;
     //表中节点的数量
     unsigned long length;
     //表中层数最大的节点的层数
     int level;
}zskiplist;

多个跳表节点构成跳表,跳表节点定义如下:

typedef struct zskiplistNode {
     //层
     struct zskiplistLevel{
           //前进指针
           struct zskiplistNode *forward;
           //跨度
           unsigned int span;
     }level[];
     //后退指针
     struct zskiplistNode *backward;
     //分值
     double score;
     //成员对象
     robj *obj;
} zskiplistNode

① 搜索:搜索还是比较好理解的,从最高层开始检索,如果比当前层的当前节点大并且比当前层的当前节点的下一个节点小,那么就向下寻找,也就是与当前层的当前节点的下一层的下一个节点比较,以此类推。例如,寻找节点11,先从最高层节点3开始,发现3<7<16,那么就向下一层找,与下一层的节点3的下一个节点7比较,发现比7大,然后将7看作当前节点,重复上述步骤,即7<11<16,继续与下一层的相同节点的下一个节点11比较,相等,返回

② 删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。

③插入:跳表的插入比价复杂,选择在哪一层插入是随机的,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。比如确定插入第x层,然后找到新节点在每一层的上一个节点,将新节点插入到每一层的上一个节点和下一个节点之间,插入是在底层到x层都发生的。

        有关跳表的增删查我只能说这么多了,简单理解一下原理吧,这块比较复杂,我还没有那么多精力去深入了解,毕竟学Java不想看那么多C的代码哈哈哈

参考文章:
https://blog.csdn.net/u013536232/article/details/105476382/
https://zhuanlan.zhihu.com/p/102422311
https://www.cnblogs.com/ysocean/p/9080942.html#_label6
https://blog.csdn.net/qq193423571/article/details/81637075

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值