前言:本文redis版本:7.2.4
本文语雀原文地址(首发更新):https://www.yuque.com/wzzz/redis/xg2cp37kx1s4726y
本文CSDN转载地址: https://blog.csdn.net/u013625306/article/details/136842107
1. 常见的数据结构
Redis常见的数据结构有5种: String,List, Set, ZSet, Hash,这只是对外的数据结构
Redis对内的底层数据结构具体实现方法还有如下几种:int, raw, embstr, linkedlist, ziplist, hashtable, intset, skiplist,quicklist,listpack
他们之间的映射关系如下
2. Redis对象基础知识
Redis对象,就是不管你是什么类型的redis变量,都必须要有这个对象,可以想象是一种对象头,就比如你java中所有的类,不管是系统的,新建的,他们都有一个Object父类
Redis对象的定义如下:
typedef struct redisobject{
// 类型 4bit 即用于表示[String, List, Set, ZSet, Hash, Geo, HyperLogLog, Stream, Bitmaps]中的一种
unsigned type:4;
// 编码方式 4bit 即用于表示[int, embstr, raw, ziplist...]中的一种
unsigned encoding:4;
// LRU时间 24bit (相对于server.lrulock)
unsigned lru:24;
// 引用计数 32bit redis里面的数据可以通过引用计数进行共享
int refcount:32;
// 用于指向具体对象的指针 64bit,比如指向string, list等等
void *ptr;
}
一个redisobject占用的存储空间:4+4+24+32+64=128bit/8=16字节
3. String底层数据结构
3.1 int类型
首先 object encoding key
这个命令可以返回key在redis中内部的编码方式
当保存的字符串是数字类型时,长度≤19位,此时内部就会用int表示
当保存的是个20位数字时
当保存的字符串首位数字是0时,不会用int表示,因为int前面的0存可以存,但读不出来呀
3.2 embstr类型
embstr类型是redis专门用来保存短字符串的一种优化编码,其结构如下图所示
该结构中,redisobject是必须有的,每个redis对象都有。embstr使用时,只分配一次内存空间,因为redisobject和sdshdr是连续的,所以在一起分配,当embstr字符串扩大时,也就照成了要重新给embstr类型的字符串重新分配内存空间,所以redis中为了杜绝这种情况,embstr字符串是只读的,不能修改,如果修改了,就会变为raw编码方式,无论其长度是否达到了44字节
3.3 raw类型
raw类型是redis用来保存,可变长的,可修改的字符串,其结构如下图所示:
如图所示,raw方式保存字符串时,会进行两次申请内存空间,第一次是给redisobject申请,第二次是给sdshdr申请。
这样的方式,虽然比embstr多申请一次内存空间,但是这种方式当字符串长度发生扩展时,只用重新申请sdshdr的内存就行了,然后修改ptr指针,可以有效防止内存碎片。
3.4 SDS 简单动态字符串
3.4.1 3.2版本之前
SDS的结构定义在sds.h文件中,以前版本的SDS比较简单,有三个属性,分别是len, free, buf数组
// 3.0
struct sdshdr {
// 记录buf数组中已使用字节的数量,即SDS所保存字符串的长度
unsigned int len;
// 记录buf数据中未使用的字节数量
unsigned int free;
// 字节数组,用于保存字符串
char buf[];
};
3.4.2 3.2版本及之后
在3.2版本之后,根据字符串长度的不同,分别初始化不同的sds
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
3.2版本之后,会根据字符串的长度来选择对应的数据结构
static inline char sdsReqType(size_t string_size) {
if (string_size < 1<<5) // 32
return SDS_TYPE_5;
if (string_size < 1<<8) // 256
return SDS_TYPE_8;
if (string_size < 1<<16) // 65536 64k
return SDS_TYPE_16;
if (string_size < 1<<32) // 4294967296 4G
return SDS_TYPE_32;
return SDS_TYPE_64;
}
以下是raw编码方式下sdshdr8的一个字符串示例:
3.5 SDS动态字符串与C语言字符串的区别
C语言使用长度N+1的字符数组来表示长度为N的字符串,字符数组的最后一位是’\0’, 用以表示字符串的结尾,但是这种方式不能满足redis对字符串的安全性,效率和功能上的要求,所以redis在字符串上面使用了sds动态字符串来保存字符串,相比与c语言字符串,sds动态字符串有以下优点
C语言字符串 | SDS动态字符串 | SDS的优点 |
---|---|---|
C语言中没有保存字符串长度,当想获取字符串长度时,需要遍历字符串,统计长度,时间复杂度O(n) | SDS结构中保存了字符串的长度len, 当程序想获取字符串的长度时,直接访问len属性即可,时间复杂度为O(1),确保了获取字符串长度不会成为redis的性能瓶颈 | 常数复杂度获取字符串长度 |
- C语言中如果对字符串进行操作,如果是增长字符串,会导致内存的重新分配。
- 如果是截取字符串,未截取的地方需要被释放掉,如果没有释放,就会导致内存溢出
| SDS结构中,对字符串数组采用预申请,len属性记录已经使用的长度,alloc属性记录所有的长度(在3.0版本使用free记录未使用空间,3.2版本则改为alloc记录总长度),sds采用这种空间预分配和惰性空间释放两种策略,解决了字符串拼接和截取两种场景下的空间问题。 - 空间预分配:当对一个SDS字符串进行增长操作且内存不够用时,SDS会进行内存的申请,申请时,如果len≤1MB,那么就申请len+len+1的内存,也就是额外申请已使用内存len的两倍+1。如果申请时len>1MB, 那么就申请len+1MB+1的内存,每次扩增1MB。
- 惰性空间释放:用于优化SDS字符串缩短操作,当SDS的字符串进行缩短时,程序不会释放其内存,而是使用free字段记录下还有多少未使用的空间,等待以后使用
| 杜绝缓冲区溢出
减少修改字符串带来的重新分配内存的次数 |
| 可以使用所有的<string.h>中的函数 | 可以使用部分<string.h>中的函数 | |
| 只能保存文本数据 | 可以保存文本或二进制数据 | |
4. List底层数据结构
4.1 ziplist类型(压缩列表)
在说Redis的ziplist之前,先看一下什么是压缩列表。压缩列表是一种紧凑型的数据结构,目的就是为了节省内存空间,他借鉴了数组的存储思想,占用一块连续的内存空间,但又与数组有些许区别。因为数组要求每个元素占用相同的存储空间,且每个存储空间的大小依据最大的那个元素来计算。
上面图中所表示,前4个元素都浪费了存储空间,所以为了保证占用内存空间的连续性和节省更多的内存空间,可以对数组进行压缩,使每个元素的大小为实际存储的大小。但这样的话,不知道每个元素具体的大小了,代码不能判断下一个元素的起点,所以还要为每个元素增加一个字段用于记录每个元素占据的位置大小,用于标识当前元素的大小,这样代码在遍历的时候,就能判断下一个元素的起点,这就构成了最简单的压缩链表
redis的ziplist就是基于上面的压缩列表做了自己的封装,接下来我们看redis中ziplist具体的实际结构如何:
ziplist最大的好处就是节省了内存空间,但他的缺点也很多。
- 不能存储太多的元素,否则遍历效率极低
- 当新增或修改某个元素时,会重新分配内存,甚至会引起连锁更新的问题。
**连锁更新:**连锁更新就是我只更新了一个元素,但由于某个原因,导致所有元素都进行了内存重分配,导致所有元素都进行了更新,就会降低效率。
那么ziplist如何出现连锁更新问题的呢?这就要看entry节点中prevlen属性占用空间大小了,prevlen属性记录是上一个节点的大小,当上一个节点大小小于256字节时,prevlen属性需要1字节的空间来保存这个长度大小,当上一个节点大小超过256字节时,prevlen属性就需要5字节的空间来保存这个长度大小。如果当上一个节点增大超过256字节时,就会引发下一个节点的prevlen属性内存空间增大,需要为下一个节点重新分配额外的4字节空间。循环往复,有可能每一个下个节点都被导致重新分配内存空间,直至最后一个节点,就会导致连锁更新。
因此:ziplist只适合保存结点数量不多的场景。
4.2 linkedlist类型(双向链表)
redis中的linkedlist就是基于双向链表实现的。以下是redis中linkedlist具体结构图:
可以看到linkedlist中包含了3个属性:
- head:用于指向链表中的第一个结点
- tail:用于指向链表中的最后一个结点
- len:用于记录链表中所有结点的数量
除此之外,还包含了3个函数:
- dup:结点复制函数,用于复制节点
- free:结点释放函数,用于释放节点的内存空间
- match:结点比较函数,用于比较节点之间的值
linkedlist的优缺点:
linkedlist的优点 | linkedlist的缺点 |
---|---|
- 可以直接获取到头结点和尾结点,时间复杂度O(1)
- 每个结点中都有next和prev属性,可以方便的获取下一个结点和上一个结点,时间复杂度也是O(1)
- linkedlist中维护了链表的长度len,可以直接获取链表长度,时间复杂度也是O(1)
| - 双向链表中的每个结点内存空间都是不连续的,无法很好的利用cpu缓存
- 双向链表遍历的时间复杂度为O(n)
|
4.3 quicklist类型
由于ziplist和linkedlist都存在着不可避免的缺陷,所以在redis3.2版本之后,引入了一种新的数据结构:QuickList(快速列表)。List对象的底层数据结构,也由ziplist和linkedlist变为QuickList。
QuickList结合了ziplist和linkedlist的优点,它是一个压缩列表ziplist为结点的双向链表,以下是它的数据结构图:
可以看到quicklist的数据结构和linkedlist总体上比较相似,都是包含了一个双向链表,并且都维护了双向链表的head结点和tail结点,以及结点的数量,最大的区别就是在双向链表中存储的value值发生了变化,linkedlist中存储的值是实际的值,而quicklist中存储的值是一个指向ziplist的指针。该ziplist的最大大小由quicklist的fill属性进行限制,当某个节点的ziplist大小超出了该限制,就会在链表中新建一个ziplist保存到新的quicklist结点中。
4.4 listpack类型(紧凑列表)
5. Set底层数据结构
5.1 intset
intset适用于保存都是整数的集合,根据编码的不同可以保存不同大小范围的整数,如果三种编码都无法保存这个整数,或者有元素不是整数,就会升级为hashtable的编码方式进行编码。
5.2 hashtable
HashTable(哈希表)是一个保存key-value键值对的结构体,它与Java中的HashMap相似,每个key都是唯一的,可以通过key去查询和修改其value值。
在redis中,hashtable底层实现是基于数组的,数组的每个元素相当于一个bucket桶,当通过哈希散列函数计算出key对应的hash值(也就是数组下标)时,就会将该元素放入在数组中对应的下标位置,当发生hash冲突时,就会采用链地址法,在对应的bucket桶下形成一个链表来保存这些数据。
常见的解决hash冲突的方式还有以下几种:
- 链地址法:形成一个Entry链表来保存数据
- 二次hash:用另一种函数重新计算hash值,尝试映射到其他的bucket中
- 公共溢出区:将产生Hash冲突的元素统一存放到一个公共的区域
- 开放定址法:冲突的Entry按照一定的规则(线性探测、平方探测)在hash表中找到下一个空闲的bucket存放
下面是hashtable的结构图:
从结构中可以看到,映射到相同数组下标位置的元素,会形成链表,当redis再次查询时,会先计算数组下标位置,然后再沿着链表进行遍历查找,时间复杂度为O(1)+O(n)=O(n).
哈希表使用了链式哈希法虽然很好的解决了哈希冲突的问题,但随着结点越来越多,链表上的结点元素也越来越多,就会照成查询时的成本变高,会大大降低查询效率。
redis为了解决hash冲突,在数据量达到一定阈值的时候,就会对hash表进行rehash(重新计算hash)操作,会将数组进行扩容,对每个元素重新计算hash,然后重新映射到新位置。
触发rehash的条件有两个:
- 负载因子≥1,并且服务器没有执行bgsave命令或者bgrewriteaof命令,就会执行rehash操作,但并一定100%执行
- 负载因子≥5,说明此时的hash表冲突已经很严重了,将强制执行rehash操作
负载因子 = hash表中entry结点的总数量 / 数组大小
redis为了解决hash冲突,在进行rehash的时候,也设计了一种dict结构,dict中包含了两个dictht哈希表ht[0]和ht[1],默认情况下,这两个哈希表只使用一个。以下是dict的结构:
当我们第一次新增数据的时候,元素结点会存放在ht[0]中,当ht[0]中的结点负载因子超过阈值时,就会重新计算结点的hash值,将其放入到ht[1]中。
rehash步骤:
- 给哈希表ht[1]分配空间,分配空间的大小为 2n中第一个>ht[0].used * 2的n值。比如上图中ht[0].used * 2=22, 而25>22>24,所以n=5,即分配的空间大小为32.
- 将ht[0]中的entry结点经过rehash重新运算后,迁移到ht[1]中
- 迁移完成后,释放ht[0]的内存空间,并把ht[1]设置为ht[0]
- 创建一个新的ht[1],为下次扩容做准备
通过扩容操作,并进行rehash操作,可以有效地解决hash冲突的问题,但当hash表中的元素很大时,就会导致rehash动作耗时很长,而redis是单线程的,一旦耗时过长影响到后续的操作,那就是得不偿失的。因此在redis中没有采用直接全部rehash,而是采用渐进式rehash操作来进行。
**渐进式rehash:**不一次性把所有的数据rehash到新的哈希表中,而是在每次对哈希表的元素进行增删查改的时候,顺带把某个数组下标的链表上所有的结点进行rehash操作,直至把所有的元素rehash。在新增数据时,对于新增的数据将其放入ht[1]中,对于查询数据时,先去查询ht[0],再去查询ht[1]。在渐进式rehash操作时,两个dict中都会存储数据,但ht[0]中的数据会越来越少,直至全部rehash进入到ht[1]中。
以下是rehash的结构图:
6. ZSet底层数据结构
6.1 ziplist类型
ziplist的具体结构在4.1节已经介绍过了。但ZSet中具体的做法是将元素和其对应的分值分别保存到两个相邻的entry的content中。
当ziplist作为zset的底层数据结构时,每个集合元素使用两个紧挨在一起的压缩列表结点来保存。第一个结点保存元素成员,第二个结点保存结点的分值。
redis选择ziplist作为zset的底层数据结构也是有限制的:
- 有序集合中保存的元素数量<128个
- 有序集合保存的所有元素的长度小于64字节
否则就会使用skiplist作为zset的底层实现。
6.2 skiplist类型
ziplist俗称跳表,跳表是一个基于有序链表实现的可以快速查找元素的数据结构。下面是一个普通的有序链表的结构图:
该链表中如果想找到靠后的结点,就需要挨个遍历结点,时间复杂度为O(n)。那么有没有办法提高查询效率呢,当然是有的。既然我们的链表是有序的,我们可不可以跳过前面的结点呢?这时候我们的初级链表就出来了(一个含有两层链表的跳表, 相当于带了一层索引):
这样我们查询12的话:
- 只有一层链表:1->3->6->8->9->12,需要遍历6次,时间复杂度O(n)
- 有两层链表:1->6->9->12,需要遍历4次,时间复杂度O( n 2 \frac{n}{2} 2n)
- 同理,对于一个n层链表来说,时间复杂度为O( l o g n logn logn)
在上面的结构中,跳跃表遵守上一层结点是下一层结点的一半,但是在发生大量插入元素的时候,就会发生频繁调整跳跃表这个操作。
所以在redis中设计了一种升层算法,对于每一个新增的结点,初始化其层数为1,然后循环以下算法步骤:算法算出一个0~1之间随机数,如果随机数<0.25,则层数增加一层,然后循环这个步骤,直到生成的随机数>
1
4
\frac{1}{4}
41,如果循环到第三次退出循环时,那么这个新增节点的层数就是2。这个算法对于每个结点来说,新增一层的概率是0.25,层数越高,概率越小。对于新增的结点,只需要修改其前向指正和后向指针即可,其它结点不受影响。
综上所述,通过将有序集合的部分节点层,从最上层结点开始依次查找,如果本层的next结点大于我们想要找的结点,那么就自降一层再依次查找,如果找到了就返回其值,找不到就返回null。如果跳跃表的层数很多的话,那么就会跳过很多节点,从而提升查询的效率,这就是跳跃表的思想。
跳跃表有以下性质:
- 跳跃表有很多层结构组成,最底层的结点个数为跳跃表的长度
- 跳跃表有一个头结点,头结点中默认有一个32层的结构(redis默认层数最高32层),每层的结构都包含一个指向本层下一个元素的next指针
- 除了头结点外,有最高层的结点的层数称为跳跃表的高度
- 每层都是一个有序链表,随着数据score依次递增
- 除了头结点外,最底层的链表包含所有的元素
在跳跃表中,对于一个新增的元素来说,其新增结构如下:
7. Hash底层数据结构
7.1 ziplist类型
当元素较少时,使用ziplist保存hash结构的元素,当元素个数超过一定的限制后,使用hashtable来保存。
7.2 hashtable类型
hashtable在上述5.2章节中已经介绍。
7.3 listpack类型
本文参考文献:
- https://blog.csdn.net/YTREE_BJ/article/details/119415521
- https://zhuanlan.zhihu.com/p/619828101
- https://www.jianshu.com/p/674c9635b2b9
- https://baijiahao.baidu.com/s?id=1731524214636496899&wfr=spider&for=pc
- https://www.jianshu.com/p/674c9635b2b9
- https://blog.51cto.com/u_39029/6501913
- https://blog.csdn.net/u013277209/article/details/125998869
- https://blog.csdn.net/qq_32099833/article/details/133889188