0%

极客时间 - 《Redis核心技术与实战》 学习笔记 1

数据结构:快速的 Redis 有哪些慢操作?


  • Redis 表现突出的原因
一方面,这是因为它是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。
另一方面,这要归功于它的数据结构。 这是因为,键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作, 所以高效的数据结构是 Redis 快速处理数据的基础。
  • 底层数据结构一共有 6 种,分别是简单动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。

  • 键和值用什么结构组织?
为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。
一个哈希表,其实就是一个数组,数组的每个元素称为一个哈希桶。 所以,我们常说,一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。
哈希桶中的 entry 元素中保存了 \*key 和 \*value 指针,分别指向了实际的键和值, 这样一来,即使值是一个集合,也可以通过*value指针被查找到。

  • 为什么哈希表操作变慢了?
哈希表的冲突问题和 rehash 可能带来的操作阻塞。
Redis 解决哈希冲突的方式,就是链式哈希。
但是,这里依然存在一个问题,哈希冲突链上的元素只能通过指针逐一查找再操作。
如果哈希表里写入的数据越来越多,哈希冲突可能也会越来越多, 这就会导致某些哈希冲突链过长,进而导致这个链上的元素查找耗时长,效率降低。
对于追求“快”的 Redis 来说,这是不太能接受的。
所以,Redis 会对哈希表做 rehash 操作。

  • 哈希表做 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。
  • 渐进式 rehash
简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求。
每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中。
等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:

  • 对于 String 类型来说,找到哈希桶就能直接增删改查了,所以,哈希表的 O(1) 操作复杂度也就是它的复杂度了。
  • 压缩列表
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。
和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数; 压缩列表在表尾还有一个 zlend,表示列表结束。
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。
而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。

  • 跳表
有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。
具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示: 当数据量很大时,跳表的查找复杂度就是 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),可以实现快速操作。
  • 复杂度较高的 List 类型,它的两种底层实现结构:双向链表和压缩列表的操作复杂度都是 O(N)。因此,因地制宜地使用 List 类型