02Redis的慢操作

前言

Redis 性能优异的原因:

  1. Redis是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。
  2. Redis的键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作,所以高效的数据结构是 Redis 快速处理数据的基础。String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 Sorted Set(有序集合)是Redis 键值对中值的数据类型,也就是数据的保存形式。

一、Redis数据类型的底层实现

底层数据结构6 种:简单动态字符串、双向链表、压缩列表、哈希表、跳表、整数数组

它们和数据类型的对应关系:

Redis数据类型和底层数据结构的对应关系

String 类型的底层实现只有一种数据结构:简单动态字符串。

List、Hash、Set 和 Sorted Set 这四种数据类型,都有两种底层实现结构。这四种类型称为集合类型,特点是:一个键对应了一个集合的数据

二、键和值的结构组织

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

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

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

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

全局哈希表

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

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

三、哈希表操作变慢的原因

哈希冲突:即两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。哈希桶的个数通常要少于 key 的数量,难免会有一些 key 的哈希值对应到了同一个哈希桶中。

3.1、链式哈希

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

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

哈希表的哈希冲突

3.2、rehash

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

为了避免这个问题,Redis 会对哈希表做 rehash 操作。增加哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。

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

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

用增大的哈希表 2 保存更多数据,而原来的哈希表 1 留作下一次 rehash 扩容备用。

3.3、渐进式rehash

第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求,无法快速访问数据了。

为了避免这个问题,Redis 采用了渐进式 rehash。在第二步拷贝数据时,Redis每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。

渐进式rehash

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

四、集合类型的操作效率

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

集合类型即使找到哈希桶了,还要在集合中再进一步操作。

  • 第一步:通过全局哈希表找到对应的哈希桶位置;
  • 第二步:在集合中再增删改查。

影响集合的操作效率的因素 :

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

五、集合类型的底层数据结构

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

  • 哈希表:上文已述;

  • 整数数组:顺序读写,通过数组下标逐个元素访问;操作复杂度是 O(N),操作效率比较低

  • 双向链表:顺序读写,通过指针逐个元素访问,操作复杂度是 O(N),操作效率比较低。

  • 压缩列表:像一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是:
    压缩列表在表头有三个字段 zlbytes(列表长度)、zltail(列表尾的偏移量) 和 zllen(entry 个数)
    压缩列表在表尾还有一个 zlend,表示列表结束。
    压缩列表查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。查找其他元素是逐个查找,复杂度就是 O(N) 了。
    压缩列表

  • 跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,当数据量很大时,跳表的查找复杂度就是 O(logN)
    要在链表中查找 33 这个元素,只能从头开始遍历链表,查找 6 次,直到找到 33 为止,复杂度是 O(N),查找效率很低。
    为了提高查找速度,增加一级索引:从第一个元素开始,每两个元素选一个出来作为索引。这些索引再通过指针指向原始的链表。
    例如,从前两个元素中抽取元素 1 作为一级索引,从第三、四个元素中抽取元素 11 作为一级索引。此时,我们只需要 4 次查找就能定位到元素 33 了。
    如果还想再快,可以再增加二级索引:从一级索引中,再抽取部分元素作为二级索引。例如,从一级索引中抽取 1、27、100 作为二级索引,二级索引指向一级索引。只需要 3 次查找,就能定位到元素 33 了。
    跳表

按照查找的时间复杂度给这些数据结构分下类了:

数据结构的时间复杂度

六、不同操作的复杂度

  • 读写单个集合元素的:例如 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 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),实现了渐进式遍历,每次只返回有限数量的数据。相比于 HGETALL、SMEMBERS 避免了一次性返回所有元素而导致的 Redis 阻塞。

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

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

总结

Redis 的底层数据结构包括:

  • 用来保存每个键和值的全局哈希表结构;
  • 支持集合类型实现的双向链表、压缩列表、整数数组、哈希表、跳表

Redis 能快速操作键值的原因:

  • O(1) 复杂度的哈希表被广泛使用,包括 String、Hash 和 Set,它们的操作复杂度基本由哈希表决定;
  • Sorted Set 用了 O(logN) 复杂度的跳表

不过,集合类型的范围操作要遍历底层数据结构,复杂度通常是 O(N)。建议用其他命令来替代,例如可以用 SCAN 来代替, 避免在 Redis 内部产生费时的全集合遍历操作。

List 类型两种底层实现结构:双向链表和压缩列表的操作复杂度都是 O(N)。建议因地制宜地使用 List 类型。例如,既然它的 POP/PUSH 效率很高,那么就将它主要用于 FIFO 队列场景,而不是作为一个可以随机读写的集合。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

海陆云

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值