Redis的数据结构

331 阅读8分钟

Redis 是一个使用率非常高的分布式非关系型数据库,尤其在缓存方面的应用,而它非常显著的特点就是快,这和它的一些设计原理是息息相关的,主要是三个方面,基于内存,特殊的单线程模型,优秀的数据结构。接下来我会从这三个方面深入了解一下 Redis 的工作原理。

Redis 的常用数据结构解析

我们常见的数据结构,比如 String,Hash,Set 等等在 Redis 中都是有非常显著特点的实现的,我会从基础到较为复杂的数据结构慢慢的阐述一下各个数据结构是实现原理。

String

Redis 中的字符串不是直接使用的 C 语言的字符串作为基础的,而是实现了一个 SDS 的数据结构,也就是 Simple Dynamic String动态字符串,实际的存储是一个字符数组,而且数组的结尾是一个空字符 \0,这有点像 Java 中的 String,用 char 数据存储数据。共实现了 5 种,但是有一种是没有被使用的,它们分别是 sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64,其实看名字也能发现一些特征,数字代表着长度不同,它们的属性如下

  • len(整型): 字符串已经使用的字节数
  • allot(整型): 总共可用的空间大小,剩余的空间就等于 allot-len
  • bu[](char): 存储字符串的字符数组
  • flags: 标志位,是使用的哪种 sds

SDS 相比于 C 语言的字符串还是有很大提升的,提升如下:

  1. 避免了缓冲区的溢出:在 C 语言中字符串的拼接这种操作是需要足够的内存空间的,如果没有会造成缓冲区溢出的问题,而在 SDS 中会预先判断是否有足够的空间存储字符串,如果没有的话会先进行扩容。扩容的规则就是,所需要的空间的大小如果小于 1M,那么就增加一倍,如果大于 1M 那么就等于所需长度+1M 作为新的长度。
  2. 获取字符串长度的性能显著提升:在 C 语言中获取字符串长度需要遍历整个字符串,直到碰到 \0为止,而在 SDS 中是记录了 len 的所以获取的时间复杂度就是O(1)
  3. 减少了内存的分配次数:在C 语言中进行修改字符串是需要每次都重新分配内存的,在 SDS 中是使用了预先分配+惰性分配的策略的,字符串存储的时候都会计算它所需要的空间,然后往大一些分配,留出一定的空间,当减少的时候,也不会清除(当然了,有清除的 api),为了后面使用。
  4. 对于二进制数据的安全:在 C 语言中使用 \0作为结束标识,如果二进制的数据中存储这样的空字符就显然是不行的,在 SDS 中使用的是 len 决定着字符串的长度,所以对于二进制数据的存储是安全的。

hash

对于 Hash也是非常常见的,尤其对应到 Java 中的 HashMap ,理解起来就会非常容易,在 Redishash 的结构就是链式 hash,当 hash 冲突的时候就会用链表连接起来,所以,一定会有的一个操作就是rehash,这个是所有 hash 结构必须要面对的问题。

Redis 中同样也存在一个负载因子,它等于 hash 存储的节点数/hash 桶的个数。里面内置了两张表,hash 表 1 存储数据,hash 表 2 用于扩容的,所以通常是 hash表 1的 2倍,当进行扩容的时候,将数据迁移到 hash 表 2 中,然后将表 2 改为表 1,然后对原来的表 1 重做,标记成表 2,等待下一次扩容,当进行 rehash 过程增加的 key-value 都会存储到新表中,进行查找的时候先查找表 1,如果表 1 找到就返回并且直接迁入到表 2,如果没有,那么就到表 2中查找。它的 rehash 的方式叫做渐进式 rehash。当负载因子等于 1 的时候并且 Redis 没有做bgsavebgrewiteaofRDBAOF 操作的时候回去 rehash,当负载因子等于 5 的时候,不管你做没做都要进行 rehash

list

列表就相对来说上了一点难度了,因为它的实现是比较厉害的,对于它的实现在 Redis3.2 以前使用的是LinkedList 双向链表或者 ZipList 压缩列表,到 3.2 以后就使用 ZipListLinkedList 的结合体 QuickList, 但是QuickList还是基于压缩列表实现的,Redis5.0 设计了 listPack,从 Redis7.0 开始 ZipListlistpack 取代。

LinkedList

这个双向链表就是每个节点记录了 prevnext,这个没什么难度,它的缺点就是内存不连续,无法利用 CPU 缓存,同时维护节点对于内存的开销也很大,这个不多说了。

ZipList

压缩列表,这个是一个经典的数据结构,它主要就是使用一块连续的内存空间存储数据,所以此压缩不是彼压缩,不是数据给你压缩了,这样就可以使用 CPU 缓存,因此内存连续,就可以连续放置数据了,保证了数据的顺序,它的数据结构如下图

Redis-ziplist结构图.png 从这个图就可以发现找头还是找尾部还是查询长度都是O(1),但是其他的就得遍历了,同时啊,它存在联系更新的问题,你修改其中的数据,都需要进行联系更新,就像数组一样,一直往后调,像多米诺骨牌一样,这也是最大的缺点,所以被替代是早晚的事。

quickList

它是一个结合体的原因就是结合了 LinkedListzipList 的,每一个 quickListNode 都有前驱和后继,每一个节点内都是一个压缩列表,严格的控制了放入数据的大小和个数,每次新增节点的时候都先判断当前的quickListNode能否放下节点,放不下就新建一个quickListNode,但是还是避免不了连续更新的问题。

listPack

它借鉴了压缩列表的思想,但是放开的小脚。它不再记录前驱节点的长度,而是记录自己的长度,就在自己这一块玩,解决了连续更新的问题。数据结构如下图

listpack结构图.png

ZSet

有序集合,也是非常常用的类型,也是非常牛逼的类型,我们常见的各种排行榜,都可以通过它来实现。它底层的结构使用的是 hash 表+跳表完成的,hash 负责权重运算,跳表负责大部分其他运算。

SkipList 跳表

上面我们说了,是有序集合,那么就一定要保证顺序,与此同时还要保证查询,新增的效率。我们先说一下单链表的问题。

链表.png 单链表的问题在于查询的效率太慢,时间复杂度为 O(n),而我们显然是不能接受的。跳表的形式就是在原始部分节点上增加一下冗余节点,然后称之为索引节点,按层划分,每一层的索引节点也都是相连的,并且有序,如下图。

链表.png 搜索的形式其实和树的搜索很像,比如我们查询 5,先找到 1,然后发现比 1 大,比 4 大,那么比较节点右移,然后比 4 大比 7 小,那么就下移,如果到了根链表,那么就往后找就行了。

所以看起来像跳跃一样,这就是跳表,但是显然一层索引肯定是不够的,如果我们的数据很多的话,那么索引数量多就会和单链表差不多了,所以索引的层数就决定着跳表的效率,理想的情况下就是树高 = log2n,每层的节点树都是下层的一班,这样一看就很像一棵二叉树了,查询效率就会趋近于 logn,甚至命中索引行的时候效率更优。所以索引的维护是跳表的核心,在 Redis 中,它是用随机的方式,也称之为晋升概率进行运算,然后决定数据建在哪一层索引上,我们不能让所有的数据都在一行上,所以随意分配是比较好的选择。

Redis为什么使用跳表而不是红黑树?

既然它趋近于一棵树,那为什么不选择红黑树作为存储结构呢?也能保证顺序。跳表比红黑树的优势大的一点就是范围查找,跳表的范围查找只需要找到起始节点然后往后遍历就行了,但是红黑树需要回溯节点,然后遍历,效率来说肯定是跳表要强。