参考:
工程使用
- redis的zset
- kafka中的?
- JDK中也提供了跳表的实现,为JUC包中的ConcurrentSkipListMap,和ConcurrentSkipListSet。
起源
存储线性数据,我们一般会使用:
- 数组
- 链表
来进行存储。二者的对比如下:
-
数组
- 优势:支持根据索引的随机访问,因此也支持根据索引的直接替换,对于每个已知索引的元素,我们都可以很轻松地在O(1)时间内访问到。
- 劣势:数组原生不支持动态扩容,每次数组初始化都需要预先为数组分配空间,因此例如JDK中常用的ArrayList,扩容也是通过copy到新数组来达成这一功能的。
-
链表
-
优势:链表相比起数组,对于元素在某个已知元素前后的位置的插入是非常简单的,不同于数组,这个插入不需要对其他位置上的元素进行复制变更,只需要通过指针链接就可以。
同时,链表并没有扩容这一机制,需要更大的容积的时候,只需要往后继续添加即可,不需要考虑预先分配空间的问题。
-
劣势:链表由于只记录了前后节点的信息,因此链表不支持根据随机访问,只能从前往后找。
-
因此,在执行中内存相对来说不那么紧缺的场合(对于局部变量而言,数组比起链表多花费的空间是完全可以接受的,这种场合在现在来说非常常见),数组以及封装数组的数据结构的使用频次要远大于链表。
至此我们得出结论:
- 链表强在扩容,非尾插入,以及空间节省上
- 数组强在提供的随机访问的能力
链表的弱项就在于随机访问的能力,那么:
- 如果我们能通过额外的节点,给链表提供更快的搜索能力
那么我们就可以得到一个链表和数组的混合方案:空间花费小于数组,查询花费小于链表。
实现
维护操作的实现
redis版本:见:juejin.cn/post/684490…
java版本:见:ConcurrentSkipListMap (重点:put,remove)
为了查找更快,我们需要的数据结构可能是有顺序的,假定这个问题我们已经解决了,然后我们开始考虑以下的问题。
既然是要提升随机访问的性能,那么我们可以为一些元素添加索引,像这样:
-o1 --------------- o2 ------------o3-------
-| | |
p1 ---p2-----------p3------p4------p5--------
这样,如果我们要查找p2,我们o1和o2可以定位到p1,p3,查找这个范围内的数据就可以判断p2是否在存储中了。
把抽取出来的不存储实际数据的O1层称为索引层,那么就有点像InnoDB里的B+树了。
当数据增多地时候我们不断地往上抽取索引层,在适当的时机在抽取对索引层创建索引层,这样子就可以使得查询更加地快速。
根据这个思想,我们就可以实现一个具有链表和数组优点的数据结构了。
维护操作
在推导中我们只考虑了查找的操作,根据这个思想我们可以做到我们希望优化的目标了。
但在数据结构的保持方面,为了维持这种对于链表的查找优势,我们就需要花费更大的成本来维护数据结构:
-
考虑以下场景:
-
添加一个节点
-
通过查找,我们可以快速地找到节点应在的位置。我们当然可以直接在非索引直接指向的位置上进行修改,对于已包含索引之外的部分我们再定长抽取索引,但这样有一个问题:在输入N个数之后,我们的查找效率就会受到这些数的稀疏程度的影响,极端情况下可能我们只在前面的若干数建立了索引:
o-o-o-----------o-------------
|-|-|-------------|-----------
p-p-p------------p-------------
这是我们不希望看到的。为此,我们需要做一些额外的工作,来保持索引层指向的数,在下一层中的间隔是尽可能均匀的,来保持我们查找的效率。
-
-
删除一个节点
- 同样的,在数据缩减之后,极端情况下我们也会遇到相同的问题:原来相对均匀的索引指向数据,在若干删除之后,区间变小了,那么我们索引的搜索效率也变慢了。
根据上面的两个分析,我们就需要和B树中一样,维护这些索引项的相对稀疏程度,否则在使用中跳表就会退化为链表,我们数据结构的设计就没有意义了。
我们决定需要做一些额外工作,来维护索引,那么需要考虑以下问题:
-
如何判断需要添加/删除索引节点?
- 如果需要添加/删除索引,我们需要添加/删除几层索引?
-
参考一下java中ConcurrentSkipListMap的实现,我们可以看到:
int rnd = ThreadLocalRandom.nextSecondarySeed();
if ((rnd & 0x80000001) == 0) { // test highest and lowest bits
int level = 1, max;
while (((rnd >>>= 1) & 1) != 0)
++level;
实际上使用了随机数,来决定对于该数需要添加几层索引。随后再根据层数,将这些索引添加到上层的各个对应位置上。
既然添加的时候是随机的方式,那么对于删除,只要将添加在该层的对应所有索引删除即可。
跳表和平衡树的对比
为什么redis使用跳表,而非b树?作者解释是这样的:
There are a few reasons:
They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
总结一下就是:
- 通过参数的设置可以节省内存空间。
- 范围查找操作下,使用双向链表和缓存区域化,性能至少和B+树一样棒。
- 跳表的实现,调试简单。
那么我们也大概知道为啥MySQL索引用的B+树(实际上是innoDB,MyISAM都分文件了),redis用的跳表:
-
实际上两个数据结构实现的逻辑是大致相同的,都是不断增加上层的索引来引导下一层的数据,最终在底层上存储最终的数据。(群簇索引底层存的全数据,二级索引存的索引数据和id)
-
由于MySQL是硬盘存储,redis是内存存储,那么这个地方就是取舍的重要部分:
- MySQL的B+树中的存储单元是数据页,一次读出来很多数据,这样子可以减少磁盘随机IO的次数。
- redis存的就是索引,由于内存的随机访问性能远强于HDD,因此存索引就可以减少空间上的消耗。
-
时间线上的原因:当然还有一个原因,可以查看一下这些东西的发布日期:
-
MySQL版本时间线:www.cc1021.com/article/134…
- 这里可以知道,2001年MySQL中还是将InnoDB认为是新的引擎,InnoDB的开发时间不晚于2001年。
- redis一开始是一个小项目而非InnoDB这种实验室预计要商用的项目,而且开发时间比MySQL的InnoDB晚。
- 跳表是在1990年发明的,而平衡树(B树)这种东西在当时已经在工程上具有大量的实践,这个可能也是考量数据存储选择的一个因素。
-
\