1.跳跃表
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表支持**平均O(logN)、最坏O(N)**复杂度的节点查找,还可以通过顺序性操作来批量处理节点。在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。
跳表的查找:
跳表的插入:
先确定该元素要占据的层数K(由随机算法产生),然后在Level 1到Level K各个层的链表都插入元素。随机算法所使用的幂次定律,含义是,如果某件事的发生频率和它的某个属性成幂关系,那么这个频率就可以称之为符合幂次定律。
2.跳跃表在zset中的应用
Redis使用跳跃表作为有序集合zset键的底层实现之一,如果一个zset包含的元素数量比较多,又或者zset中元素的成员是比较长的字符串时,Redis就会使用跳跃表来作为zset键的底层实现。Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。
zset内部的排序功能是通过跳跃列表数据结构来实现的,它的结构非常特殊,也比较复杂。因为zset要支持随机的插入和删除,所以它不好使用数组来表示。我们先看一个普通的链表结构。
我们需要这个链表按照score值进行排序,这意味着当有新元素需要插入时,要定位到特定位置的插入点,这样才可以继续保证链表是有序的。通常我们会通过二分查找来找到插入点,但是二分查找的对象必须是数组,只有数组才可以支持快速位置定位,链表做不到。
跳跃列表使用层级制,最下面一层L0所有的元素都会串起来。然后每隔几个元素挑选出一个代表来,再将这几个代表使用另外一级指针串起来形成L1。然后在这些代表里再挑出二级代表,再串起来形成L2,最终就形成了金字塔结构。
跳跃列表之所以跳跃,是因为内部的元素可能身兼数职,比如上图中间的这个元素,同时处于 L0、 L1 和 L2 层,可以快速在不同层次之间进行跳跃。定位插入点时,先在顶层进行定位,然后下潜到下一级定位,一直下潜到最底层找到合适的位置,将新元素插进去。
那新插入的元素如何才有机会身兼数职呢?**跳跃列表采取一个随机策略来决定新元素可以兼职到第几层。**首先 L0层肯定是 100% 了, L1层只有50%的概率, L2层只有25%的概率, L3层只有 12.5%的概率,一直随机到最顶层L31层。绝大多数元素都过不了几层,只有极少数元素可以深入到顶层。列表中的元素越多,能够深入的层次就越深。
Redis的跳跃表由zskiplistNode和zskiplist两个结构定义,其中zskiplistNode结构用于表示跳跃表节点,而zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
3.跳跃表节点
跳跃表节点的结构如下:
(1)层
跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。每次创建一个新跳跃表节点的时候,程序都根据幂次定律(powerlaw,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。
(2)前进指针
每个层都有一个指向表尾方向的前进指针(level[0].forward属性),用于从表头向表尾方向访问节点。这个指针一定是指向后续节点的首地址,而非zskiplistLevel结构的地址。
(3)跨度
层的跨度(level[0].span属性)用于记录两个节点之间的距离,跨度代表两个节点的Rank之差。
- 两个节点之间的跨度越大,它们相距得就越远。
- 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点。
遍历操作只使用前进指针就可以完成,与跨度无关,跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。举个例子,图5-4用虚线标记了在跳跃表中查找分值为3.0、成员对象为o3的节点时,沿途经历的层:查找的过程只经过了一个层,并且层的跨度为3,所以目标节点在跳跃表中的排位为3。
(4)后退指针
节点的后退指针(backward属性)用于从表尾向表头方向访问节点,与可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
(5)分值
节点的分值(score属性)是一个double类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
(6)成员
节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,字符串对象保存着一个SDS值。在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的,分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面,成员对象较大的节点则会排在后面。
4.跳跃表
多个跳跃表节点就可以组成一个跳跃表,通过使用一个zskiplist结构来维护这些节点。zskiplist的结构如图:
header和tail指针分别指向跳跃表的表头和表尾节点,程序定位表头节点和表尾节点的复杂度为O(1)。当跳跃表中元素为n个时,实际上有n+1个结点,多出来的那个结点就是跳跃表的头结点,头结点的score值为0,obj为null,backward后退指针指向null,并且默认有32个层level[0…31]。
length属性来记录节点的数量,查询跳跃表的长度的复杂度为O(1)。
level属性则用于获取跳跃表中节点的层数量,获取跳跃表层数量的复杂度为O(1),注意表头节点的层高并不计算在内。
5.有序集合对象实现方式
(1)编码类型
有序集合的编码可以是ziplist或者skiplist。
ziplist编码的有序集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,前面节点保存元素的值,后面节点则保存元素的分值,压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。ziplist编码的有序集合对象的结构如下。
skiplist编码的有序集合对象使用zset结构作为底层实现一个zset结构同时包含一个字典和一个跳跃表,结构如下。
zset结构中的*zsl跳跃表按分值从小到大保存了所有集合元素,每个跳跃表节点都保存了一个集合元素。跳跃表节点的object属性保存了元素的值,跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的。
zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素。字典的键保存了元素的值,字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定值的分值,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。
有序集合每个元素的值都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。虽然zset结构同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。
(2)有序集合同时使用跳跃表和字典的原因
在理论上,有序集合可以单独使用字典或者跳跃表的其中一种数据结构来实现,但无论单独使用字典还是跳跃表,在性能上对比起同时使用字典和跳跃表都会有所降低。为了让有序集合的查找和范围型操作都尽可能快地执行,Redis选择了同时使用字典和跳跃表两种数据结构来实现有序集合。
如果我们只使用字典来实现有序集合,查找成员的分值的复杂度为O(1)。但是,因为字典以无序的方式来保存集合元素,所以每次在执行范围型操作——比如 ZRANK、ZRANGE等命令时,程序都需要对字典保存的所有元素进行排序,完成这种排序需要至少O(NlogN)时间复杂度,以及额外的O(N)内存空间(因为要创建一个数组来保存排序后的元素)。
如果我们只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,没有了字典,成员查找分值的复杂度将从O(1)上升为O(logN)。
(3)编码的转换
当有序集合对象可以同时满足以下两个条件时,使用ziplist编码,否则使用skiplist编码。
- 有序集合保存的元素数量小于128个;
- 有序集合保存的所有元素成员的长度都小于64字节;
以上两个条件的上限值是可以修改的,修改配置文件中zset-max-ziplistentries选项和zset-max-ziplist-value选项。当使用ziplist编码所需的两个条件中的任意一个不能被满足时,就会执行对象的编码转换操作,原本保存在压缩列表里的所有集合元素都会被转移并保存到zset结构里面,对象的编码也会从 ziplist变为skiplist。