11.Redis的慢操作之rehash

2023年 9月 2日 109.8k 0

Redis为什么快

它接收到一个键值对操作后,能以微秒级别的速度找到数据,并快速完成操作。

数据库这么多,为啥 Redis 能有这么突出的表现呢?

内存+数据结构

一方面,这是因为它是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。

另一方面,这要归功于它的数据结构。

键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作。

所以高效的数据结构是 Redis 快速处理数据的基础。

数据类型和数据结构对应关系

image.png

img

可以看到,String 类型的底层实现只有一种数据结构,也就是简单动态字符串。

而 List、Hash、Set 和 Sorted Set 这四种数据类型,都有两种底层实现结构。

通常情况下,我们会把这四种类型称为集合类型,它们的特点是一个键对应了一个集合的数据。

键和值的组织结构:哈希表

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

一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。

一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。

如果值是集合类型的话,作为数组元素的哈希桶怎么来保存呢?

其实,哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。

这也就是说,不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针。

在下图中,可以看到,哈希桶中的 entry 元素中保存了key和value指针,分别指向了实际的键和值。

这样一来,即使值是一个集合,也可以通过value指针被查找到。

image.png

image.png

因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。

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

你看,这个查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系。也就是说,不管哈希表里有 10 万个键还是 100 万个键,我们只需要一次计算就能找到相应的键。但往Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。

因为哈希表的冲突问题和 rehash 可能带来操作的阻塞。

慢操作之哈希表的rehash

rehash之扩容的时机

image.png

typedef struct dict{
  //哈希表,分为h[0],h[1]哈希表,通常情况下使用h[0],h[1]用于rehash
  dictht ht[2];
  //rehash索引,当rehash完成时,值为-1
  itn trehashidx;
}

哈希表什么情况下会扩容和收缩?也就是什么情况下会rehash?

哈希表负载因子计算公式:

loadfactory = ht[0].used / ht[0].size

即负载因子 = 哈希表已保存节点数量 / 哈希表大小

扩容时机

1.程序没有执行BGSAVE命令或者BGREWRITEAOF(AOF重写)命令,并且哈希表的负载因子大于等于1。

2.如果程序正在执行BGSAVE或者BGREWRITEAOF(AOF重写)命令并且哈希表的负载因子大于等于5。

在执行RDB或者AOF重写操作时,redis会创建当前服务器的子进程执行相应操作,为了避免在子进程存在期间对哈希表进行扩展操作,将扩展因子提高。可以避RDB或者AOF重写时不必要的内存写入操作,最大限度的节约内存。

rehash之收缩的时机

收缩:负载因子小于0.1进行收缩。

解决rehash变慢:渐进式rehash

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

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

Redis 解决哈希冲突的方式,就是链式哈希。

链式哈希是指同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。

img

如下图所示:entry1、entry2 和 entry3 都需要保存在哈希桶 3 中,导致了哈希冲突。

此时,entry1 元素会通过一个next指针指向 entry2,同样,entry2 也会通过next指针指向 entry3。

这样一来,即使哈希桶 3 中的元素有 100 个,我们也可以通过 entry 元素中的指针,把它们连起来。

这就形成了一个链表,也叫作哈希冲突链。

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

对于追求“快”的 Redis 来说,这是不太能接受的。

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

什么是渐进式rehash?为什么redis使用渐进式rehash?

扩展和收缩需要将ht[0]上的所有键值对rehash到ht[1]哈希表中。但是redis的rehash动作并不是一次性完成的,而是分多次、渐进式的完成的。

为什么不是一次性的完成呢?

如果哈希表中保存的键值对数量很少,那么服务器在瞬间就可以将这些键值对全部rehash到ht[1]。

但是如果哈希表中保存的是百万、千万甚至亿级别的键值对数量,如果一次性将这些键值对全部rehash到ht[1],庞大的计算量可能会导致服务器在一段时间内停止服务。

为了使 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。如下图所示:

img

这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。好了,到这里,你应该就能理解,Redis 的键和值是怎么通过哈希表组织的了。对于 String 类型来说,找到哈希桶就能直接增删改查了,所以,哈希表的 O(1) 操作复杂度也就是它的复杂度了。但是,对于集合类型来说,即使找到哈希桶了,还要在集合中再进一步操作。接下来,我们来看集合类型的操作效率又是怎样的。

影响集合数据操作效率的因素

和 String 类型不同,一个集合类型的值,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查。

那么,集合的操作效率和哪些因素相关呢?

集合的底层数据结构

首先,与集合的底层数据结构有关。例如,使用哈希表实现的集合,要比使用链表实现的集合访问效率更高。

读写操作

其次,操作效率和这些操作本身的执行特点有关,比如读写一个元素的操作要比读写所有元素的效率高。

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

底层数据结构

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

其中,哈希表的操作特点我们刚刚已经学过了;

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

数据结构

压缩列表

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

image.png

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。

而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。

ziplist 是一个特殊的双向链表。

特殊之处在于:没有维护双向指针:prev next;

而是存储上一个 entry的长度和 当前entry的长度,通过长度推算下一个元素在什么地方。

牺牲读取的性能,获得高效的存储空间,因为(简短字符串的情况)存储指针比存储entry长度 更费内存。这是典型的“时间换空间”。

ziplist使用局限性 字段、值比较小,才会用ziplist。

压缩列表ziplist存储结构 ziplist使用连续的内存块,每一个节点(entry)都是连续存储的;

1.ziplist可以通过data header计算出当前entry的结束位置,也就能得到下一个entry的起始位置,正向遍历。

2.ziplist可以通过length of previous entry计算出上一个entry的起始位置,反向遍历

Redis是基于内存的nosql,有些场景下为了节省内存redis会用“时间”换“空间”。
ziplist就是很典型的例子。
​
ziplist是list键、hash键以及zset键的底层实现之一(3.0之后list键已经不直接用ziplist和linkedlist作为底层实现了,取而代之的是quicklist)
这些键的常规底层实现如下:
​
list键:双向链表
hash键:字典dict
zset键:跳跃表zskiplist
但是当list键里包含的元素较少、并且每个元素要么是小整数要么是长度较小的字符串时,redis将会用ziplist作为list键的底层实现。同理hash和zset在这种场景下也会使用ziplist。
​
既然已有底层结构可以实现list、hash、zset键,为什么还要用ziplist呢?
当然是为了节省内存空间
我们先来看看ziplist是如何压缩的

整体布局

ziplist是由一系列特殊编码的连续内存块组成的顺序存储结构,类似于数组,ziplist在内存中是连续存储的,但是不同于数组,为了节省内存 ziplist的每个元素所占的内存大小可以不同(数组中叫元素,ziplist叫节点entry,下文都用“节点”),每个节点可以用来存储一个整数或者一个字符串。 下图是ziplist在内存中的布局

image.png

zlbytes: ziplist的长度(单位: 字节),是一个32位无符号整数
zltail:  ziplist最后一个节点的偏移量,反向遍历ziplist或者pop尾部节点的时候有用。
zllen:   ziplist的节点(entry)个数
entry: 节点
zlend: 值为0xFF,用于标记ziplist的结尾
普通数组的遍历是根据数组里存储的数据类型 找到下一个元素的,例如int类型的数组访问下一个元素时每次只需要移动一个sizeof(int)就行(实际上开发者只需让指针p+1就行,在这里引入sizeof(int)只是为了说明区别)。
上文说了,ziplist的每个节点的长度是可以不一样的,而我们面对不同长度的节点又不可能直接sizeof(entry),那么它是怎么访问下一个节点呢?
ziplist将一些必要的偏移量信息记录在了每一个节点里,使之能跳到上一个节点或下一个节点。
接下来我们看看节点的布局
​
节点的布局(entry)
​
每个节点由三部分组成:prevlength、encoding、data
​
prevlengh: 记录上一个节点的长度,为了方便反向遍历ziplist
encoding: 当前节点的编码规则,下文会详细说
data: 当前节点的值,可以是数字或字符串 
 为了节省内存,根据上一个节点的长度prevlength 可以将ziplist节点分为两类:

image.png

entry的前8位小于254,则这8位就表示上一个节点的长度。

entry的前8位等于254,则意味着上一个节点的长度无法用8位表示,后面32位才是真实的prevlength。

用254 不用255(11111111)作为分界是因为255是zlend的值,它用于判断ziplist是否到达尾部。

参考链接:blog.csdn.net/zgaoq/artic…

跳表

我们再来看下跳表。有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。

跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:

image.png

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

为了提高查找速度,我们来增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。

这些索引再通过指针指向原始的链表。

例如,从前两个元素中抽取元素 1 作为一级索引,从第三、四个元素中抽取元素 11 作为一级索引。

此时,我们只需要 4 次查找就能定位到元素 33 了。如果我们还想再快,可以再增加二级索引:从一级索引中,再抽取部分元素作为二级索引。

例如,从一级索引中抽取 1、27、100 作为二级索引,二级索引指向一级索引。这样,我们只需要 3 次查找,就能定位到元素 33 了。可以看到,这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)。

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

image.png

不同操作的复杂度

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

第一,单元素操作,是指每一种集合类型对单个数据实现的增删改查操作。例如,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 从 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 数据类型丰富,每个类型的操作繁多,我们通常无法一下子记住所有操作的复杂度。所以,最好的办法就是掌握原理,以不变应万变。这里,你可以看到,一旦掌握了数据结构基本原理,你可以从原理上推断不同操作的复杂度,即使这个操作你不一定熟悉。这样一来,你不用死记硬背,也能快速合理地做出选择了。

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论