Redis基础篇(数据结构一)

159 阅读7分钟

Redis底层数据结构

简单来说,Redis的底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈 希表、跳表和整数数组。它们和数据类型的对应关系如下图所示:

image.png

可以看到,String 类型的底层实现只有一种数据结构,也就是简单动态字符串。而 List、 Hash、Set 和 Sorted Set 这四种数据类型,都有两种底层实现结构。通常情况下,我们会 把这四种类型称为集合类型,它们的特点是一个键对应了一个集合的数据

全局哈希表

为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。这也就是说,不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针。

image.png

哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对。但是随着Hash表写入的数据越来越多的时候,写入速度会变慢了?这是为什么呢?因为这里隐藏着一个问题:哈希冲突和哈希扩容(rehash)问题。

为什么哈希操作变慢了?

当你往哈希表中写入更多数据时,哈希冲突是不可避免的问题。这里的哈希冲突,也就是指,两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。

Redis 解决哈希冲突的方式,就是链式哈希(同Java的HashMap的原理相似)。链式哈希也很容易理解,就是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

image.png

渐进式Hash

哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。

所以,Redis 会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,减少哈希冲突的概率。那具体怎么做呢?

其实,为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:

  1. 给哈希表2分配更大的空间,比如哈希表1的2倍;
  2. 拷贝哈希表1所有的元素到哈希表2;
  3. 释放哈希表1的空间,留着下一次rehash的扩容使用。

但是问题来了,第二个步骤中,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,导致无法服务其他请求。所以,redis设计了渐进式hash。

简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求 时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝 到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的entries。

也就是说这个动作(数据拷贝)是分多次,渐进式地完成的。

image.png

对于String 类型来说,找到哈希桶就能直接增删改查了,所以,哈希表的 O(1) 操作复杂度也就是它的复杂度了。

在rehash期间,增加操作,一律会添加在在h2(哈希表2);如果是更新或者查找操作,会现在h1去找,再判断当前是否在重哈希,如果没有,则h1就是结果;否则会去h2中查找。

哈希表的扩展与收缩

当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作(增加操作时):

  1. 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
  2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

另一方面,当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。(删除操作时)

集合的操作效率

集合类型的底层数据结构主要有 5 种:整数数组、双向链表、哈希表、压缩列表和跳表。

其中,哈希表的操作特点就是o(1);整数数组和双向链表也很常见,它们的操作特征都是顺序读写,也就是通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是 O(N),操作效率比较低。

压缩列表

压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。

image.png

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。

跳表

有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位

image.png

可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)。

数据结构的时间复杂度

image.png

Redis 之所以能快速操作键值对,一方面是因为 O(1) 复杂度的哈希表被广泛使用,包括String、Hash 和 Set,它们的操作复杂度基本由哈希表决定,另一方面,Sorted Set 也采用了 O(logN) 复杂度的跳表。不过,集合类型的范围操作,因为要遍历底层数据结构,复杂度通常是 O(N)。这里,我的建议是:用其他命令来替代,例如可以用 SCAN 来代替,避免在 Redis 内部产生费时的全集合遍历操作。

了解了redis底层数据结构,就可以明白,redis操作时潜在的慢问题,特别是在使用时要警惕o(n)的数据结构的操作。

问题

整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么Redis还会把它们作为底层数据结构呢?主要出于以下两点考虑:

  1. 内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。
  2. 数组对CPU高速缓存支持更友好,所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。