在Redis中有五种基本的数据类型,分别是String(字符串)、List(列表)、Hash(哈希)、Set(集合)和有序集合(Sorted Sort)。下次我们详细了解一下,这些在Redis中是如何保存这些数据类型的。
Redis如何保存这些数据类型?
Redis是一个Key-Value的非关系型数据库。说到Key-Value,我们自然能想到哈希表。
Redis也是使用了一个哈希表来保存所有的键值对。
一个哈希表,其实就是一个数组,数组中的每个元素称为一个哈希桶,每个哈希桶中保存了键值对信息。
在Redis中,每个哈希桶保存的并不是值本身,而是指向具体值的指针。如下图所示:
因为这个哈希表保存了所有的键值对,所以可以称之为全局哈希表。哈希表最大的好处就是可以让我们用O(1)的时间复杂度老快速查找到键值对——我们只需要计算键的hash值,就可以知道它所对应的哈希桶的位置,然后就可以访问相应的entry元素。
哈希表存储有一个潜在的问题就是,当存储大量的数据的时候哈希表的冲突问题和rehash可能带来的操作阻塞。
Redis如何解决Hash冲突
哈希冲突:就是两个Key计算哈希值后,落入到了同一个哈希桶中。
Redis解决哈希冲突的方式,就是使用链表。就是当多个元素需要保存在同一个哈希桶中时,用一个链表来保存这些元素。
如下图所示:entry1、entry2和entry3都需要保存在哈希桶3中,导致了hash冲突。此时,entry1元素就会通过一个*next指针指向entry2,同样,entry2也会通过*nxet指针指向entry3。这样,即使哈希桶中的元素再多,也可以通过entry中的指针连接起来。这就形成了一个链表,也叫做hash冲突链。
这就会导致另外一个问题,如果一个桶上的元素过多,这个链表过长,操作元素时只能通过指针逐一查找在操作。这就导致元素查询耗时长,效率降低。这时候就需要rehash。
rehash:就是增加hash桶的数量,让元素尽可能在哈希桶中分散保存,减少单个桶中的元素数量。
为了使rehash操作更加高效,Redis默认使用了两个全局哈希表:哈希表1和哈希表2。一开始,当你刚插入的数据的时候,默认使用哈希表1,此时的哈希表2并没有分配空间,随着数据逐步增多,Redis开始执行Rehash,这个过程分为三步:
- 给哈希表2分配更大的空间,例如是当前哈希表1大小的两倍;
- 把哈希表1中的数据重新映射并拷贝到哈希表2中;
- 释放哈希表1的空间。
此时,我们就可以从哈希表1切换到哈希表2,使用容量较大的哈希表2保存数据,哈希表1则留作下一次rehash使用(也可以认为这时候表1和表2的位置发生了互换)。
这其中第二步操作涉及到了大量的数据拷贝,如果一次性把hash表1中的数据都迁移完,会造成Redis线程阻塞,无法服务其他请求。
为了避免这个问题,redis采用了渐进式rehash。
简单的说就是在第二步拷贝数据是,Redis仍然正常处理客户端请求,每处理一个请求时,会将请求的元素对应的哈希桶上面的所有entries拷贝到哈希表2中,如下图所示:
通过这样的操作,将一次性大量拷贝的工作,分摊到了多次请求出过程中,避免了耗时操作,保证了数据的快速访问。
Redis底层数据结构
Redis的底层数据结构一共有6种,分别为简单动态字符串(SDS)、双向链表、压缩链表、哈希表、跳表和证书数组,它们和数据类型的对应关系如下图所示。
下面主要介绍一下压缩列表和跳表。
压缩列表实际上类似一个数组。和数组不同的是,压缩列表在表头有三个字段zlbytes、zltail和zllen,分别代表列表长度、列表尾部的偏移量和列表中的entry个数;压缩列表在表尾还有一个zlend,表示列表结束。
在压缩列表中,我们如果要查找定位第一个和最后一个元素,可以通过表头的三个字段直接定位,复杂度时O(1),而查找其他元素时,只能逐个查找,此时复杂度是O(n)。
跳表你可以认为是一种特殊的链表。链表查找元素只能逐一查找,而跳表在链表的基础上增加了多级索引,从而可以快速定位元素。
如果我们要查找21这个元素,如果在链表中,只能从头开始遍历链表,直到找到21为止,一共需要查找6次。
现在我们在这个链表上增加一级索引:从第一个元素开始,每两个元素选一个作为索引。这些索引再通过指针指向原始的链表。此时我们只需要4次查找就能定位到元素21。
我们还可以在一级索引的基础上增加二级索引。这样,我们只需要三次查找,就能定位33了。
可以看到这个过程就是在多级索引上跳来跳去,最后定位到元素。所以叫做跳表。
不同的数据结构的查找时间复杂度如下:
名称 | 时间复杂度 |
---|---|
哈希表 | O(1) |
跳表 | O(logN) |
双向链表 | O(N) |
压缩列表 | O(N) |
整数数组 | O(N) |
不同操作的复杂度
在Redis中,不同操作的复杂度时不一样的。
-
单元素的操作的复杂度是由采用的数据结构决定的,例如HGET、HSET、和HDEL是对哈希表做操作,所以他们的复杂度都是O(1);Set类型用哈希表作为底层数据结构时,它的SADD、SREM、SRANDMEMBER复杂度也O(1)。但是当你对多个元素进行操作时,此时操作的复杂度,就是有单个元素复杂度和元素个数决定的。例如,HMSET增加M个元素,复杂度就从O(1)变成O(M)。
-
范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据。这类操作的复杂度一般是O(N),比较耗时,我们应该尽量避免。不过,Redis从2.8版本提供了Scan系统操作(包括HSCAN,SSCAN和ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。避免了一次性返回所有的元素而导致的Redis阻塞。
-
统计操作,是指集合类型对集合中所有元素个数的记录。当集合类型采用压缩列表,双向列表,整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效的完成相关操作。
-
例外情况,对于底层数据结构是压缩列表和双向链表的集合类型来说。比如,List的LPOP、RPOP、LPUSH、RPUSH这四个操作来说,它们只是在列表的头尾增删元素,这样可以通过偏移量直接定位,所以它们的复杂度也只有O(1),可以实现快速操作。
总结来说就是:
-
单元操作最基础;
-
范围操作非常耗时;
-
统计操作通常高效;
-
例外情况只有几个。