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

勿以浮沙筑高台


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. 作用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,当内存不够用时就会释放空间。

  1. 作用2:防止内存溢出
    在原本的C语言当中,倘若2个相邻的内存空间C1和C2
    在这里插入图片描述
    如果开发者忘记了对C1的内存空间进行扩容则会发生C2的内存空间上存储的是C1的追加内容,比如追加-hello。这时候就造成了内存溢出的问题。在C语言中我们称之为野指针。
    在这里插入图片描述
    但是在Redis当中,会对追加的内容进行判断,倘若小于free字段就会进行存储,大于则会增加free进行存储。

1.为什么要有len字段。

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

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

1.为什么是buff字段。

  1. 二级制安全
    虽然数据库一般用于保存文本数据, 但使用数据库来保存二进制数据的场景也不少见因此, 为了确保 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_length以字节为单位, 记录了压缩列表中前一个节点的长度
encoding编码格式
content内容

遍历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;
字段解释
type类型特定函数,为创建多态字典而设置的
privdata类型特定函数,为创建多态字典而设置的
htht 属性是一个包含两个项的数组, 数组中的每个项都是一个 dictht 哈希表, 一般情况下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只会在对 ht[0] 哈希表进行 rehash 时使用
Hash表结构
typedef struct dictht {
   // 哈希表数组
   dictEntry **table;
   // 哈希表大小
   unsigned long size;
   // 哈希表大小掩码,用于计算索引值
    // 总是等于 size - 1
   unsigned long sizemask;
   // 该哈希表已有节点的数量
   unsigned long used;
} dictht;
字段解释
table是一个哈希表数组,里面每个元素指向Hash表节点
sizetable数组大小
sizemask哈希表大小掩码,用于计算索引值,总是等于 size - 1,因为是从0开始的
Hash表节点
typedef struct dictEntry {
   // 键
   void *key;
   // 值
   union {
       void *val;
       uint64_t u64;
       int64_t s64;
   } v;
   // 指向下个哈希表节点,形成链表
   struct dictEntry *next;
} dictEntry;
字段解释
key属性保存着键值对中的键,
next属性是指向另一个哈希表节点的指针(指向下一个内存地址)
union

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

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

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

在Redis当中使用链地址法来解决键冲突:即每个Hash表节点上有个next节点,多个哈希表节点可以用 next 指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用
这个单向链表连接起来, 这就解决了键冲突的问题。在这里插入图片描述
另外在Hash节点当中只有next属性记录了表头位置,为了效率考虑,因此插入的位置都链表的表头。

Hash表的刷新(rehash)

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

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

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

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

1.首先需要进行计算:

  1. 扩展操作:扩展的ht[1]的大小为ht[0]的节点数量的2的次方,即ht[0].used*2
  2. 收缩操作:即ht[1].used=ht[0].used

2.将ht[0]的键值对重新计算Hash复制到ht[1]上

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

used

渐进式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

字段解释
header指向跳跃表的表头节点。
tail指向跳跃表的表尾节点
level记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)
length记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。

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

跳跃表节点对象
typedef struct zskiplistNode {
	// 后退指针
	   struct zskiplistNode *backward;
	   // 分值
	   double score;
	   // 成员对象
	   robj *obj;
	   // 层
	   struct zskiplistLevel {
	       // 前进指针
	       struct zskiplistNode *forward;
	       // 跨度
	       unsigned int span;
	   } level[];
} zskiplistNode;
字段解释
Level向图所指,标记层级,每个层级包含二个属性,分别是前进指针和跨度,前进指针用于下一个内容读取的地址,跨度代表这下一个内容读取地址到现在的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
backward(后退指针BW)和前进指针相反,指向前一个内存地址
score分值,在跳跃表当中,分值从小到大进行保存
obj成员对象,存储的value
层(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类型跳跃表算法实现
跳跃表详解

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值