Redis01——数据结构篇

从 Redis 的数据结构开始聊起

前言

如今提到 Redis,大众的第一印象应该就是“快”,也正是由于 Redis 的快的特性,使得其可以适用于分布式缓存、键值对数据存储等场景。但反过来想一下,为什么 Redis 能有如此突出的表现呢?它是如何在相关领域独占鳌头的呢?

诶,这个时候可能懂点皮毛的程序员就会说,因为它是内存数据库,所有的操作都是在内存上完成的,内存的访问速度本身就比磁盘要快。这么回答当然没错,但是如果再深入一点的话呢?这样就要归功于 Redis 的数据结构。在 Redis 中键值对是按一定的数据结构来组织的,所以说用户对于键值对的操作,最终就是对数据结构进行增删改查的操作。

如果上手使用过 Redis 的话,我们可以知道常见的有:String、List、Hash、Set 和 Sorted Set。不过,这些都只是 Redis 键值对中值的数据类型,说通俗点就是数据的保存形式。但在这篇博客中,我们来一起探究其底层的实现。

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

Redis_DataSture

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

看到这里,我们可以想一下,这些数据结构都是值的底层实现,键和值本身之间用什么结构组织呢?为什么集合类型有那么多的底层结构,它们之间有何区别呢?下面我们就这两个问题来展开讨论。

键和值用什么结构?

先说答案:Redis 的键和值之前使用哈希表来组织和存储。在 Redis 中,每个键都与一个值相关联,并且这些键值对都存储在哈希表中。一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶,通过哈希函数将键映射到哈希桶中,从而快速地访问和检索数据。所以,我们常说,一个哈希表是由多个哈希桶组成,每个哈希桶中保存了键值对数据。

这个时候可能有的读者就会想到:“如果值是集合类型的话,作为数组元素的哈希桶该怎么保存呢?”其实,这个答案很简单,哈希桶中保存的元素并不是这个值本身,而是指向这个值的指针。也就是说,不管值是 String 还是集合类型,哈希桶中的元素都是指向它们的指针。

我们可以借助下图来理解:哈希桶的中 entry 元素中保存了 *key*value 指针,分别指向了实际的键和值,这样做的好处是,即使值是一个集合,也可以通过 *value 指针被查找到。

Hash_Bucket

因为这个哈希表保存了所有的键值对,所以,可以将之称为全局哈希表。我们知道哈希表最大的优点是可以在 O(1) 的时间复杂度来快速查找对应的键值对。实现过程就是计算键的哈希值,从而得到对应的哈希桶的位置,然后就可以访问相应的 entry 元素。

在这个查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系。也就是说在理想状况下,不管哈希表里面有 10 万个键还是 100 万个键,我们只需要一次计算就能找到相应的键。

但事实却并不是这样,当向 Redis 中写入大量数据后,就可能会发现有时候操作会突然变慢了。这是因为忽略了一个潜在的风险:服务器的资源并不是无限的,所以在存储过程中必定会有取舍,对于哈希表也是同理,也就有了哈希表的冲突问题和 rehash 可能带来的操作阻塞

哈希冲突

经过上面的分析,我们可以知道在有限的空间内,向哈希表中写入大量的元素时,哈希冲突是不可避免的问题。说简单点就是:当两个或两个以上的 Key 哈希值和哈希桶计算对应关系时,刚好落在了同一个哈希桶中。因为哈希桶的个数通常要少于 Key 的数据,这就导致了有一些 Key 的哈希值同时对应到了同一个哈希桶中。

在 Redis 中解决哈希冲突的方式时链式哈希,是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次使用指针连接

如下图所示:entry1、entry2 和 entry3 都需要保存在哈希桶 3 中,导致了哈希冲突。此时,entry1 元素会通过一个next 指针指向 entry2,同样,entry2 也会通过next 指针指向 entry3。这样一来,即使哈希桶 3 中的元素有 100 个,我们也可以通过 entry 元素中的指针,把它们连起来。这就形成了一个链表,也叫作哈希冲突链。

Hash_Conflict

但这么做也依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多,这就回导致某些哈希冲突链过长,也就会进一步导致这个链上的元素查找耗时较长,效率也会降低。这也越 Redis 的“快”相违背。

所以,Redis 会对哈希表做 rehash 操作,也就是通过 rehash 增加现有的哈希桶的数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。具体做法如下:

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

  1. 给哈希表 2 分配更大的空间,例如是当前哈希表 1 的两倍;
  2. 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
  3. 为了节省空间,释放哈希表 1 的空间。

当完成上述操作之后,就可以从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多的数据,而原来的哈希表 1 释放空间之后,留作下一次 rehash 扩容备用。

看到这里,有些读者可能会说,原来这么简单的呐。其实并不是,仔细思考一下,在第二步的时候会涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移到过去,可能会造成 Redis 线程阻塞,无法服务于其他请求。此时,Redis 就无法快速访问数据叻。这就好比,既然一口吃不下,那就多分几口吃呗。是的,Redis 采用的是渐进式 Hash

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

ReHash

这么做的好处是巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。

看到这里想必有可以理解 Redis 的键和值是怎么通过哈希表组织的了吧。对于 String 类型来说,找到哈希桶就能直接进行增删改查了,所以,哈希表的 O(1) 操作复杂度也就是它的复杂度了。但是,对于集合类型来说,即使找到哈希桶了,还要在集合中再进行一步操作。下面就一起来看看,在 Redis 中集合类型是怎么玩的。

集合数据结构

和 String 类型不同的是,集合类型处理起来要复杂一点。对于集合类型的值来说,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再进行增删改查。下来我们就来分析一下集合的操作效率和哪些因素有关。

首先可以想到的是,于集合的底层数据结构有关。例如,使用哈希表实现的集合,要比使用链表实现的集合访问效率更高。其次,操作效率和这些操作本身的执行特点也有关,比如读写一个元素的效率要比读写所有元素的效率高。

接下来,我们就分别看一下集合类型的底层数据结构和操作复杂度。

底层数据结构

在开篇我们提到了,集合类型的底层数据结构主要有 5 种:整数数组、双向链表、哈希表、压缩列表和跳表。

其中,哈希表的特点刚才已经讲过;至于整数数组和双向链表也是比较常见,它们的操作都是顺序读写。也就是通过数组下标或者链表的指针逐个元素进行访问,时间复杂度基本是 O(N),操作效率比较低;至于压缩列表和跳表我们平时接触的可能不是很多,但它们是 Redis 重要的数据结构,下面来重点解释一下。

压缩列表比较类似于一个数组,数组中的每个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zlail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数,在压缩列表的表尾还有一个 zlend,表示列表结束,如下图所示:

Compressed-List

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

那么跳表呢?效率会不会高一点?

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

Skip-List

如果我们要在链表中查找 26 这个元素,只能从头开始遍历链表,查找 6 次,直到找到该元素为止。此时,时间复杂度为 O(N),查找效率很低。

为了提高查找速度,我们来增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。例如,从前两个元素中抽取元素 1 作为一级索引,从第三、四个元素中抽取 8 作为一级索引,依此类推。此时,我们只需要 4 次查找就能定位到元素 26 了。

看到这里,聪明的你可能会想到,是不是能够再增加一个二级索引呢?从一级索引中再抽取部分元素作为二级索引。例如,从一级索引中抽取 1、19 和 45 三个元素作为二级索引,二级索引指向一级索引。这样,我们只需要 3 次查找,就能定位到元素 26 了。

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

到此为止,我们讲完了 Redis 中的底层数据结构类型,下面按照查找的时间复杂度总结如下:

名称时间复杂度
哈希表O(1)
跳表O(logN)
双向链表O(N)
压缩列表O(N)
整数数组O(N)

不同操作的复杂度

在 Redis 中集合类型的操作有很多,有读写单个集合元素的,比如 HGET、HSET,也有操作多个元素的,例如 SADD,还有对整个集合进行遍历操作的,例如 SMEMBERS。这么多操作,它们的复杂度也各不相同。而复杂度的高低又是我们选择集合类型的重要依据。主要可以归纳为以下四类:

第一,单元素操作,是指每一种集合类型对单个元素实现的增删改查操作。例如,Hash 类型的 HGET、HSET 和 HDEL,Set 类型的 SADD、SREM、SRANDMEMBER 等。这些操作的复杂度由集合采用的数据结构决定,例如 HGET、HSET 和 HDEL 是对哈希表做操作,所以他们的复杂度都是 O(1);Set 类型用哈希表作为底层数据结构时,它的 SADD、SREM 和 SRANDMEMBER 的复杂度也是 O(1)。

不过,这里有个地方需要注意一下,经过刚才介绍我们知道集合类型支持同时对多个元素进行增删改查,例如 Hash 类型的 HMGET 和 HMSET,Set 类型的 SADD 也支持同时增加多个元素。此时,这些操作的复杂度,就是由单个元素操作复杂度和元素个数决定的。例如,HMSET 增加 M 个元素时,复杂度就从 O(1) 变成 O(M) 了。

第二,范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据,比如 List 类型的 LRANGE 和 ZSet 类型的 ZRANGE。这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免。

上述问题作为 Redis 的维护者当然也知道这一点,所以 Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。这样一来,相比于 HGETALL、SMEMBERS 这类操作来说,就避免了一次性返回所有元素而导致的 Redis 阻塞。

第三,统计操作,是指集合类型对集合中所有元素个数的记录,例如 LLEN 和 SCARD。这类操作复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。

第四,例外情况,是指某些数据结构的特殊记录,例如压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作。

总结

在这篇博客,主要介绍了 Redis 的底层数据结构,这既包括了 Redis 中用来保存每个键和值的全局哈希表结构,也包括了支持集合类型实现的双向链表、压缩列表、整数数组、哈希表和跳表这五大底层结构。

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

当然,我们也不能忘了复杂度较高的 List 类型,它的两种底层实现结构:双向链表和压缩列表的复杂度都是 O(N)。因此,我们可以根据实际情况灵活地使用 List 类型。例如,既然它的 POP/PUSH 效率很高,那么就将它主要用于 FIFO 队列场景,而不是作为一个可以随机读写的集合。

至于整数数组和压缩列表虽然在查找时间复杂度方面并没有很大的优势,但这二者在底层都是非常紧凑的数据结构,要比链表占用的内存要更少,Redis 本身定位又是内存数据库,大量数据存到内存中,所以需要尽可能地优化,提高内存的利用率。

Redis 数据类型丰富,每个类型的操作繁多,如果第一次接触是很难记住所有操作的复杂度。单正所谓万变不离其宗,只要我们掌握其原理,就可以做到以不变应万变叻。