Redis追本溯源(二)数据结构:String、List、Hash、Set、Zset底层数据结构原理

文章目录

一、String底层------sds(Simple Dynamic String)

Redis 并没有直接用 C 语言的字符串,而是自己搞了一个 sds 的结构体来表示字符串,这个 sds 的全称是 Simple Dynamic String,翻译过来就是"简单的动态字符串"。

1.sds相比C语言字符串的优点

  • 安全的二进制存储

资源。关于sds的扩容和缩容下面会进行详细的介绍,这里先不赘述了。

在一些情况中,我们需要在字符串中存储特殊字符,例如\0。如果使用C语言字符串,\0会被视为字符串结尾,这就导致了存储的字符串不完整。为了存储特殊字符,SDS会明确记录字符串长度而不是将\0视为字符串结尾,这样我们就能够在字符串里面存储特殊字符了。这种记录字符串长度的方式被称为"安全的二进制存储"。

  • 减少 CPU 的消耗

C语言字符串是一个简单的char数组,没有属性来记录字符串长度。因此,每次需要获取字符串长度时,都需要从头开始一个一个字符地遍历,直到遇到\0结束符。这样的遍历操作会消耗大量的CPU。SDS记录字符串长度,避免了这部分CPU消耗。

  • 解决字符串的扩缩容问题

如C语言字符串的长度需要在创建字符串时确定,如果需要在字符串后面添加数据,就需要重新分配char数组的空间,将新的字符串拷贝进去,然后释放原来的空间,这会消耗大量的资源。SDS解决了字符串扩展和缩放的问题,这将在下面详细介绍。

2.结构

c 复制代码
```c
typedef struct sdshdr {
    // 记录 buf 数组中已使用字节的数量
    // 等于 SDS 所保存字符串的长度
    int len;
    
    // 记录 buf 数组中未使用字节的数量
    int free;
    
    // 字节数组,用于保存字符串
    char buf[];
} sdshdr;

len:记录了当前SDS保存的字符串的长度,即buf数组中已使用的字节数。这个值不包括空字符\0。

free:记录了当前SDS中未使用的字节数。这个值加上len等于buf数组的总长度。

buf:实际存储字符串的字符数组,以\0结尾。

SDS的结构体中使用了柔性数组(Flexible Array Member)的技术,即buf数组没有指定大小,可以根据需要动态分配内存。这种技术使得SDS的内存分配更加高效,因为SDS可以根据实际需要动态调整内存大小,避免了频繁的内存分配和释放操作。

3.扩容

如果字符串对象的长度超过了当前分配的空间,Redis会分配一个新的空间,并将原有的字符串内容复制到新的空间中。新的空间通常是原有空间的两倍大小,这样可以减少扩容的次数。扩容的过程中,Redis还会更新字符串对象的长度属性,以反映新的大小。

4.缩容

如果字符串对象的长度变得比较短,Redis可能会尝试缩减它的空间以节省内存。Redis会根据当前字符串对象的长度和分配的空间大小来决定是否需要进行缩容。如果当前字符串长度小于等于1/5的空间大小,Redis会尝试将空间缩减到当前长度所需的大小。缩容的过程中,Redis会分配一个新的空间,并将原有的字符串内容复制到新的空间中。缩容的过程中,Redis还会更新字符串对象的长度属性,以反映新的大小。

需要注意的是,由于Redis的动态字符串实现使用了惰性空间释放机制,所以即使执行了缩容操作,分配的空间也不会立即被释放。相反,Redis会保留这些空间以备后续使用,这样可以减少频繁的内存分配和释放操作,从而提高Redis的性能。

二、List底层------quickList、zipList

1.quickList及其优化过程

Redis开发了quickList这样的数据结构作为Redis List的底层逻辑,实际上它就是一个双端链表。

(1)quickList大致结构

  • 缺点:
    存储密度低: 如果每个 Node 节点里面 Value 指针指向的数据很小,比如只存储了一个 int 值,那 next、prev 指针占了 Node 节点的绝大部分空间,真正存储数据的有效负载就比较低。
    内存碎片: 链表节点分配很多的话,就会出现很多不连续的内存碎片。
    查询效率低: 还有就是,链表查询数据的时候,需要顺着 next 或者 prev 指针链表的一端开始查找,不像数组那样,可以按照下标随机访问,所以说双端链表的查找效率比较低。

(2)引入zipList进行优化

为了优化上面的问题,把 Node 节点的 Value 指针,指向了一个 ziplist 实例,ziplist 是一块连续的空间,里面可以存储多个 List 元素。简单来说,ziplist 实际上就是用一块连续的内存区域,实现了类似链表的效果。

存储密度变高、内存碎片变少:这样,quicklist 和简单的链表结构相比呢,Node 节点数量会更少,内存碎片也就更少了,而且一个 Node 里面存放了多个元素,next、prev 指针占的空间就几乎可以忽略了,有效负载也高了。

扫描效率变高:ziplist 虽然是一块连续的空间,但是呢,它还是不能像数组那样随机访问。查找元素的时候,也是要从头开始扫描,听上去查找方面并没有什么提升。但是也正是 ziplist 是一块连续的内存空间,扫描的时候,不会像 Node 那样有很多指针解析的开销,数据量少的时候,迭代起来还是挺快的。

鉴于zipList篇幅较多,放在后面一节单独介绍。

(3)引入listPack进行优化

从 7.0 开始,Redis 就用 listpack 这个结构替换了 ziplist 结构。开发者希望设计一种简单点的、能够替换 ziplist 的紧凑型数据结构。

下面这张图是 listpack 结构,它和 ziplist 的结构很类似,也是分为头、中、尾三部分。先来看头、尾两部分:头部里面的 tot-bytes 占了 4 个字节,存了 listpack 占用的总字节数,num-elements 占用 2 个字节,存了这个 listpack 里面的元素个数;尾部是一个字节的 end-byte,它的值始终是 255。

看上去和zipList差不多,但实际上存放数据本身的element和zip的存放数据本身的entry是不一样的。后面有介绍

2.zipList

首先来看队头里面的 zlbytes 值,它里面存了一个 int 值,占了 4 个字节,里面存的是整个 ziplist 占的总字节数。

然后是队头的第二部分 zltail 值,也是一个 int 值,占了 4 字节,记录了最后一个 entry 在 ziplist 里面的偏移字节数。为什么要存 zltail 这个值呢?主要是为了实现逆序遍历。如果我们已知一个 ziplist 的首地址,就可以结合它的 zltail 值,计算出最后一个 entry 的地址,对吧?而在 entry 里面呢,会记录前一个 entry 的长度,这样的话,我们就可以找到前一个 entry 的地址,于是一个个 entry 反着找,就能实现 ziplist 的逆序遍历了。(至于 entry 的具体结构呢,马上就会说到,咱们一步一步来哈。)

队头的第三部分是 zllen 值,它是一个 2 字节的整数,记录了整个 ziplist 中的 entry 个数,也就是元素个数哈。所以,我们说一个 ziplist 最多存储 2^16-1 个元素,没问题吧?其实呢,如果元素个数超过了 2^16 -1,ziplist 依旧能存得下,但是这个场景就比较低效了。在 zllen 值变成全 1 的时候呢,Redis 就不再认为 zllen 表示的是元素个数,而是把它当成一个溢出的标识,这个时候要获取元素个数的话,就需要遍历整个 ziplist。

接下来看 ziplist 的结尾,也就是图里面的 zlend 部分,它占了 1 个字节,而且它的值始终是 255,用来标识 ziplist 结束。这就类似于 C 字符串的 \0 结束符,这只是个类比而已哦,ziplist 已经明确地记录了整个 ziplist 长度,所以它本身就是二进制安全的。

3.zipList和listPack的区别

ziplist是一种紧凑的列表实现,它将所有元素都存储在一块连续的内存区域中。每个节点中存储了一个元素以及一个前向指针和一个后向指针。ziplist可以在不需要任何额外内存分配的情况下插入、删除元素,这使得它在内存使用和性能方面都非常高效。但是,由于它是紧凑的,所以 在插入和删除元素时需要移动后续元素的位置, 这会导致操作效率的下降。另外,由于ziplist是一种非常紧凑的结构,所以它的 节点大小是固定的, 这意味着无法存储超过一定大小的元素。

listpack是一种更为灵活的列表实现,它将列表元素存储在一个或多个包中,每个包都有自己的头部和尾部指针。每个包可以包含多个元素,元素的大小可以不同,这使得listpack能够存储更大的元素。在插入和删除元素时,listpack只需要修改相应的指针,而不需要像ziplist一样移动元素的位置,因此效率更高。 另外,listpack还支持压缩,这可以在不需要额外内存分配的情况下,将多个包合并成一个更大的包,从而节省内存。但相对于ziplist而言,listpack的内存使用和性能都要稍微逊色一些。

综上,ziplist和listpack各有优缺点,需要根据具体的使用场景来选择使用哪种数据结构。如果需要存储小型元素,且对内存使用和性能有较高的要求,可以选择ziplist;如果需要存储大型元素或者元素大小不固定,且对内存使用和性能有一定要求,可以选择listpack。

4.quickList的结构

quicklist 同时使用双向链表结构和 listpack 连续内存空间,主要是为了达到空间和时间上的折中。引入listPack或者zipList能带来内存性能上的提升,但是quickList本身的双端链表的插入和删除效率是比较高的。

三、Hash底层------zipList/listPack或dict

哈希表底层数据少、键值对都比较短的时候用 listpack 存,数据量大了之后就用 dict 存。

1.dict

Redis中的Hash类型底层是通过实现一个哈希表(Hash table)来实现的,这个哈希表被称为dict(字典)。dict是Redis中非常常用的数据结构之一,它是一个高效的键值对存储结构。

dict实际上是一个由多个哈希表组成的数组,每个哈希表都有自己的哈希函数,并且可以根据需要进行扩展或收缩。当需要在一个Hash类型的键值对中执行操作时,Redis首先会根据键的哈希值找到对应的哈希表,然后再在这个哈希表中查找相应的键值对。

dict中的每个元素都是一个哈希表节点,每个节点包含一个key指针和一个value指针,这两个指针分别指向键和值的内存地址。在dict中,键是唯一的,而值可以是任何Redis支持的数据类型。每个哈希表节点还包含一个指向下一个节点的指针,这使得dict可以高效地处理哈希冲突。

哈希表的求值过程如下:

  1. 首先,Redis会根据键的哈希值计算出所属的哈希表,并在该哈希表中查找对应的桶(bucket)。
  2. 然后,Redis会在桶中遍历所有的哈希表节点,查找与指定键相匹配的节点。
  3. 如果找到了匹配的节点,Redis就会返回该节点的值指针;否则,Redis会返回null指针,表示指定的键不存在。

在哈希表中查找元素的时间复杂度通常是O(1),这使得dict成为Redis中高效的数据结构之一。但是,当哈希表中的元素数量增加时,可能会导致哈希冲突增多,从而影响查找效率。因此,Redis中的dict还实现了哈希表扩容和收缩的功能,可以动态地调整哈希表的大小以适应不同的负载情况。

2.扩容和渐进式 Rehash

Redis的Hash类型使用哈希表(Hash table)来实现。当哈希表中的元素数量增多时,可能会导致哈希冲突增多,从而影响查找效率。为了解决这个问题,Redis实现了哈希表扩容的功能,可以动态地调整哈希表的大小以适应不同的负载情况。

哈希表扩容分为以下几个步骤:

  1. 创建一个新的哈希表,大小是原哈希表的两倍。
  2. 将所有原哈希表中的元素重新分配到新哈希表中。
  3. 当有新的元素需要添加时,先在新哈希表中查找,如果找到了就直接添加。如果没有找到,则在原哈希表中添加新元素,并在后续的rehash过程中将该元素移动到新哈希表中。

在哈希表进行扩容时,需要对原有的键值对进行rehash操作,即将键值对从原哈希表移动到新哈希表。rehash过程分为多个步骤:

  1. 从原哈希表中选取一个桶(bucket)。
  2. 遍历该桶中的所有节点,将节点对应的键值对从原哈希表中移除,并插入到新哈希表中。
  3. 重复以上两个步骤,直到原哈希表中的所有桶都被rehash完毕。

在rehash过程中,需要保证原哈希表和新哈希表的并发访问。因此,Redis使用了两个哈希表来实现平滑的哈希表扩容,即在rehash过程中,同时使用原哈希表和新哈希表来进行键值对的查找和插入操作。这种方式可以避免在rehash过程中出现大量的哈希冲突,从而使得哈希表扩容操作更加高效。 当所有的键值对都被rehash到新哈希表中后,Redis会将原哈希表释放掉,从而完成哈希表扩容的过程。

需要注意的是,在rehash过程中,可能会存在同时访问原哈希表和新哈希表的情况,如果在这种情况下进行删除操作,可能会导致数据丢失。因此,在rehash过程中,Redis禁止对哈希表进行删除操作。

四、Set底层------dict或intSet

Set 底层的结构也是 dict;但是,在元素都是整数值的时候,Set 可以用一种更省空间的方式来存数据------intset。

  • 一个 Set 要用 intset 作为底层存储的话,需要满足两个条件:
    一个就是前面说的,元素都是整数类型;
    另一个条件是这个 Set 里面的元素个数,要少于 set-max-intset-entries 配置指定的这个值,这个值默认是 512。

Redis 之所以使用 intset 结构来进行优化,主要是为了减少内存碎片,提高查询效率,这也体现了 Redis 在空间占用和耗时等方面的折中和思考。

intSet

Redis中的intset是一种特殊的数据结构,用于保存整数类型的元素集合。它是一种紧凑的、高效的数据结构,可以在内存中节省空间,并且可以快速地执行一些常见的操作,如判断元素是否存在于集合中、获取集合中的最大值和最小值等。

intset的底层实现是一个数组,数组中的每个元素都是一个整数类型的值。数组中的元素按照从小到大的顺序排列,并且所有元素都是唯一的。当向intset中添加新元素时,如果该元素已经存在于intset中,则不会进行任何操作,否则将该元素插入到正确的位置,同时更新intset的长度信息。

intset的内存布局比较紧凑,它的每个元素都是一个整数类型的值,可以使用不同的存储空间来存储不同大小的整数。具体来说,intset中的元素可以分为三类:

  • int16_t:可以用16位的有符号整数表示。
  • int32_t:可以用32位的有符号整数表示。
  • int64_t:可以用64位的有符号整数表示。

在intset中,每个元素的类型是动态确定的,它的类型由元素的大小来决定。当intset中所有元素都能够用int16_t来表示时,intset的元素类型就是int16_t;当存在某个元素无法用int16_t来表示时,intset的元素类型就变为int32_t;当存在某个元素无法用int32_t来表示时,intset的元素类型就变为int64_t。

intset的实现比较简单,可以快速地执行一些常见的操作,如判断元素是否存在于集合中、获取集合中的最大值和最小值等。在intset中查找元素的时间复杂度通常是O(log n),这使得它成为Redis中高效的数据结构之一。但是,由于intset只能存储整数类型的元素,因此它的应用场景比较受限。

五、ZSet底层------skipList

skiplist 是基于随机算法的一种有序链表,其查询效率与红黑树的查询效率差不多,都是 O(logn),但是 skiplist 的结构远比复杂的红黑树简单很多。

1.基本结构

使用 skiplist 查找数据的时候,会先从最高层,也就是上图中的 level 2,开始向后遍历,这里发现 level 2 层小于 48 的最大节点是 18 节点,那么向下一层来到 level 1,继续从 18 节点向后遍历。在 level 1 层中发现小于 48 的最大节点是 35,那么再向下一层来到 level 0,继续从 35 节点向后迭代,最终找到 48 这个目标节点。

跳跃表的基本思想是在链表中添加"跳跃指针",这些指针可以让查找操作快速跳过一些不必要的节点,从而提高查找的效率。跳跃表中的每个节点都包含多个指针,每个指针都指向链表中的另外一个节点。这些指针可以让跳跃表中的节点形成多个层次,每个层次都是一个链表,最底层的链表就是跳跃表的主链表。

在跳跃表的插入和删除操作中,需要维护跳跃表中各个节点之间的指针关系,保证跳跃表的各个层次之间的链表保持有序。 在跳跃表的查找操作中,可以通过跳跃指针快速地跳过一些不必要的节点,从而提高查找的效率。跳跃表的查找操作的时间复杂度通常是O(log n),这使得它成为一种高效的数据结构。

Redis中使用跳跃表来实现有序集合(Sorted Set)数据结构,有序集合中的元素是有序的,并且每个元素都对应一个分数(score),可以根据分数进行排序。有序集合中的元素可以通过跳跃表快速地进行查找和排序,同时还可以支持插入、删除等操作,因此在Redis中广泛应用于排行榜、计数器等场景。

需要注意的是,在跳跃表中的节点数量越多,跳跃表中的跳跃指针就越多,这会占用更多的内存空间。因此,在实际应用中,需要根据数据规模和性能需求等因素来选择合适的跳跃表节点数量。

2.跳跃步伐设计

在设计跳跃表的跳跃步伐时,需要考虑以下因素:

  • 节点数量: 跳跃表中的节点数量越多,跳跃指针就需要跨越的节点数量就越多,因此跳跃步伐也需要相应地增大。

  • 节点分布: 跳跃表中节点的分布情况也会影响跳跃步伐的设计。如果节点分布比较均匀,每个节点之间的距离比较相近,跳跃步伐可以设置得比较小;如果节点分布比较不均匀,节点之间的距离比较大,跳跃步伐需要设置得相对较大。

  • 目标节点位置: 跳跃步伐的大小还需要根据目标节点的位置来确定。如果目标节点在跳跃表的靠近头部或尾部的位置,跳跃步伐可以设置得比较小;如果目标节点在跳跃表的中间位置,跳跃步伐需要设置得相对较大。

基于以上因素,跳跃表的跳跃步伐通常是根据概率分布来设计的,例如常见的设计方式是使用1/2的概率向前跳跃一步,1/4的概率向前跳跃两步,1/8的概率向前跳跃三步,以此类推,直到概率降至非常小的程度为止。这种设计方式可以保证跳跃表的查找效率较高,并且可以保持跳跃表的平衡性,从而提高跳跃表的可扩展性。

3.skipList和红黑树的对比

Skip List和红黑树都是常见的平衡数据结构,它们都可以用于实现有序集合等类似的数据结构,具有一定的相似性,但也有一些区别。

  1. 实现复杂度

Skip List的实现相对简单,对于插入、删除和查找等基本操作,时间复杂度都是O(log n),并且具有较好的可扩展性和高效性能。而红黑树的实现相对复杂,虽然也具有O(log n)的时间复杂度,但是在实际应用中,它的性能可能会受到平衡因子的影响,而且在极端情况下,可能会退化为链表,从而影响性能。

  1. 空间复杂度

Skip List的空间复杂度相对较高,因为每个节点都需要存储多个指针,从而占用更多的内存空间。而红黑树的空间复杂度相对较低,因为每个节点只需要存储一个指针和一些颜色信息,占用较少的内存空间。

  1. 插入和删除操作

在Skip List中,插入和删除操作相对简单,只需要修改节点的指针即可,不需要进行旋转操作。而在红黑树中,插入和删除操作需要进行旋转操作,这会增加实现的复杂度。

  1. 查找操作

在Skip List中,查找操作的时间复杂度通常是O(log n),这使得它成为一种高效的数据结构。而在红黑树中,查找操作的时间复杂度同样是O(log n),但是在实际应用中,由于红黑树的实现比较复杂,可能会影响查找的效率。

在实际应用中,Skip List和红黑树可以根据具体的需求来选择使用。如果需要实现一个高效、可扩展的有序集合,可以选择使用Skip List;如果需要实现一个复杂的数据结构,并且对空间和时间的要求比较高,可以选择使用红黑树。

相关推荐
都叫我大帅哥1 小时前
🌊 Redis Stream深度探险:从秒杀系统到面试通关
java·redis
都叫我大帅哥1 小时前
Redis持久化全解析:从健忘症患者到记忆大师的逆袭
java·redis
努力写代码的熊大2 小时前
单链表和双向链表
数据结构·链表
野蛮人6号2 小时前
黑马点评系列问题之基础篇p7 06初识redis无法在虚拟机查到图形化界面存进去的键
redis·黑马点评
Orlando cron3 小时前
数据结构入门:链表
数据结构·算法·链表
大春儿的试验田4 小时前
高并发收藏功能设计:Redis异步同步与定时补偿机制详解
java·数据库·redis·学习·缓存
hqxstudying4 小时前
Redis为什么是单线程
java·redis
Fireworkitte6 小时前
Redis 源码 tar 包安装 Redis 哨兵模式(Sentinel)
数据库·redis·sentinel
纳兰青华6 小时前
bean注入的过程中,Property of ‘java.util.ArrayList‘ type cannot be injected by ‘List‘
java·开发语言·spring·list
许愿与你永世安宁7 小时前
力扣343 整数拆分
数据结构·算法·leetcode