跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表在 Redis 的唯一作用, 就是实现 有序集合zset 数据类型。
跳跃表支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。 在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。
Redis
使用跳跃表作为有序集合(zset)键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis
就会使用跳跃表来作为有序集合键的底层实现。
跳跃表就是在链表的基础上,增加多级索引提升查找效率。
跳跃表由 zskiplistNode
和 zskiplist
两个结构定义,其中 zskiplistNode
表示跳跃表节点,用于 zskiplist
结构保存跳跃表节点的相关信息。
跳表的底层数据结构如下:
最左侧是zskiplist结构,结构包含以下属性:
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 表头节点、表尾节点
unsigned long length;// 表中节点数量
int level;// 表中层数最大的节点的层数
} zskiplis
header
和tail
指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)。length
属性来记录节点的数量,程序可以在O(1)复杂度内返回跳跃表的长度。level
属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内。
图中四个 zskiplistNode
结构,包含如下属性:
typedef struct zskiplistNode {
robj *obj;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;//每一层中的前向指针
unsigned int span;//x.level[i].span
} level[];
} zskiplistNode;
HashMap 的应用非常广泛,最主要的原因就是能够以O(1)的复杂度快速查询。那么是如何实现的呢?
一个最简单的 HashMap 就是一个数组,数组里的每个元素是一个哈希桶(也叫做Bucket),第一个数组元素被编为哈希桶 0,以此类推。当一个键值对的键经过 Hash 函数计算后,再对数组元素个数取模,就能得到该键值对对应的数组元素位置,也就是第几个哈希桶。
字典的底层结构:
typedef struct dictht{
dictEntry **table; //哈希表数组
unsigned long size; //哈希表大小
unsigned long sizemask; //哈希表大小掩码,用于计算索引值,总是等于 size-1
unsigned long used; //该哈希表已有节点的数量
}dictht
如下图,key1 经过哈希计算和哈希值取模后,就对应哈希桶 1,类似的,key3 和key16 分别对应哈希桶 7 和桶 4。
从图上我们还可以看到,需要写入 Hash 表的键空间一共有 16 个键,而 Hash 表的空间大小只有 8 个元素,这样就会导致有些键会对应到相同的哈希桶中。这种情况就是哈希冲突。
为了解决哈希冲突,Redis采用 链式哈希 的方法不同的键对应到相同的哈希桶中。
解决 hash 冲突(哈希冲突)有以下四种方法:
当有相同的键需要插入时,在哈希桶中,就会行成一个链表,链表的节点上记录的就是每个键的值。当查询一个键时,如果对用的哈希桶中存储的是一个链表,就会再次根据键值找到对用的哈希项,这样就避免了哈希冲突。
采用链式哈希解决哈希冲突有一个问题,根据链表的结构,查询非链表头或链表尾的数据复杂度比较高,如果链表太长,会导致查询变慢,因此同一个哈希桶内的链表长度,需要控制。
如果哈希桶内的链表太长怎么处理呢?Redis采用rehash的方式解决,直白一点就是进行扩容。
当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤如下:
ht[0].used * 2n
的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。扩容的条件:
负载因子 = 哈希表已保存节点数量 / 哈希表大小;
渐进式rehash:
扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。
如果保存在 Redis 中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成 Redis 一段时间内不能进行别的操作。所以 Redis 采用渐进式 rehash。
在数据迁移的时候不是一次性将大量数据拷贝进入新的 hash 表,而是在 rehash 期间,每次哈希表元素进行新增、删除、查找或者更新操作操作时,redis 除了会执行对应的操作之外,还会顺序将旧的 hash 表中的索引位置上所有的 key - value 迁移到新的哈希表上;会在最终的某个时间完成哈希表的 rehash 操作;
这样在进行渐进式 rehash 期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行增加操作,一定是在新的哈希表上进行的。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。