Redis为什么就这么快?Redis底层数据结构解析(跳跃表,SDS,链表,Hash)

Redis之所以执行速度很快,主要依赖于以下几个原因:

1.纯内存操作

避免大量访问数据库,减少直接读取磁盘数据,redis将数据储存在内存里面,读写数据的时候都不会受到硬盘 I/O 速度的限制,所以速度快;

2.单线程操作

避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;

3.采用了非阻塞I/O多路复用机制

image.png

用户首先将需要进行IO操作的socket添加到select中,然后阻塞等待select系统调用返回。当数据到达时,socket被激活,select函数返回。用户线程正式发起read请求,读取数据并继续执行。这样用户可以注册多个socket,然后不断地调用select读取被激活的socket,redis服务端将这些socket置于队列中,然后,文件事件分派器,依次去队列中取,转发到不同的事件处理器中,提高读取效率。
采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作,从而提高效率。

4.灵活多样的数据结构

redis内部使用一个redisObject对象来表示所有的key和value。redisObject主要的信息包括数据类型、编码方式、数据指针、虚拟内存等。它包含String,Hash,List,Set,Sorted Set五种数据类型,针对不同的场景使用对应的数据类型,减少内存使用的同时,节省网络流量传输。

5.持久化

由于redis的数据都存放在内存中,如果没有配置持久化,redis重启后数据就全丢失了,于是需要开启redis的持久化功能,将数据保存到磁盘上,当redis重启后,可以从磁盘中恢复数据。redis提供两种方式进行持久化,一种是RDB持久化(原理是将redis在内存中的数据库记录定时 dump到磁盘上的RDB持久化),另外一种是AOF(append only file)持久化(原理是将redis的操作日志以追加的方式写入文件)。持久化似乎和redis的速度快并没有直接关系,但是这保证的redis数据的安全性和可靠性,也起到数据备份的作用。

SDS

Redis是基于C语言实现的但是并没有使用C语言的传统字符来实现存储,而是针对String类型专门做了一个叫**简单动态字符串(simple dynamic string,SDS)**的抽象类型, 并将 SDS 用作 Redis的默认字符串(String)表示。

127.0.0.1:6379> set name context
OK

比如新建了一个key-value,这个操作其实将key和value分别包装了为2个不同的SDS。

127.0.0.1:6379> RPUSH name2 context1 context2 context3
(integer) 3

上面这个情况,一共包含了4个SDS,分别是name2 context1 context2 context3

那么包装为SDS有什么好处呢?为什么Reids要这样设计呢?就要从SDS的数据结构解析起

SDS的数据结构

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


SDS数据结构由free剩余空间,len字节长度,buf字节数组组成。

SDS数据结构解析

1.为什么要有free字段。

作用1:作为数据库属性避免内存
在我们平时编码过程是不是很少对字符串进行添加修改操作,倘若真的需要一个字符串直接声明一个开辟一个新的内存空间,此后长时间不操作这个空间了。但是对于我们数据库来说,我们却要经常对数据进行修改追加。倘若这样的操作在内存当中势必造成不必要的QBS。因此在Redis中采用了空间换时间的做法,free代表冗余字段,在context写入时,会开辟相同空间的冗余free空间。
当context的数据内容小于1MB,则buf的字节长度为2len+1。
举例:新增context,则free存储为5,len为5,buf对应的空间节点长度为5+5+1。
当context的数据内容大于1MB,则buf的字节长度为len+1MB.length。
举例:新增一个context的30MB,则buf长度为30MB.length。
不必担心浪费内存问题,在SDS中提供了相应的API,当内存不够用时就会释放空间。

作用2:防止内存溢出
在原本的C语言当中,倘若2个相邻的内存空间C1和C2

如果开发者忘记了对C1的内存空间进行扩容则会发生C2的内存空间上存储的是C1的追加内容,比如追加-hello。这时候就造成了内存溢出的问题。在C语言中我们称之为野指针。

但是在Redis当中,会对追加的内容进行判断,倘若小于free字段就会进行存储,大于则会增加free进行存储。

2.为什么要有len字段。

优化时间复杂度
Reids为了优化查询效率将查询的时间复杂度从O(n)降为了O(1)。
举例:比如插入reids这个字段,因为R E I D S 这个5个字节会存在不同的内存分片当中,当我查询的长度的时候,就是O(5)的时间复杂度,但是如果我记录了len,那么时间复杂度就是O(1)。避免了查询时间和查询长度所用的时间一样,长期占用内存空间。

定义buff字节结束位置。
在传统C语言当中以空格’\0’来结束字符串的结束,,但是这样会对中间包含空格的数据产生误读,比如以下
这个数据结构读取出来就是Red,造成了误读。所以在C语言中被要求字符串当中不能包含空格。
而在redis当中len记录的长度和buff的起始位置加+len的偏移量和’\0’加以判断字符串的结尾。
还是上图,读出来的是"Red is"

3.为什么是buff字段。

二进制安全
虽然数据库一般用于保存文本数据, 但使用数据库来保存二进制数据的场景也不少见因此, 为了确保 Redis 可以适用于各种不同的使用场景, SDS 的 API 都是二进制安全的(binary-safe),这也就是为什么是buff字段的原因,因为每个字节都是存储的对应的二进制。这样保证了没有对字符串的过滤,包装。保证了安全性。
另外扩展了redis,对图片,音频,视频等存储的扩展。

小结SDS
1.二级制安全
2.优化时间复杂度
3.减少内存开销,用空间换取时间

List

基础准备:
ziplist,在我们传统的list内存分配是翻倍分配,比如存储的内存为10个,这时最加一个,变为了11个。但是List数组内存会分配为20个,这时候造成了9个内存空间的浪费。
ziplist就是按需分配,需要11个内存空间则分配11个空间。

List的本质就是一个双向链表在基础当中我已经提到过了,那么他是怎么实现的呢,我们看下图
链表和链表节点的实现

在图最左边竖着的是quickList是整个List的结构,存储了长度,头标签栈地址,尾标签栈地址和个数。

List的结构图

Copytypedef struct quicklist {
    quicklistNode *head;   /*指向头节点(左侧第一个节点)的指针。*/
    quicklistNode *tail;    /*指向尾节点(右侧第一个节点)的指针。*/
    unsigned long count;        /* 所有ziplist数据项的个数总和。 */
    unsigned long len;          /* 所有ziplist数据项的个数总和。 */
    int fill : QL_FILL_BITS;              /* : 16bit,ziplist大小设置 */
    unsigned int compress : QL_COMP_BITS; /*16bit,节点压缩深度设置 */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;

quicklistNode结构

Copytypedef struct quicklistNode {
    struct quicklistNode *prev; //上一个node节点
    struct quicklistNode *next; //下一个node
    unsigned char *zl;            //保存的数据 压缩前ziplist 压缩后压缩的数据
    unsigned int sz;             /* ziplist的存储字节大小 */
    unsigned int count : 16;     /* ziplist 存储字节长度*/
    unsigned int encoding : 2;   /*表示ziplist是否压缩了,1代表没有压缩 RAW==1 or LZF==2 */
    unsigned int container : 2;  /*存储类型  NONE==1 or ZIPLIST==2,是一个预留字段*/
    unsigned int recompress : 1; /* 这个节点是否是ziplist */
    unsigned int attempted_compress : 1; /* 节点是否能压缩 1代表不能,这个值只对Redis的自动化测试程序有用,我们不用管*/
    unsigned int extra : 10; /*其它扩展字段。 */
} quicklistNode;

中间Node通过前尾地址进行标记。

注解:前面标有*的都是指向的内存地址不是实际的值。

多个 listNode 可以通过 prev 和 next 指针组成双端链表。

Redis 的链表实现的特性可以总结如下:

双端: 链表节点带有 prev 和 next 指针, 获取某个节点的前置节点和后置节点的复杂度都是O(1) 。
无环: 表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL , 对链表的访问以 NULL为终点。
带表头指针和表尾指针: 通过 list 结构的 head 指针和 tail 指针, 程序获取链表的表头节点和表尾节点的复杂度为 O(1) 。
带链表长度计数器: 程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数, 程序获取链表中节点数量的复杂度为 O(1) 。
多态: 链表节点使用 void* 指针来保存节点值, 并且可以通过 list 结构的 dup 、 free 、match 三个属性为节点值设置类型特定函数, 所以链表可以用于保存各种不同类型的值。

常用操作

1.从头或者尾插入

当我们执行插入操作时,首先会通过quicklist去寻找node节点,找到node节点之后会有2个情况:

1.如果插入的数据没有超过ziplist数组长度,则直接插入ziplist当中。
2.当超过时,会新建一个新的Node节点,修改pre额next节点地址来进行保存

2.从中间插入

1.当插入位置所在的ziplist大小没有超过限制时,直接插入到ziplist中就好了。
2.当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小没有超过限制,那么就转而插入到相邻的那个quicklist链表节点的ziplist中。倘若context的内容空间不够,则插入到上一个nodelist中的尾当中,或者下一个node的头当中,只不过下一个需要移动内存。
3.当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小也超过限制,这时需要新创建一个quicklist链表节点插入

3.查找

直接根据Node节点存储的ziplist数组查找值。

4.删除

quicklist 在区间删除时,会先找到 start 所在的 quicklistNode,计算删除的元素是否小于要删除的count,如果不满足删除的个数,则会移动至下一个 quicklistNode 继续删除,依次循环直到删除个数和count相同为止。

5.ZIPList

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

zlbytes:记录整个压缩列表占用的内存字节数:在对压缩列表进行内
存重分配, 或者计算 zlend 的位置时使用。
zltail

字段解释
zlbytesValue记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
zltail偏移量,用于获取内存地址的结束位置
zllen记录了压缩列表包含的节点数量: 当这个属性的值小于UINT16_MAX ( 65535 )时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。
entryX保存的节点地址
zlend特殊符号,0xFF,标记节点的末尾

entryX节点的组成

字段解释
previous_entry_lengthValue记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
encoding偏移量,用于获取内存地址的结束位置
content记录了压缩列表包含的节点数量: 当这个属性的值小于UINT16_MAX ( 65535 )时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。

遍历ziplist数组
因为记录了前一个节点的长度,因此从表尾向表头的遍历过程就是双指针维持空窗,在前面用了lend字段去标识最后一个节点,所以只要获得了最后一个content的地址减去前一个地址长度,就是当前内存地址,依次内推遍历所有content。

如图:

Hash

Redis 的字典使用哈希表作为底层实现, 一个哈希表里面可以有多个哈希表节点, 而每个哈希表节点就保存了字典中的一个键值对。

字典

typedef struct dict {
   // 类型特定函数
   dictType *type;
   // 私有数据
   void *privdata;
   // 哈希表
   dictht ht[2];
   // rehash 索引
   // 当 rehash 不在进行时,值为 -1
   int rehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;

image.png

Hash表结构

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

image.png

Hash表节点

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

image.png

这3者关系如图,即字典包含Hash表,表包含节点,节点包含键值对。

哈希算法
当要将一个新的键值对添加到字典里面时, 程序需要先根据键值对的键计算出哈希值和索引值, 然后再根据索引值, 将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。

解决键冲突
Hash算法,不同的key计算出来的地址也不一定完全不同,可能计算在了同一个key上,我们称之为键值冲突。

在Redis当中使用链地址法来解决键冲突:即每个Hash表节点上有个next节点,多个哈希表节点可以用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用
这个单向链表连接起来, 这就解决了键冲突的问题。

另外在Hash节点当中只有next属性记录了表头位置,为了效率考虑,因此插入的位置都链表的表头。

Hash表的刷新(rehash)

基础准备:负载因子
负载因子表示一个散列表的空间的使用程度,有这样一个公式:initailCapacity*loadFactor=HashMap的容量。

所以负载因子越大则散列表的装填程度越高,也就是能容纳更多的元素,元素多了,链表大了,所以此时索引效率就会降低。

反之,负载因子越小则链表中的数据量就越稀疏,此时会对空间造成浪费,但是此时索引效率高。

当随着对Hash表的操作,键值对越来越多或越来越少,为了让Hash的负载因子维持在一个合理的范围,需要对Hash进行收缩或者扩展操作。

1.首先需要进行计算:

扩展操作:扩展的ht[1]的大小为ht[0]的节点数量的2的次方,即ht[0].used*2
收缩操作:即ht[1].used=ht[0].used
2.将ht[0]的键值对重新计算Hash复制到ht[1]上

3.释放掉ht[0],将ht[1]变为变为ht[0],并新建ht[1]一个全新的表。

渐进式Hash表的刷新(rehash)
上面阐述了Hash表为了保证查询的效率和空间碎片的平衡,重新rehash了来,但是当数量过大,比如上千万的rehash,进行rehash操作就导致内存泄露或者数据库宕机问题。

因此真正的rehash过程是渐进式的,具体步骤如下:

1.为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
2.在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
3.在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对rehash 到 ht[1] ,当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
4.随着rehash不断的推进,最终会在某个时间进行刷新完成。并将rehashidx标记为-1。

渐进式Hash表的刷新(rehash)时操作
在rehash期间进行操作时,删除,修改和查找操作都会在ht[0],ht[1]同时操作一遍,如果ht[0]没有查找到的值,会在ht[1]里再一次查找。

在rehash操作期间的新增操作都只会在ht[1]里进行,这样保证了ht[0]的值只减不增。

Set

Hashtable

如图所示,Set的本质其实就是一个Hashtable,是String类型的无需集合,基于Hashtable实现,时间复杂度为O(1)

ZSet

跳跃表

跳跃表数据结构如上图

zskiplist

image.png
在zskiplist右边的是节点zskiplistNode,该结构属性包含如下:

跳跃表节点对象

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

image.png

层(level):

数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。每次就创建一个新的跳跃表的时候,程序会根据幂次定律生成一个1到32之间的数字,这个数字就代表了层的高度。
如图

为什么要有层级


拥有层级是为了快速查找,二分法查找。
只不过在这里层级变成了我们的数组下标,

1.先从最高层级开始寻找层级中间开始寻找。
2.如果不在上层级,则在下层级。下层级则将toplevel=midlevel
3.然后又从0-toplevel这个层级中分割。看需要寻找的数值大于还是小于这个区间,
4.直到想要找的数等于当前层级对应的数值。
注意,这里的层高又算法生成,不是图上所示中间层高,自寻查询幂次定律

跨度

层的跨度,用于记录两个节点之间的距离:
两个节点之间的跨度越大, 它们相距得就越远。
指向 NULL 的所有前进指针的跨度都为 0 , 因为它们没有连向任何节点。

用于计算在节点的排位位,比如:
从节点A到到节点B的距离为2,节点B到C的距离为3,则该数据存储在此跳跃表中的排位为5。

有了排位就可以对数据进行排序。

backward

后退指针,每个层级数组之间的只有一个后退指针,用于从表尾向表头查找,实际用处,二分查找数据。

score和obj

score是一个double的浮点数据类型,决定了数据存储哪一个节点数组,从小到大排列数据。obj是一个指针,指向字符串,而字符串对象里则有一个SDS的值。

在一个跳跃表对象当中,成员对象必须是唯一的,但是分值却是可以相同的。

当分值相同的时候,会根据成员对象的长度决定所处的字节数组。

比如,当三个对象的score都是11时,那么当对象length小的会排在靠近表头的字节数组里,length大的则靠近表尾。

注意:
1.层高不代表Node的个数,层高1-32,不代表只有32个节点。节点层高可以相同,是由算法生成出来了的,层高越大,Node数量越少,层高越小,Node数量越多。
2.层高里不存存储任何数据。obj里才有数据
3.层高不代表层级。

最后,以下图路径讲解跳跃表流程。就从第一个节点的L4开始举例。细小红线

1.header-找到第一个节点L4
2.根据前进指针指针找到第二个Node节点的L4
3.到达L4后,当前指针对应的层高不属于1层级,则往下寻找,在2层高找到L1的层级。
4.找到Node3节点,继续找到Node4节点
5.最后找到NULL结束。

跳跃表的gif动图讲解。

参考文章:
Redis 底层中的 SDS 以及 对象系统简介
Redis源码剖析和注释(七)— 快速列表(quicklist)
Redis底层数据结构之Hash
Redis Zset类型跳跃表算法实现
跳跃表详解
https://blog.csdn.net/qq_35059264/article/details/118023710

  • 9
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值