Redis的复合数据结构及设计原理:hash/set/zset

赵帅强

Redis的复合数据结构

我们之前已经讲过了Redis的数组列表(List),但其实Redis中最常用的数据结构是字典(hash),可以说,Redis整体的设计都是基于字典的,这不仅仅体现在我们存取数据都是通过键值对的方式,还在于其他的复合数据结构set/zset也都是基于hash来设计的。

hash 字典

字典在任何语言中都是非常基础和常见的数据结构,在Java中它是HashMap,在PHP中它是Array,在JS中它是Object,它更常见的是通用的数据传输格式JSON
hashTable数据结构 (1).png
字典是一种可变容器型数据结构,可以存储任意类型的数据,它通过哈希表来存储数据和访问,哈希表是其实现原理。

hashTable 哈希表

哈希表又称为散列表,它根据键(key)来直接访问指定存储位置的数据,而确定要访问的指定存储位置是通过散列函数对key值进行压缩生成摘要,生成固定长度的随机字母和数字的字符串,创建散列值,但在hashTable中,我们会使用纯数字。优秀的散列函数会尽量均匀的分布数据,避免散列冲突。

哈希表的主要目的是加快查找指定key的速度,无论hashTable中有多少元素,它的查找效率均为O(1),相当于无需遍历,直接定位到元素。
hashTable数据结构.png

如图所示,哈希表是数组+链表的二维数据结构,数组是第一维,链表是第二维。数组中的每个元素称为槽或者桶,存储着链表的第一个元素的指针。在上图中我们一共有11个槽,现在我们有a-h共8个key需要存储,我么会使用哈希函数对每个key值生成纯数字的摘要,然后将数字对11取模,这样就能够确定该key值应该放在哪个槽中。当多个key值的摘要取模后相等时,就会使用链表进行串联依次存储key值,这种情况称为散列冲突,也叫碰撞。
hashTable数据结构 (2).png

这样,当我们需要取某个key时,只需要对这个key进行重新hash得到摘要,然后取模就能知道它在哪个槽里了,然后通过链表的遍历依次匹配,就能得到指定的key,进而取得value。

无序的字典

这种哈希表遍历的时候是无序的,因为常规的遍历方式是从槽0遍历到槽10,当当前槽存在元素链表时,再按照顺序依次进行遍历,这样我们的遍历顺序就跟存储的时候不同,因此说是无序的。

对于JS的对象来说,我们也说遍历的顺序是无法保证的,但如果元素的集合已经是确定的,那么遍历的顺序应该是一致的呀。这里涉及到的问题就是槽的数量可能不同,因此顺序是完全不同的,在不同浏览器引擎的设计中,初始化设置的槽数不同,扩缩容的时机和空间数也是不同的,因此无法保证。对于Redis也是这样。

扩缩容

我们前面说到字典的查找效率是O(1)的,这是建立在字典能够充分hash的前提下,也就是一维数组要足够用保证二维链表不会过长,否则查找效率会降低到O(lgn),甚至在只有一个槽时降低到O(n)

因此我们会在散列冲突较多时对字典进行扩容,但扩容是以牺牲空间为代价提高效率的,Redis作为内存占用型的缓存系统,可以说内存非常宝贵,因此我们需要在槽空洞过多时进行缩容。

扩容条件:hashTable中元素的个数等于一维数组长度时,会对数组长度进行两倍的扩容。不过如果系统正在做bgsave(后台刷新内存数据到磁盘中)时,会延迟扩容时机。但当元素达到一维数组长度的5倍时,就会强制执行扩容。

缩容条件:元素个数小于一维数组长度的10%

rehash 重新哈希

hashTable数据结构 (4).png

当扩缩容时,我们需要申请新的一维数组,并对所有元素进行重新哈希和挂载元素链表。由于Redis是单线程的,因此我们为了不阻塞服务的正常运转,我们采用渐进式rehash的策略。

也就是所有的字典结构内部首层是一个数组,数组的两个元素分别指向一个哈希表,正常情况下只有一个哈希表,而在迁移过程中会同时保留新旧两个哈希表,元素有可能存在于两个表中的任意一个,因此会同时尝试从两个哈希表中查找数据。当数据搬迁完成后,老的哈希表就会被自动删除。

渐进式hash不是一次性将字典的内容搬完,而是通过每个执行命令时搬运一部分,同时定时任务搬运一部分,最终用时间来换取执行效率。

哈希函数

对于hashTable来说,哈希函数至关重要,因为好的哈希函数可以将哈希表的key值打散的比较均匀,这样高随机性的元素分布也能够提升整体的查找效率。Redis使用的哈希函数是siphash

如果哈希函数打散的效果很差,或者有模式可以遵循,那么就会存在hash攻击,攻击者利用模式的偏向性通过大量产生数据,将这些数据尽可能挂载在同一个链表上,这种hash不均匀会导致查找的性能急剧下降同时浪费大量的内存空间,进而拖垮Redis整体的性能。

set 集合

set和字典非常类似,其内部实现就是上述的hashTable的特殊实现,与字典不同的地方有两点:

  1. 只关注key值,所有的value都是NULL
  2. 在新增数据时会进行去重。

hashTable数据结构 (5).png

zset 有序集合

zSet是Redis非常有特色的数据结构,它是基于Set并提供排序的有序集合。其中最为重要的特点就是支持通过score的权重来指定权重。

zadd code 9.0 "java"
zadd code 8.0 "python"
zadd code 8.5 "php"
zrange code 0 -1
1) "python"
2) "php"
3) "java"

hashTable数据结构 (6).png

此时,所有的value都变成了score。而这种支持通过score来进行排序的则是通过另一个特殊的数据结构:跳跃列表。

skiplist 跳跃列表

hashTable数据结构 (10).png

Redis的zset数据结构是一个复合结构,通过一个类似于set的hashTable来实现value和score的对应关系,也支持set的快速读写和去重的功能。同时通过skiplist来支持按照score排序的功能。

hashTable数据结构 (8).png

上图中每一列代表一个元素,从左到右score值越来越大,最左侧的kv head代表起始位置,score值为MIN_VALUE。最底层用双向链表串联,用于反向遍历,元素的二层以上会有指向下一个同层高元素的单向指针。

增加元素

hashTable数据结构 (9).png

如上图所示,当我们需要根据score值插入紫色kv节点时,我们首先从kv-head的最高层进行启动,判断指针的下个元素的score值是否小于新元素的score值,如果小于,则继续向前遍历,否则从kv-head降一层,重新比较判断。

通过这种对比,我们可以仅仅比较6次就找到合适的新元素位置,这在大量数据的时候性能提升效果非常明显。

从上面的算法可以看出每个元素的层高对该算法的执行效率影响非常明显,如果层高全部一致,效率就会变成O(n),从头遍历的感觉。虽然为了避免这种情况,skiplist除了考虑score值外,还考虑对value值进行字符串对比并进行排序。但是层高算法依然非常重要。

每个元素的层高通过随机算法分配层高,Redis层高总共64层,每层的晋升率为25%,因此1层每个元素是100%,二层是25%,三层就是1/16,四层就是1/64的概率。这样是为了减少层高,能够减少向下遍历的次数,同时能够承载更多的元素也不降低效率。

skipList会记录最高的层高,并将kv-head的高度置为这个层高。

查找元素

查找元素的方法和上述新增元素的方法是一致的。

删除元素

通过上述的查找方式找到元素后,直接删除元素,并更新前后的指针即可。如果最高层数变化了,需要更新下maxLevel参数。

更新元素

当更新元素时,其实就是改变了score值,这时Redis会直接先删除,然后插入。这样在某些场景下效率较低,如score值改变并不影响排序。

元素排名

我们可以获取指定排名段的元素列表,也可以获得指定元素的排名,这个rank其实是通过skiplist的span属性得到的。

我们在每个forward单项指针上都增加了span(跨度)属性,表明从上一个元素到下一个元素中间经过了多少个元素,因此,当我们沿着上面的方法查找时,只需要将经过的所有指针的span值进行累加,就知道指定元素的排名是多少了。

参考资料

  1. 《Redis深度历险 核心原理与应用实践》
阅读 3.7k

前后端的那点小事
四年百度研发,了解一些前端、后端、服务器、数据库、产品等内容,大家一起来交流下。

前端打工人

3.3k 声望
370 粉丝
0 条评论

前端打工人

3.3k 声望
370 粉丝
文章目录
宣传栏