11.Redis的慢操作之rehash

Redis为什么快

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

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

内存+数据结构

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

慢操作之哈希表的rehash

rehash之扩容的时机

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

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

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

md 复制代码
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 解决哈希冲突的方式,就是链式哈希。

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

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

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

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

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

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

集合的底层数据结构

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

读写操作

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

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

底层数据结构

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

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

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

数据结构

压缩列表

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

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 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的起始位置,反向遍历

python 复制代码
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在内存中的布局

makefile 复制代码
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节点分为两类:

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

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

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

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

跳表

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

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

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

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

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

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

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

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

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

不同操作的复杂度

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

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

相关推荐
一直都在5724 小时前
Redis (一)
数据库·redis·缓存
秦jh_4 小时前
【Redis】客户端使用
数据库·redis·缓存
我真会写代码4 小时前
Redis核心特性详解:事务、发布订阅与数据删除淘汰策略
java·数据库·redis
IT 行者5 小时前
LangChain4j 集成 Redis 向量存储:我踩过的坑和选型建议
java·人工智能·redis·后端
wenlonglanying5 小时前
nginx 代理 redis
运维·redis·nginx
随风,奔跑6 小时前
Redis
数据库·redis·缓存
TlYf NTLE7 小时前
redis分页查询
数据库·redis·缓存
wangjialelele7 小时前
一文读懂 Redis 持久化与事务
linux·数据库·redis·bootstrap
野犬寒鸦7 小时前
Redis复习记录Day03
服务器·redis·后端·面试·bootstrap·mybatis
曲幽7 小时前
FastAPI里玩转Redis和数据库的正确姿势,别让异步任务把你坑哭了!
redis·python·mysql·fastapi·web·celery·sqlalchemy·task·backgroundtask