gitbook/Redis核心技术与实战/docs/268253.md
2022-09-03 22:05:03 +08:00

16 KiB
Raw Permalink Blame History

02 | 数据结构快速的Redis有哪些慢操作

你好,我是蒋德钧。

一提到Redis我们的脑子里马上就会出现一个词“快。”但是你有没有想过Redis的快到底是快在哪里呢实际上这里有一个重要的表现它接收到一个键值对操作后能以微秒级别的速度找到数据,并快速完成操作。

数据库这么多为啥Redis能有这么突出的表现呢一方面这是因为它是内存数据库所有操作都在内存上完成内存的访问速度本身就很快。另一方面这要归功于它的数据结构。这是因为键值对是按一定的数据结构来组织的操作键值对最终就是对数据结构进行增删改查操作所以高效的数据结构是Redis快速处理数据的基础。这节课我就来和你聊聊数据结构。

说到这儿你肯定会说“这个我知道不就是String字符串、List列表、Hash哈希、Set集合和Sorted Set有序集合”其实这些只是Redis键值对中值的数据类型也就是数据的保存形式。而这里我们说的数据结构是要去看看它们的底层实现。

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

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

看到这里,其实有些问题已经值得我们去考虑了:

  • 这些数据结构都是值的底层实现,键和值本身之间用什么结构组织?
  • 为什么集合类型有那么多的底层结构,它们都是怎么组织数据的,都很快吗?
  • 什么是简单动态字符串,和常用的字符串是一回事吗?

接下来我就和你聊聊前两个问题。这样你不仅可以知道Redis“快”的基本原理还可以借此理解Redis中有哪些潜在的“慢操作”最大化Redis的性能优势。而关于简单动态字符串我会在后面的课程中再和你讨论。

我们先来看看键和值之间是用什么结构组织的。

键和值用什么结构组织?

为了实现从键到值的快速访问Redis使用了一个哈希表来保存所有键值对。

一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

看到这里你可能会问了“如果值是集合类型的话作为数组元素的哈希桶怎么来保存呢”其实哈希桶中的元素保存的并不是值本身而是指向具体值的指针。这也就是说不管值是String还是集合类型哈希桶中的元素都是指向它们的指针。

在下图中可以看到哈希桶中的entry元素中保存了*key*value指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过*value指针被查找到。

因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。哈希表的最大好处很明显就是让我们可以用O(1)的时间复杂度来快速查找到键值对——我们只需要计算键的哈希值就可以知道它所对应的哈希桶位置然后就可以访问相应的entry元素。

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

但是如果你只是了解了哈希表的O(1)复杂度和快速查找特性那么当你往Redis中写入大量数据后就可能发现操作有时候会突然变慢了。这其实是因为你忽略了一个潜在的风险点那就是哈希表的冲突问题和rehash可能带来的操作阻塞。

为什么哈希表操作变慢了?

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

毕竟哈希桶的个数通常要少于key的数量这也就是说难免会有一些key的哈希值对应到了同一个哈希桶中。

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

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

但是这里依然存在一个问题哈希冲突链上的元素只能通过指针逐一查找再操作。如果哈希表里写入的数据越来越多哈希冲突可能也会越来越多这就会导致某些哈希冲突链过长进而导致这个链上的元素查找耗时长效率降低。对于追求“快”的Redis来说这是不太能接受的。

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

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

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

到此我们就可以从哈希表1切换到哈希表2用增大的哈希表2保存更多数据而原来的哈希表1留作下一次rehash扩容备用。

这个过程看似简单但是第二步涉及大量的数据拷贝如果一次性把哈希表1中的数据都迁移完会造成Redis线程阻塞无法服务其他请求。此时Redis就无法快速访问数据了。

为了避免这个问题Redis采用了渐进式rehash

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

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

好了到这里你应该就能理解Redis的键和值是怎么通过哈希表组织的了。对于String类型来说找到哈希桶就能直接增删改查了所以哈希表的O(1)操作复杂度也就是它的复杂度了。

但是,对于集合类型来说,即使找到哈希桶了,还要在集合中再进一步操作。接下来,我们来看集合类型的操作效率又是怎样的。

集合数据操作效率

和String类型不同一个集合类型的值第一步是通过全局哈希表找到对应的哈希桶位置第二步是在集合中再增删改查。那么集合的操作效率和哪些因素相关呢

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

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

有哪些底层数据结构?

刚才我也和你介绍过集合类型的底层数据结构主要有5种整数数组、双向链表、哈希表、压缩列表和跳表。

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

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

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

我们再来看下跳表。

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

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

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

如果我们还想再快可以再增加二级索引从一级索引中再抽取部分元素作为二级索引。例如从一级索引中抽取1、27、100作为二级索引二级索引指向一级索引。这样我们只需要3次查找就能定位到元素33了。

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

好了,我们现在可以按照查找的时间复杂度给这些数据结构分下类了:

不同操作的复杂度

集合类型的操作类型很多有读写单个集合元素的例如HGET、HSET也有操作多个元素的例如SADD还有对整个集合进行遍历操作的例如SMEMBERS。这么多操作它们的复杂度也各不相同。而复杂度的高低又是我们选择集合类型的重要依据。

我总结了一个“四句口诀”,希望能帮助你快速记住集合常见操作的复杂度。这样你在使用过程中,就可以提前规避高复杂度操作了。

  • 单元素操作是基础;
  • 范围操作非常耗时;
  • 统计操作通常高效;
  • 例外情况只有几个。

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

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

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

不过Redis从2.8版本开始提供了SCAN系列操作包括HSCANSSCAN和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还会把它们作为底层数据结构呢

数据结构是了解Redis性能的必修课如果你身边还有不太清楚数据结构的朋友欢迎你把今天的内容分享给他/她,期待你在留言区和我交流讨论。