从Redis底层的数据结构来说说为什么读写数据这么快
Redis是一个C语言编写的非关系型数据库,与MySQL关系型数据库不同的是,Redis的数据是存储在内存中的,所以说使用Redis读写数据非常的快!因此Redis常用来的做缓存。当然,之所以Redis读写数据这么快,出了它的数据存储在内存中这个原因以外,还与它的底层数据结构有关!这篇文章就从Redis底层数据结构来分析为什么读写数据这么快。
String类型
可以说我们平常操作最多的数据类型就是字符串了,对于String类型我们最常用的命令就是set 和get , 一般常用在订单编号,或者需要计数的场景,比如用户的访问次数、热点文章的点赞转发数量等等。
在C语言中字符串是以空字符结尾的字符数组来表示的,但是Redis并没有直接使用C语言的字符串,而是自己构建了一种叫做**简单动态字符串(SDS)**的抽象类型,来作为Redis默认字符串表示。而String类型的key在底层就是使用SDS来实现的。
我们来看一下SDS的定义:
通过SDS的定义可以看到,SDS数据结构实际上就是封装了一个char类型的数组,同时使用两个成员变量分别来记录字符串长度和数组中未使用的空间长度。
你可能会想为什么不直接用C语言本身的字符串数据类型,干嘛非得创建一个新的字符串数据结构?
接下来就说一说SDS和C字符串的区别:
-
C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符’\0’。而SDS的数据结构也遵循了C字符串以空字符结尾的惯例,这样可以直接复用C字符串函数库里面的函数 -
除此之外,SDS封装了自身字符串的长度len以及未使用空间大小free,使得获取字符串长度的时间复杂度为O(1) -
SDS使用free来记录未使用空间大小,杜绝了发生缓冲区溢出的可能性,也同时减少了修改字符串时带来的内存重分配次数。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略
-
空间预分配 用于优化SDS的字符串增长操作。当对SDS进行修改并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须的空间,还会为SDS分配额外的未使用空间
- 修改后SDS长度小于1MB,则len和free一样大
- 修改后SDS长度大于等于1MB,则free为1MB
通过空间预分配策略,可以减少连续执行字符串增长操作所需的内存重分配的次数。当扩展SDS空间之前,会先检查free空间是否够用,如果不够才继续扩展空间。 -
惰性空间释放 惰性空间释放用于优化SDS的字符串缩短操作,当SDS需要缩短保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。 -
二进制安全。redis使用SDS的buf字符数组不是用来保存字符,而是用来保存一系列二进制数据,并使用len属性而不是空字符来判断字符串是否结束
总结一下就是:
- redis只会使用C字符串作为字面量,在大多数情况下,redis使用SDS作为字符串表示
- 相比C字符串,SDS具有以下优点
- 获取字符串长度时间复杂度为O(1)
- 缓存区不会发生溢出
- 减少修改字符串长度时所需的内存重分配次数
- 二进制安全
- 兼容部分C字符串函数
List类型
存放多个元素的容器,我们最常使用的就是list列表这个类型,最常用的命令有lpush 、rpush 、lrange 、llen 等。List底层实现是一个链表,所以一般应用订阅场景,比如微信文章、订阅公众号。
实际上List列表类型的底层的实现是一个链表
-
节点结构体定义 -
链表结构体定义
通过节点和链表的结构体定义我们可以看出,List数据类型的底层实现实际上是一个双向链表
但是我们要知道维护一个双向链表成本是比较高的,如果说当一个list类型的key只有少量的列表项的时候,再使用双向链表去实现一个List类型,就显得性价比有点低了。所以这种情况Redis会采用压缩列表来实现,从而节约内存。
压缩列表是Redis为了节约内存而实现的一种数据结构,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。
我们举个例子解释一下各个字段什么意思
既然压缩列表是由一个个节点构成了,那么我们再来看一下Entry节点的构成,每个压缩列表节点可以保存一个字节数组或者一个整数值
我们说压缩列表是由连续的内存块构成,在查询数据方面,可以说和数组类似,效率比较高,但是如果要进行插入或者删除操作时,就会导致牵扯到元素移动更新的问题,最坏的情况就是,我们Entry节点结构中有previous_entry_length属性记录前一个节点的长度,如果说插入的新节点长度比较大,就会导致后一个节点的previous_entry_length属性由1个字节扩展为5个字节,进而影响到后面的节点,造成连锁更新反应,连续多次对压缩列表执行空间重分配的操作。
Hash类型
Hash类型的key常用来存储对象数据,最常用的命令有hset 、hget 、hlen、hdel 等。Hash类型常用在存储用户、商品信息业务场景。
Hash类型的key底层实现实际上使用的是字典,而字典的底层实现是哈希表。提到哈希表就很熟悉了,Hash类型类似于之前讲过的HashMap集合的底层使用,差不多也是使用数组+链表的形式实现的哈希表。
我们先来看一下哈希表结构定义:
哈希表里面可以有多个哈希表节点,每一个哈希表节点保存了字典中的一个键值对。
再看一下节点结构体定义:
了解了节点的数据结构和哈希表的数据结构,我们说字典的底层实现就是哈希表,那再来看一下字典的结构体定义:
我们看到,字典中ht属性包含两个哈希表,一般情况字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用
既然底层使用的哈希表,那么当要讲一个新的键值对添加到字典中时,程序需要先根据键值对计算出hash值和对应的数组中的索引值,然后再根据索引值,将包含新键值对的节点添加到哈希表数组指定的索引位置。同样的也会发生hash冲突的问题,而redis的哈希表也是使用链地址法来解决hash冲突,只不过和JDK 8之后的HashMap相比,redis采用的是头插法。每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这样就解决了键冲突问题。
随着对哈希表中保存的键值对的添加或者删除,为了让哈希表的负载因子维持在一个合理的范围之内,需要对哈希表的大小进行相应的扩展和收缩。扩展和收缩哈希表的工作可以通过rehash重新散列操作来完成:
- 为字典和ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量。
- 如果是执行的扩展操作,ht[1]大小为第一个大于等于ht[0]键值对数量*2的2的n次幂
- 如果执行的的收缩操作,ht[1]大小为第一个大于等于ht[0]键值对数量的2的n次幂
- 将保存在ht[0]中的所有键值对rehash到ht[1]上面,rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]的哈希表的指定位置上
- 所以键值对迁移完成之后,释放ht[0],并将ht[1]设置为ht[0],并在ht[0]创建一个空白哈希表,为下一次rehash做准备
那么什么时候才会进行rehash操作呢?当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展:
- 服务器目前没有执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1
- 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5
负载因子计算公式:
根据BGSAVE命令或者BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或者BGREWRITEAOF命令的过程中,redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提供执行扩展操作所需的负载因子,从而尽可能的避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度的节约内存。
当哈希表的负载因子小于0.1时,会执行收缩操作。
但是当哈希表中键值对数量太多时,如果一次性集中的进行rehash操作就会对服务器负载过大,所以为了避免rehash对服务器性能造成影响,服务器并不是一次性rehash,而是分多次、渐进式的rehash
渐进式rehash的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作平均分摊到对字典的增删改查的操作上。
Set类型
Set类型类似于HashSet集合,Set类型是一个无序集合,而且集合中的元素是不允许重复的,同时可以进行集合之间的运算。常用的命令有sadd 、srem 、smembers 、sismember 、scard 。一般常用在抽奖小程序、关注好友列表等业务场景中。
set类型底层的实现实际上使用的是一个整型数组:
contents这个整型数组中每一个元素都是整数值,并且按照值的大小从小到大排列,并且数组中不包含重复项。
数组中的整数值分为8位、16位、32位,这取决于encoding编码方式这个属性。如果将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合中。
需要注意的是,数组大小不会发生降级!
ZSet类型
与Set类型相比,ZSet类型增加了一个权重参数score,使得集合中的元素能够按 score 进行**有序排列。**最常用的命令有zadd 、zrange 、zscore 、zrem 、zcard 等。ZSet类型一般应用在根据商品销售情况进行排序、热搜等业务场景中。
ZSet类型的底层实现实际上使用的跳跃表。我们先看一下跳跃表结构体定义:
我们发现跳跃表是由一个个跳跃表节点组成的,跳跃表维护了表头和表尾两个指针,同时还维护了表中节点的数量,以及表中层数最大的节点的层数。
接下来再看一下跳跃表节点的结构体定义:
每一个跳跃表节点都有一个level数组,数组可以包含多个元素,每一个元素都包含一个指向其它节点的指针,称为前进节点。每次创建一个新的跳跃表节点的时候,程序都会根据幂次定律(越大的数出现的概率越小)随机生成一个1~32之间的数作为level数组的大小。可以通过这些层来加快访问其它节点的速度,一般来说,层数越多,访问其它节点的速度就越快。
之所以叫做跳跃表,就是因为有level数组的存在,不同的层高也就代表着当前节点可选择的跨越的距离也不同,层的跨度就是用来记录两个节点之间的距离,实际上是用来计算排序的。
每个层都有一个指向表尾方向的前进指针,用于从表头向表尾方向访问节点。当然对应的也有一个后退指针,从表尾向表头方法访问节点,不能一次跳过多个节点,每个节点只有一个后退指针,只能后退到前一个节点
我们说与Set类型相比,多了一个分值score 这个参数,score是一个double类型的浮点数,跳跃表中的所有节点就是按照score从小到大来排序的。而obj 这个成员属性是一个指针,指向字符串对象,而这个字符串对象则保存着一个SDS值,这才是真正的value值。在同一个跳跃表中,多个节点可以有相同的分数值,但是每个节点的成员对象必须是唯一的,分数值相同时,按照成员对象从小到大排序!
|