Redis的底层数据结构

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/yx0628/article/details/80035533

Redis 中有各种自定义的数据结构,来实现了各种功能,下面一一进行说明。

简单动态字符串SDS

Redis 没有直接使用 C 语言的字符串,而是构建了自己的抽象类型简单动态字符串(simple dynamic string)。
在 Redis 中,对于所有键,都是字符串类型,其底层实现是 SDS,而键值对的值,其实最终都是以字符串为粒度的,底层都是 SDS 实现。(比如列表,其实列表中每一项都是字符串以 SDS 实现的)。
SDS结构
SDS 结构中,包含 char 类型的数组 buf ,每个位置存储字符,最后一个位置存储空字符 ‘\0’。另外,还有 free 属性和 len 属性。free 属性的值代表未使用空间的大小,len 属性代表目前保存的字符串的实际长度,结尾的 ‘\0’ 空字符不计算在内。

SDS 的优势:
C 语言的字符串不会记录自己的长度,而是需要进行遍历获得,时间复杂度为 O(n) ,而 SDS 已经封装了 len 属性,直接读取 len 的值就可以获得长度,不需要遍历,时间复杂度 O(1) 。
C 语言字符串修改时,有可能发生缓冲区溢出;而 SDS 要修改时,API 会先检查 SDS 的空间是否满足修改的要求,如果不满足,会将 SDS 的空间扩展至执行修改的所需的大小,然后才执行实际的修改操作。

SDS的优化策略

空间预分配

空间预分配,用于优化 SDS 的字符串增长操作,当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。(这个有点类似于 Java 中的 ArrayList 的空间每次增长扩大为之前 1.5 倍大小,进行额外的空间预分配)。
具体的分配规则:
- 如果修改后的 SDS 长度 len 小于 1MB,那么程序分配和 len 属性相等的未使用空间,此时 free 和 len 的值相同。所以此时数组的实际长度为 free + len + 1byte(额外的空字符 1 个字节)。
- 如果修改后的 SDS 长度大于 1MB,那么程序分配 1MB 的未使用空间。实际长度为 len + 1MB + 1byte。
在扩展 SDS 之前,会检查未使用空间是否够用,如果足够,就不用内存重分配,直接使用剩余空间即可。

惰性空间释放

惰性空间释放,用于优化 SDS 的字符串缩短操作,当 SDS 的 API 对一个 SDS 进行缩短时,并不会立即使用内存重分配来回收多出来的字节,而是使用 free 属性将这些字节的数量记录下来,等待将来使用。
通过此策略,可以避免内存重分配,同时将来增长操作也有空间。
同时 SDS 也有相应的 API ,用来真正释放未使用空间,不用担心内存的浪费。

二进制存储

在 C 语言字符串中,’\0’ 空字符会被认为是字符串的结束,如果二进制数据中有该字符的存在,会被认为是字符串的结尾。而 SDS 由于有 len 属性的存在,使用 len 来判断字符串是否结束,而不是空字符。这样就避免了二进制数据的问题,可以用来保存图片,音频,视频等文件的二进制数据。

链表

C 语言中没有内置链表的数据结构,Redis 实现了自己的链表结构。Redis 中列表的底层实现之一就是链表。
Redis链表
每个链表节点都有指向前置节点和后置节点的指针,是一个双向链表。每个链表结构,有表头表尾指针和链表长度等信息。
另外表头节点和前置和表尾节点的后置都是 NULL ,所以是无环链表。

字典

字典是用来保存键值对的抽象数据类型。C 语言中没有内置这种数据结构,Redis 实现了自己的字典结构。
字典在 Redis 中应用很广泛,Redis 底层数据库就是用字典来实现的。任意一个键值对,无论是什么类型,都是存放在数据库的字典中的。
另外,字典还是哈希对象的底层实现之一。
结构如下:
Redis字典结构
字典的 ht[0] 和 h[1] 在 rehash 时使用。

字典的实现

字典的实现可以参考 Java 中 HashMap 的实现原理:Java集合框架——HashMap源码分析
新增时,先根据键值对的键计算出哈希值,然后根据 sizemask 属性和哈希值,计算索引值——即落入数组中的哪个位置。之后如果有一个位置多个键值对要存入时,组成单向链表即可。
这里和 HashMap 的不同之处在于,链表添加时总是添加在表头位置。因为 dictEntry 节点组成的链表没有指向链表表尾的指针,为了速度考虑,总是将新节点加在链表的表头位置。(为什么要这样,而不是遍历完整个链表后加在链表尾部,不遍历出现重复键怎么办?)

rehash

rehash 也可以参考 Java 中 HashMap 的原理。
负载因子 = 哈希表中已保存的节点数量 / 哈希表数组大小。
当哈希表中存放的键值对不断增多或减少,为了让负载因子在一个合理的范围内,需要对大小进行扩展或者收缩。(这里类似 HashMap 中的重新散列方法)
1. 字典的 ht[1] 分配空间,空间的大小由 ht[0] 已经使用的键值对数量以及执行的扩张和收缩来决定。
- 扩展操作,那么 ht[1] 分配的空间大小应是比当前 ht[0].used 值的二倍大的第一个 2 的整数幂。(比如当前使用空间 14,那么找 28 的下一个 2 的整数幂,为 32)
- 收缩操作,取 ht[0].used 的第一个大于等于的 2 的整数幂。(比如 14,那么就是 16)
2. 将 ht[0] 中的所有键值对,rehash 到 ht[1] 上面:根据新的大小来重新计算所有键的哈希和索引,映射到新数组的指定位置上。
3. ht[0] 的所有键值对都迁移到 ht[1] 之后,释放 ht[0] ,然后将 ht[1] 设置为 ht[0] ,然后在 ht[1] 处新创建空白哈希表,为下一次 rehash 做准备。

扩展和收缩的条件:

扩展的条件

  1. 服务器没有执行 BGSAVE 或者 BGREWRITEAOF 命令,并且哈希表的负载因子大于等于 1 。
  2. 服务器正在执行 BGSAVE 或者 BGREWRITEAOP 命令,并且哈希表的负载因子大于等于 5 。
    这两种情况根据是否有后台命令执行来区分,是因为在执行 BGSAVE 或者 BGREWRITEAOF 的过程中,Redis 需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术来优化子进程的使用效率。所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,尽可能避免在子进程存在期间进行哈希表的扩展操作,来避免不必要的内存写入操作,最大限度的节省内存。

收缩的条件

当哈希表的负载因子小于 0.1 时,自动开始对哈希表进行收缩操作。

渐进式rehash

如果键值对量巨大时,一次性全部 rehash 必然造成一段时间的停止服务。所以要分多次、渐进式的将键值对从 ht[0] 慢慢的 rehash 到 ht[1] 中。
具体过程:
1. 为 ht[1] 分配空间,同时有 ht[0] 和 ht[1] 两个哈希表。
2. 在字典中维持一个索引计数器变量 rehashindex ,并将其置为 0 ,表示 rehash 正式开始。
3. 在 rehash 期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作之外,还会顺便将 ht[0] 哈希表在 rehashindex 索引上的所有键值对 rehash 到 ht[1] 上,当 rehash 工作完成之后,程序将 rehashindex 的值加一。
4. 随着字典操作的不断进行,最终在某个时间点,ht[0] 的所有键值对都被 rehash 到 ht[1] ,这时程序将 rehashindex 的值置为 -1 ,表示 rehash 工作完成。
渐进式 rehash 的过程中,更新删除查找等都会在两个哈希表上进行,比如查找,先在 ht[0] 中查找,如果没找到,就去 ht[1] 中查找。而新增操作,直接新增在 ht[1] 中,ht[0] 不会进行任何的新增操作。保证 ht[0] 的数量只减不增,最终变为空表。

跳跃表

跳跃表是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。Redis 使用跳跃表作为有序集合键的底层实现之一。
跳跃表在 Redis 中,只有两个地方用到:一个是实现有序集合对象,另一个是在集群节点中用作内部数据结构。
跳跃表
跳跃表中:
head:指向跳跃表的表头节点。
tail:指向跳跃表的表尾节点。
level:记录当前跳跃表中,层数最高的节点的层数(表头节点的层数不计算)。
length:记录跳跃表的长度,即包含节点的数量。
level:每一层都有前进指针和跨度,从头到尾遍历时,访问会沿着层的前进指针进行。
BW:后退指针,指向前一个节点,从尾到头遍历时使用。
score:分值,跳跃表中的分值按从小到大排列。
obj:成员对象,各个节点保存有各个成员对象。

整数集合

整数集合是集合键的底层实现之一。当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。
整数集合是 Redis 保存整数值的集合的抽象数据结构,可以保存 int16_t ,int32_t ,int64_t 的整数值,并且集合中不会出现重复元素。
底层由数组实现,整数集合的每个元素都是数组的一个数组项,各个项在数组中按从小到大排列。length 属性记录了包含的元素数量,即数组的长度。

升级

当一个新元素添加到整数集合中时,如果新元素类型比整数集合现有的所有元素的类型都要长时,整数集合要先进行升级,然后才能将新元素添加到整数集合中。
1. 根据新元素类型,扩展整数集合底层数组的大小,并为新元素分配空间。
2. 将底层数组现有的所有元素都转换成新元素相同的类型,并将类型转换后的元素放置到正确位置上,而且放置过程中需要维持底层数组的有序。
3. 将新元素添加到底层数组中。
因为引发升级的新元素的长度肯定比现有所有元素都大,才会出现升级的情况,所以这个值要么大于所有元素,放置的位置就对应新数组的末尾;要么小于所有元素,放置的位置在数组的开头。
升级可以提高灵活性,不用担心类型错误,可以随意添加不同类型的元素。另外,可以节约内存,只在有需要的时候进行升级。
另外,整数集合不支持降级操作。

压缩列表

压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表键只包含少量列表项并且每个都是小整数值或者长度比较短的字符串时,Redis 就采用压缩列表做底层实现。当一个哈希键只包含少量键值对,并且每个键值对的键和值也是小整数值或者长度比较短的字符串时,Redis 就采用压缩列表做底层实现。

压缩列表是 Redis 为了节约内存而实现的,是一系列特殊编码的连续内存块组成的顺序型数据结构。
压缩列表
zlbytes :4 字节。记录整个压缩列表占用的内存字节数,在内存重分配或者计算 zlend 的位置时使用。
zltail :4 字节。记录压缩列表表尾节点记录压缩列表的起始地址有多少个字节,可以通过该属性直接确定表尾节点的地址,无需遍历。
zllen :2 字节。记录了压缩列表包含的节点数量,由于只有 2 字节大小,那么小于 65535 时,表示节点数量。等于 65535 时,需要遍历得到总数。
entry :列表节点,长度不定,由内容决定。
zlend :1 字节,特殊值 0xFF ,用于标记压缩列表的结束。

压缩列表节点保存一个字节数组或者一个整数值。
字节数组可以是下列值:
- 长度小于等于 2^6-1 字节的字节数组
- 长度小于等于 2^14-1 字节的字节数组
- 长度小于等于 2^32-1 字节的字节数组
整数可以是六种长度:
- 4 位长,介于 0 到 12 之间的无符号整数
- 1 字节长的有符号整数
- 3 字节长的有符号整数
- int16_t 类型整数
- int32_t 类型整数
- int64_t 类型整数
每个压缩列表节点的结构如图:
压缩列表节点结构
previous_entry_length 属性以字节为单位,记录了压缩列表中前一个节点的长度。该属性的长度可以是 1 字节或者 5 字节。如果前一个节点的长度小于 254 字节,那么该属性长度为 1 字节,保存小于 254 的值。如果前一节点的长度大于等于 254 字节,那么长度需要为 5 字节,属性的第一字节会被设置为 0xFE (254) 之后的 4 个字节保存其长度。
压缩列表的从表尾到表头遍历:
1. 首先,有指向压缩列表表尾节点起始地址的指针 p1 (指向表尾节点的指针可以通过指向压缩列表起始地址的指针加上 zltail 属性的值得出);
2. 通过用 p1 减去节点的 previous_entry_length 属性,得到前一个节点的起始地址的指针。
3. 如此循环,最终从表尾遍历到表头节点。
encoding 属性记录了节点的 content 属性所保存的数据的类型和长度:
- 一字节、两字节或五字节长,值的最高位为 00、01 或者 10 的是字节数组编码,字节数组的长度由编码除去最高两位之后的其他位记录;
- 一字节长,值的最高位以 11 开头的是整数编码,这种编码表示保存是整数值,整数值的类型和长度由其他位记录。

出现新增或删除节点导致 previous_entry_length 1 字节或者 5 字节的长度变化,是连锁更新的问题,但出现几率比较小,而且数量不多的情况下不会对性能造成影响。

猜你喜欢

转载自blog.csdn.net/yx0628/article/details/80035533