原文地址:https://time.geekbang.org/col…
博客地址:http://njpkhuan.cn/archives/r…
你好,我是蒋德钧。
一提到 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,这个过程分为三步:
- 给哈希表 2 调配更大的空间,例如是以后哈希表 1 大小的两倍;
- 把哈希表 1 中的数据从新映射并拷贝到哈希表 2 中;
- 开释哈希表 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 和 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 数据类型丰盛,每个类型的操作繁多,咱们通常无奈一下子记住所有操作的复杂度。所以,最好的方法就是 把握原理,以不变应万变。这里,你能够看到,一旦把握了数据结构基本原理,你能够从原理上推断不同操作的复杂度,即便这个操作你不肯定相熟。这样一来,你不必死记硬背,也能疾速正当地做出抉择了。