为啥 Redis 能有这么突出的表现呢?一方面,这是因为它是内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。另一方面,这要归功于它的数据结构。这是因为,键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作,所以高效的数据结构是 Redis 快速处理数据的基础。
简单来说,Redis底层结构有5种,分别是简单动态字符串、压缩列表、双向链表、哈希表、跳表和整数数组。
可以看到String就对应简单动态字符串,其他的都有两个底层数据结构。
这里先把简单动态字符串放到下一篇讲解。
哈希表
Redis为了实现快速通过键找到值,就是通过哈希表实现的。
一个哈希表相当是一个数组,数组中的每一个元素就是一个哈希桶,一个哈希表有多个哈希桶,每个哈希桶中保存了键值对。
这里的哈希桶存储的不是值本身,而是指向具体值的指针。不管是String,还是集合类型的都是一样。
下图描述了哈希桶中存储的entry对象中保存了key和value指针,以及发生哈希冲突的next指针,然后分别指向了真实的键和值,这样不管是集合还是String都可以通过value指针找到。
一个哈希表存储了所有的键值对,只需要计算一次hash值就可以以O(1)的时间复杂读快速定位它所对应的哈希桶位置,然后就可以访问相应的 entry 元素。
不管哈希表存储了多少哈希桶就算有10万也好20万也罢,他都会计算一次哈希找到具体的哈希桶。
这其实是因为你忽略了一个潜在的风险,那就是哈希表的冲突问题和 rehash 可能带来的操作阻塞。
redis中的哈希表为什么慢怎么解决呢?
如果哈希表中存了很多的数据,就难免不会发生哈希冲突,也就是说计算哈希值两个key算出来都是一样的,这样就会争抢一个哈希桶。
一般来说哈希桶的个数要小于key的个数,也就是说有些key都放到一个哈希桶中了。
Redis解决哈希冲突的办法就是链式哈希,也就是链表。当计算出相同的hash值时就会看哈希桶中有不有数据了,如果有,这时候就是冲突了,冲突了就会把之前占有哈希桶的数据往下移动,再把新的数据放到哈希桶中,然后通过next指针指向之前下移的数据。
如上图所示的entry对象一样。这样不管哈希桶中有多少数据,entry对象都可以把它链接起来。
但是这里还有问题,就是如果我的链表越来越长,随之查找的速度也就会越来越慢。因为要逐个遍历链表中的数据才能找到对应的值。
为了解决这个问题Redis又想出来一招,rehash操作,rehash 就是增加现有的哈希桶数量,让逐渐增多的 entry 对象能在更多的桶之间分开保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
rehash其实就是对现有的哈希表进行复制到另一个hash表中,Redis会默认开启两个哈希表,称为哈希表1、哈希表2。插入数据时默认采用的时哈希表1,哈希表2还没有被使用到,当桶中的entry对象渐渐变多的时候,rehash会采用一下操作:
- 给哈希表2分配空间,比如时哈希表1大小的一两倍左右;
- 把哈希表1中的数据复制到哈希表2中;
- 最后把哈希表1的空间释放掉。
其实就是用更大的哈希表进行装载,把之前小的哈希表1分散开来装到哈希表2中。
但是, 这个 rehash 动作并不是一次性、集中式地完成的, 而是分多次、渐进式地完成的。
这样做的原因是,如果键值对只有4、5对的话,redis可以瞬间进行rehash。但是如果是一百万,两百万,甚至是上亿的时候,那么要一次性将这些键值对全部 rehash 到哈希表2 的话, 庞大的计算量可能会导致服务器在一段时间内停止服务。
为了避免这个问题redis采用了渐进式rehash。
渐进式rehash
为了解决这个问题,redis不是一次性将数据进行拷贝到哈希表2,而是渐渐的,分多次的,渐进式的将哈希表1的数据慢慢慢慢的rehash到哈希表中。
可以分为以下几步:
- 为哈希表2分配空间,让redis同时持有两个哈希表;
- 再redis中维护一个索引计数器rehashindex,并将它的值设置为0,表示rehash开始工作;
- rehash进行期间,每次对redis执行一次增删改查都会触发rehash把计数器等于0的哈希桶中的数据进行rehash到哈希表2中,程序再将计数器自增1次。
- 随着操作不断,计数器的值也就不断上升。最终计数器就会自增到哈希表1的最后一个位置,这时候再将计数器的值设置为-1,表示rehash工作完成。
渐进式rehash的好处就是,把复制数据分而治之的平均分摊到对redis的所有操作之上,从而避免了集中式 rehash 而带来的庞大计算量。
看图方便理解,注意的是随着对数据的操作而产生的rehash
:
- 准备rehash,准备两个桶,和计数器;
- 将计数器0的哈希桶rehash到哈希桶2中,这个过程可以分散到哈希表2中随机的哈希桶中。
- 知道最后rehash到最后一个哈希桶。
最后将计数器设置成-1表示完成,这就是基本的渐进式rehash操作了。
压缩列表
redis是使用的字节数组表示一个压缩列表,如下图:
- zlbytes:压缩列表的字节长度,占4个字节,因此压缩列表最多有2^32-1个字节;
- zltail:列表尾的偏移量,占4个字节;
- zllen:列表长度,占两个字节;
- entry:压缩列表存储的元素,可以是字节数组或者整数,长度不限;
- zlend:压缩列表的结尾,占1个字节。
在压缩列表中,我们查找第一个元素和最后一个元素,可以通过字段的偏移量或者长度可以直接定位,时间复杂度是O(1)。查找其它元素是,要一个一个遍历,这时候时间复杂度就是O(n)。
跳表
接下来就是最常见的跳表。
跳表其实就是有序链表,只不过是吧有序链表进行了分层。
可以看一下有序链表:
如果我要查询61这个值,就需要依次的遍历1->3->17->21->46->51-61,这个过程有7次比较加遍历。这个时间复杂度是O(n)的。
然后我们将有序链表进行分层,也可以说是加一层索引:
从第一个元素开始每次随机选出一个元素作为链表,通过next指向下一个链表。比如我们要找到61这个元素:
- 我们需要从1开始找到下一个3判断它是不是比61小,如果是继续往下;
- 找到21判断它是不是比61小,是就继续;
- 找到51判断它是不是比61小,是就继续找next指向的下一个元素;
- 找到71,发现它比61大,所以从51节点往下找,找到next指向61,最终就找到了61这个元素。
总共查找6次就可以找到61节点,比有序链表少1次。当数据量大时,优势会更明显。
为了看的明显优势,我们在加一层:
可以看的从1->21->51->61这一次我们只需要了4次就定位到61了。但数据量很大的时候,跳表的查找时间复杂度就是O(logN)。
跳跃表每个节点维护了多个指向其他节点的指针,所以在跳跃表进行查找、插入、删除操作时可以跳过一些节点,快速找到操作需要的节点。归根结底,跳跃表是以牺牲空间
的形式来达到快速查找的目的。跳跃表与平衡树相比,实现方式更简单,只要熟悉有序链表,就可以轻松地掌握跳跃表。
小结
五大数据结构本章只描述了哈希表、压缩列表、跳表这三个,而双向链表和整数数组都太简单了。
- 之所以能快速操作键值对,是因为O(1)的哈希表被广泛使用;
- List类型的底层是压缩列表和双向列表。因此时间复杂度是O(N),建议是适当的使用List类型,毕竟压缩列表操作头尾的速度毕竟快,可以考虑用POP/PUSH的操作,效率相对毕竟高的,毕竟适合搞个队列,而不是随机读写;
掌握Redis底层数据结构很重要。