你好,我是蒋德钧。
一提到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数据类型丰富,每个类型的操作繁多,我们通常无法一下子记住所有操作的复杂度。所以,最好的办法就是掌握原理,以不变应万变。这里,你可以看到,一旦掌握了数据结构基本原理,你可以从原理上推断不同操作的复杂度,即使这个操作你不一定熟悉。这样一来,你不用死记硬背,也能快速合理地做出选择了。
每课一问
整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么Redis还会把它们作为底层数据结构呢?
数据结构是了解Redis性能的必修课,如果你身边还有不太清楚数据结构的朋友,欢迎你把今天的内容分享给他/她,期待你在留言区和我交流讨论。
精选留言
2020-08-04 14:04:19
1、内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。
2、数组对CPU高速缓存支持更友好,所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。
2020-08-25 08:41:17
1,Redis的底层数据结构
二,作者是怎么把这事给讲明白的?
1,讲了Redis的数据结构:数据的保存形式与底层数据结构
2,由数据结构的异同点,引出数据操作的快慢原因
三,为了讲明白,作者讲了哪些要点?有哪些亮点?
1,亮点1:string,list,set,hast,sortset都只是数据的保存形式,底层的数据结构是:简单动态字符串,双向链表,压缩列表,哈希表,跳表,整数数组
2,亮点2:Redis使用了一个哈希表保存所有的键值对
3,要点1:五种数据形式的底层实现
a,string:简单动态字符串
b,list:双向链表,压缩列表
c,hash:压缩列表,哈希表
d,Sorted Set:压缩列表,跳表
e,set:哈希表,整数数组
4,要点2:List ,hash,set ,sorted set被统称为集合类型,一个键对应了一个集合的数据
5,要点3:集合类型的键和值之间的结构组织
a:Redis使用一个哈希表保存所有键值对,一个哈希表实则是一个数组,数组的每个元素称为哈希桶。
b:哈希桶中的元素保存的不是值的本身,而是指向具体值的指针
6,要点4:哈希冲突解决
a:Redis的hash表是全局的,所以当写入大量的key时,将会带来哈希冲突,已经rehash可能带来的操作阻塞
b:Redis解决hash冲突的方式,是链式哈希:同一个哈希桶中的多个元素用一个链表来保存
c:当哈希冲突链过长时,Redis会对hash表进行rehash操作。rehash就是增加现有的hash桶数量,分散entry元素。
7,要点5:rehash机制
a:为了使rehash操作更高效,Redis默认使用了两个全局哈希表:哈希表1和哈希表2,起始时hash2没有分配空间
b:随着数据增多,Redis执行分三步执行rehash;
1,给hash2分配更大的内存空间,如是hash1的两倍
2,把hash1中的数据重新映射并拷贝到哈希表2中
3,释放hash1的空间
8,要点6:渐进式rehash
a:由于步骤2重新映射非常耗时,会阻塞redis
b:讲集中迁移数据,改成每处理一个请求时,就从hash1中的第一个索引位置,顺带将这个索引位置上的所有entries拷贝到hash2中。
9,要点7 :压缩列表,跳表的特点
a:压缩列表类似于一个数组,不同的是:压缩列表在表头有三个字段zlbytes,zltail和zllen分别表示长度,列表尾的偏移量和列表中的entry的个数,压缩列表尾部还有一个zlend,表示列表结束
所以压缩列表定位第一个和最后一个是O(1),但其他就是O(n)
b:跳表:是在链表的基础上增加了多级索引,通过索引的几次跳转,实现数据快速定位
四,对于作者所讲,我有哪些发散性思考?
五,在将来的哪些场景中,我能够用到它?
六,评论区收获
1,数组和压缩列表可以提升内存利用率,因为他们的数据结构紧凑
2,数组对CPU高速缓存支持友好,当数据元素超过阈值时,会转为hash和跳表,保证查询效率
2020-08-19 19:29:07
另外, 在渐进式 rehash 执行期间, 新添加到字典的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表。
2020-08-24 17:05:56
2020-08-03 18:15:23
2020-08-06 09:46:22
The elements are added to a hash table mapping Redis objects to scores. At the same time the elements are added to a skip list mapping scores to Redis objects
按照注释应该还有hash table来额外存储数据吧,这样在zset里查找单个元素,可以从O(logN)降低为O(1)。不知道我理解的是否正确?
2020-12-14 00:29:33
2020-08-03 18:35:46
个人觉得老师应该把sds,dict等具体数据结构的源码也贴上
2020-08-04 22:30:57
2020-08-13 09:05:26
今天的问题 @Kaito 同学回答的很好、尝试回答下: 数组上随机访问是否对cpu缓存友好 ?
数组对cpu缓存友好的原因是: cpu预读取一个cache line大小的数据, 数组数据排列紧凑、相同大小空间保存的元素更多, 访问下一个元素时、恰好已经在cpu缓存了. 如果是随机访问、就不能充分利用cpu缓存了, 拿int元素举例: 一个元素4byte, CacheLine 假设64byte, 可以预读取 16个挨着的元素, 如果下次随机访问的元素不在这16个元素里、就需要重新从内存读取了.
想请教几个问题:
1. rehash是将访问元素所在索引位置上的所有entries都 copy 到 hash表2 ?
如果有索引位置一直没访问到、它会一直留着 hash表1 中 ?
如果是, 再次rehash时这部分没被挪走的索引位置怎么处理 ?
如果不是、那什么时候(时机)触发这部分位置的rehash呢 ?
2. rehash过程中、内存占用会多于原所占内存的2倍 ?
(ht2的内存是ht1的2倍, 原ht1的空间还未释放)
我记得redis设计实现上说 ht2的内存会与ht1实际使用的键值对的数量有关, 扩容好像是 >= ht1.used * 2 的第一个 2^n; 缩容好像是 >= ht1.used 的第一个 2^n
3. rehash完成之后, hash表1 留作下次rehash备用、但会把占用的内存释放掉, 这么理解对不 ?
4. rehash时 为什么是 `复制`, 而不是 `移动`, 这个是有什么考虑吗 ?
我的理解: 移动需要释放原空间, 每个元素都单独释放会导致大量的碎片内存、多次释放也比一次释放效率更低. 不知道是不是考虑错了~~~
2020-08-05 22:51:02
2020-08-03 18:41:57
2020-08-04 22:05:52
1.rehash的时候为啥存储位置会发生变化?一个对象的hashCode始终是一样的。 还是说rehash是对槽进行取模
2.跳表找元素没太看明白,是二分查找么?怎么感觉找33那个元素不止3次
课后一问:
因为数组占用内存连续,不需要随机读取。同时碎片化问题也不需要考虑。
希望得到老师的解答,谢谢
2020-08-03 23:29:59
2020-08-06 16:04:16
1.redis切换全局hash表是否是在将原有hash表的全部内容拷贝完成后切换
2.如果在rehash过程中,已拷贝过的位置后面又有新的冲突值过来了该怎么办
2020-08-17 09:58:41
2020-08-11 21:59:56
2021-01-15 12:56:30
一级索引,
1, 在一级索引中比较1和33的大小,1小于33,
2,继续比较11和33的大小,11比33小,
3,继续比较27和33的大小, 27比33小,
4,继续比较50和33的大小,50比33大,
5,从33位置进入底层数据,比较33和33大小,相等,找到33.
5次比较大小,应该是5次查找到33吧。
同理二级索引需要5次查找到33。
不是应该这样吗?老师
2020-08-07 11:40:02
2020-08-03 19:59:24
数组这种基本数据结构的两个特点:1 连续的内存空间分布,符合程序的局部性原理,可以利用cpu高速缓存;2 根据数组下标随机访问元素的时间复杂度是O(1)。如果实现上可以利用这两个特点,那还是有它的存在价值。