redis中的数据类型

Redis的数据类型:

本篇博客将要介绍redis中的数据类型。我们先总体概括一下redis中数据类型的大致情况,然后我们好做统一的讲解,首先需要区分的是Redis中的数据类型与数据结构:

redis的底层的数据结构有哪些:

1.SDS(动态字符串)
2.链表类型(LinkedList , ZipList , QuickList)
3.字典(Dict)
4.跳跃链表(SkipList)
5.整数集合(intset)

redis的基本数据类型有哪些:

1.String类型
2.List类型
3.hash类型
4.Set类型
5.Zset类型

redis中基本数据类型底层均是利用以上的数据结构中一种或者是多种实现的,首秀先来介绍下对应的关系:

image-20230818171810930

Redis底层的数据结构:

1.sds动态字符串:

我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:

1.获取字符串长度的需要通过运算
2.非二进制安全(如果字符串中也有\0字符那么就会认为字符串结束,就会出现问题)
3.不可修改

基于以上的缺点Redis自己构建了新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。

Redis是C语言实现的,其中SDS是一个结构体,源码如下:

image-20230818185434426

查看一下redis底层的SDS动态字符串的数据结构:

image-20230818185559926

例如,一个包含字符串"name"的sds结构体如下:

image-20230818185711107

SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为“hi”的SDS:

image-20230818185729414

假如我们要给SDS追加一段字符串“,Amy”,这里首先会申请新内存空间:

  • 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;

  • 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配:

image-20230818190022979

2. 链表:

redis当中链表又包含了LinkedList,ZipList,QuickedList与SkipList.

2.1 ZipList:

ZipList 是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。

image-20230818190638686

其中包含一些标识符,下面给出对应的解释:image-20230818190947326

注意:Entry 长度是不固定的,那么我们怎么来进行寻址处理,这就需要介绍Entry的数据类型:

Entry:

image-20230819091031377

1.previous_entry_length:前一节点的长度,占1个或5个字节。
    (1)如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
    (2)如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,
        后四个字节才是真实长度数据
2.encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
3.contents:负责保存节点的数据,可以是字符串或整数

这样的数据结构便适合正向遍历与逆向遍历:

1.正向遍历:
      如果知道一个节点的的起始地址,可以通过加上previous_entry_length字节数+encoding字节数+content长度来知道下一个节点的地址起始位置
2.逆向遍历:
      如果知道一个节点的起始地址,可以通过数据结构里的previous_entry_length知道前一个节点的长度(这里求的是值,也就是自用字节表示出来的数),通过当前的起始地址-previous_entry_length就可以知道前一个结点的起始地址。

注意ZipList的连锁更新问题:
zipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:
如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值,如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据:

现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:

但是此时如果来了一个entry正好等于254bytes,这样后一个字节previous_entry_len就需要更改,采用5个字节表示前一个字节长度,以此类推就会导致后面都会改变这就是连锁更新问题:

image-20230819091329017

ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生,但是概率会很低,因为满足条件很苛刻,因为是需要连续的不断都是接近表示字节的边缘数据才会发生连锁更新的问题。

2.2 QuickList:

问题1:

ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?

​ 答:为了缓解这个问题,我们必须限制ZipList的长度和entry大小。

问题2:但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?

​ 答:我们可以创建多个ZipList来分片存储数据。

问题3:数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?

​ 答:Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList。

image-20230819091523782

为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。
如果值为正,则代表ZipList的允许的entry个数的最大值
如果值为负,则代表ZipList的最大内存大小,分5种情况:

  • -1:每个ZipList的内存占用不能超过4kb
  • -2:每个ZipList的内存占用不能超过8kb
  • -3:每个ZipList的内存占用不能超过16kb
  • -4:每个ZipList的内存占用不能超过32kb
  • -5:每个ZipList的内存占用不能超过64kb

除了控制ZipList的大小,QuickList还可以对结点的ZipList做压缩处理。通过配置项list-compress-depth来进行控制。因为链表一般都是从首尾访问较多,所以首尾是不进行压缩的。这个参数是控制首尾不压缩的节点个数:

1. 0: 特殊值,代表不压缩
2. 1:表示QuickList的首尾结点各有1个结点不进行压缩,中间结点压缩
3. 2:表示QuickList的首尾结点各有2个结点不压缩,中间结点压缩
4.依次类推

image-20230819093137622

以下是QuickList的和QuickListNode的结构源码:

image-20230819093242282

image-20230819093447014

2.3 SkipList:

SkipList就是我们常说的跳跃链表,简称是跳表。

因为QuickList与ZipList从中间查询效果就不怎么样了:
SkipList用来解决从中间进行查询的缺点:

1.元素按照升序进程排列
2.节点可能包含多个指针,指针的跨度不同

image-20230819093714391

数据结构如下:

image-20230819093727361

zkiplistNode:
   1.ele:代表当前节点的存储的值
   2.core:代表的是当前节点的分数
   3.backward:代表前一个结点的指针,前一个结点的数据类型是zskiplistNode类型
   4.zkiplistLevel:代表的是下一个结点的指向,其又是由多级指针组装成的,有一个level数组
zkipList:
   1.zkipListNode:包含头尾结点的指针
   2.length:头尾结点的数量
   3.level:最大的索引层级

多级索引数组就是例如上图一号结点对应四级指针,而2号结点只有1级指针,所以每一个结点可能索引指针的维度是不同的,所以采用数组进行表示,例如:

image-20230819094411648

3. Dict字典:

我们知道Redis是一个键值类型(key - value)的数据库,我们可以根据键实现快速的增删改查,而键与值的映射关系正是通过Dict来实现的:
Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict):

image-20230819100218235

dictht: 代表的是hash表,注意hash表中的 used的数量可能超过size,会出现hash碰撞的情况,这个时候我们采用同一个链表将hash碰撞的值连接起来,称之为拉链法。

dictEntry:就是hash表中的元素值,key一般是指向一个字符串,value是一个联合体,里面可以存val,也可以存有符号的64位整数,无符号的64位整数,double类型的数据。还有一个next,因为要形成拉链法所以得有指向下一个hash冲突元素的地址。

当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。其实还是拿到余数的原理。如下是一个例子:新元素永远都插入到链表的链头:

image-20230819100326724

对于Dict类型还有总领的一个数据结构:

image-20230819100411950

image-20230819100418735

Dict的扩容操作:(在redis的源码dict.c中可以查看到

Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降:

因此Dict在每次键值新增时候都会检查相应的负载因子(loadFactory = used /size),满足以下两种情况时候会触发哈希表的扩容的操作:

1.hash表的loadfactory >= 1,并且服务器没有执行bgsave就是持久化操作时候或者BGREWRITEAOF等后台进程时候
2.哈希表的loadfactor > 5 实在忍无可忍

处理对于hash进行扩容的操作外,还需要对于hash表进行收缩操作,也就是每次删除元素时候,也会对负载因子做响应的检查,当LoadFactor < 0.1时候,会做哈希表的收缩操作:

新增的代码:(去判断是否需要进行扩容的操作)

image-20230819101218525

删除的操作:hash表的收缩的操作:
image-20230819101312902

注意Dict收缩以及扩容会涉及到dict[1]中的数据的扩容收缩的数据迁移,redis在做数据迁移的时候并不是一次性完成的,因为假设Dict有成百上千的数据,如果都等着数据迁移完成,则redis的主线程会发生响应的阻塞,所以我们在看dict.c源码时候并没有for循环迁移的操作。

image-20230819101537595

Dict的过程是渐进式的rehash的过程。

这中渐进式的过程是每次执行新增,查询,修改,删除操作时,都检查一下dict.rehashidx是否是大于-1,如果是则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并将rehashidx++,直至dict.ht[0]的所有数据都能rehash到dict.ht[1].

注意:在做rehash过程当中,新增操作,则直接写入到ht[1]当中,查询,修改以及删除则会在dict.ht[0]和dict.ht[1]中依次进行查找,这样可以确保ht[0]的数据只增不减,随着rehash的结束。

Redis的基本数据类型:

1. String基本类型:

String类型的底层实现原理:

1)其基本的编码方式是RAW,是基于简单动态字符串的SDS实现的,存储上限制为512mb:

image-20230819102811101

2)如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高,而RAW是两段不同的内存空间:

image-20230819102852401

为什么是44不是其他的数,我们可以来计算一下大小:

我们可以看到 len 占用一个字节,alloc占用一个字节,flags占用一个字节,'\0'占用一个字节,buf占用44个字节加起来是48个字节,然后RedisObject占用16个字节刚好是64个字节。Redis底层内存分配内存会根据2的n次方去分配内存,不会产生内存碎片。

3)如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了。因为数字可以转换成二进制位进行表示。

总结:

image-20230819103022984

2. List类型:

有上面链表的预备知识,我们来看一下redis中list数据结构的底层实现原理。redis的List结构类似于一个双端链表,可以从首部,尾部操作列表中的元素:

在redis3.2版本之前,redis采用的是ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节时候采用ZipList编码,超过则采用LinkedList编码。

在redis的3.2版本之后,Redis统一采用QuickList来实现List:

image-20230819095132777

image-20230819095142270

image-20230819095150103

注意:传入的参数是不同的,就是where参数是不同的,前面的client * 就是封装客户端传递过来的数据,包括客户端传递过来的执行指令。

3. Set类型:

以看出,Set对查询元素的效率要求非常高,思考一下,什么样的数据结构可以满足?

HashTable,也就是Redis中的Dict,不过Dict是双列集合(可以存键、值对)

为了查询效率和唯一性,set采用HT编码(Dict)。Dict中的key用来存储元素,value统一为null。
当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,Set会采用IntSet编码,以节省内存:

image-20230819103452469

真实结构如下:

image-20230819103506527

image-20230819103523089

4. Zset类型:

Zset就是我们常说的SortedSet,其中每一个元素都需要指定一个score和member值:

1.可以根据score值排序先后
2.memeber必须唯一
3.可以根据memeber查询对应的分数

因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。之前学习的哪种编码结构可以满足?

1.SkipList:可以排序,并且可以同时存储score和ele值(member)
2.HT(Dict):可以键值存储,并且可以根据key找value

所以redis在实现的时候采用的是两者都要方式,同时包含两种数据结构

image-20230819103913895

底层的数据结构如下:

image-20230819103935814

当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件:

元素数量小于zset_max_ziplist_entries:默认值281
每个元素都小于zset_max_ziplist_value字节:默认值64

底层的初始创建的源码:

image-20230819104639925

之后后续的检查会在zsetAdd方法中进行检查,会判断是否触发类型转换的条件,触发则产生类型的转换:

image-20230819104655654

意:

ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:

1.ZipList是连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
2.score越小越接近队首,score越大越接近队尾,按照score值升序排列

image-20230819104751184

5. hash类型:

底层的数据结构:
底层实现方式:压缩列表ziplist 或者 字典dict

当Hash中数据项比较少的情况下,Hash底层才⽤压缩列表ziplist进⾏存储数据,随着数据的增加,底层的ziplist就可能会转成dict,具体配置如下:

hash-max-ziplist-entries 512
hash-max-ziplist-value 64

当满足上面两个条件其中之⼀的时候,Redis就使⽤dict字典来实现hash。
Redis的hash之所以这样设计,是因为当ziplist变得很⼤的时候,它有如下几个缺点:

1.每次插⼊或修改引发的realloc操作会有更⼤的概率造成内存拷贝,从而降低性能。
2.⼀旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更⼤的⼀块数据。
3.当ziplist数据项过多的时候,在它上⾯查找指定的数据项就会性能变得很低,因为ziplist上的查找需要进行遍历。

image-20230819104941657

底层的源码:

image-20230819104950869

posted @ 2023-08-25 17:08  LycCj  阅读(37)  评论(0编辑  收藏  举报